From 89c152097e372fc019e550c8ef32b9e4c8ebd75a Mon Sep 17 00:00:00 2001 From: Nick Phura Date: Fri, 12 Apr 2024 16:27:37 -0700 Subject: [PATCH 01/31] Update documentation/readmes. (#1246) Remove old n8n files/references. --- .docker/nginx/n8n/Dockerfile | 11 - .docker/nginx/n8n/dev.conf | 19 - .gitignore | 5 - README.md | 65 +- README/Initial Setup.md | 8 +- README/Occurrence Template.md | 69 - README/Summary Template.md | 52 - .../images/templates/advanced properties.png | Bin 78401 -> 0 bytes README/images/templates/custom tab.png | Bin 66112 -> 0 bytes README/images/templates/excel file.png | Bin 54912 -> 0 bytes .../images/templates/open shift secrets.png | Bin 29238 -> 0 bytes README/images/templates/s3 setup.png | Bin 55493 -> 0 bytes .../images/templates/successful migration.png | Bin 39929 -> 0 bytes api/.docker/api/Dockerfile | 4 + api/Dockerfile | 4 +- api/README.md | 39 +- app/.docker/app/Dockerfile | 4 + app/Dockerfile | 4 +- app/README.md | 6 - containers/n8n/Dockerfile | 21 - containers/n8n/README.md | 42 - containers/n8n/n8n.bc.yaml | 87 - containers/n8n/n8n.dc.yaml | 585 -- .../postgresql12-postgis31-oracle-fdw.bc.yaml | 3 + database/.docker/db/Dockerfile | 4 +- database/.docker/db/Dockerfile.setup | 4 +- env_config/env.docker | 11 +- n8n/.docker/n8n/Dockerfile.export | 21 - n8n/.docker/n8n/Dockerfile.setup | 21 - n8n/README.md | 20 - n8n/credentials/1.json | 9 - n8n/package-lock.json | 8932 ----------------- n8n/package.json | 33 - n8n/workflows/1.json | 235 - n8n/workflows/2.json | 158 - n8n/workflows/3.json | 200 - n8n/workflows/4.json | 466 - testing/postman/README.md | 6 +- 38 files changed, 87 insertions(+), 11061 deletions(-) delete mode 100644 .docker/nginx/n8n/Dockerfile delete mode 100644 .docker/nginx/n8n/dev.conf delete mode 100644 README/Occurrence Template.md delete mode 100644 README/Summary Template.md delete mode 100644 README/images/templates/advanced properties.png delete mode 100644 README/images/templates/custom tab.png delete mode 100644 README/images/templates/excel file.png delete mode 100644 README/images/templates/open shift secrets.png delete mode 100644 README/images/templates/s3 setup.png delete mode 100644 README/images/templates/successful migration.png delete mode 100644 containers/n8n/Dockerfile delete mode 100644 containers/n8n/README.md delete mode 100644 containers/n8n/n8n.bc.yaml delete mode 100644 containers/n8n/n8n.dc.yaml delete mode 100644 n8n/.docker/n8n/Dockerfile.export delete mode 100644 n8n/.docker/n8n/Dockerfile.setup delete mode 100644 n8n/README.md delete mode 100644 n8n/credentials/1.json delete mode 100644 n8n/package-lock.json delete mode 100644 n8n/package.json delete mode 100644 n8n/workflows/1.json delete mode 100644 n8n/workflows/2.json delete mode 100644 n8n/workflows/3.json delete mode 100644 n8n/workflows/4.json diff --git a/.docker/nginx/n8n/Dockerfile b/.docker/nginx/n8n/Dockerfile deleted file mode 100644 index b25dfbc549..0000000000 --- a/.docker/nginx/n8n/Dockerfile +++ /dev/null @@ -1,11 +0,0 @@ -FROM nginx:stable-alpine - -RUN mkdir -p /usr/app - -WORKDIR /usr/app - -# remove any existing conf file -RUN rm /etc/nginx/conf.d/default.conf - -# copy our nginx conf file -COPY /dev.conf /etc/nginx/conf.d diff --git a/.docker/nginx/n8n/dev.conf b/.docker/nginx/n8n/dev.conf deleted file mode 100644 index 732cb31b9a..0000000000 --- a/.docker/nginx/n8n/dev.conf +++ /dev/null @@ -1,19 +0,0 @@ -server { - listen 5100; - - location / { - proxy_pass http://n8n:5678; - proxy_redirect default; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Host $server_name; - - if ($request_method = 'OPTIONS') { - add_header "Access-Control-Allow-Origin" "*"; - add_header "Access-Control-Allow-Methods" "GET, POST, OPTIONS, HEAD"; - add_header "Access-Control-Allow-Headers" "Authorization, Origin, X-Requested-With, Content-Type, Accept, sessionid"; - return 204; - } - } -} diff --git a/.gitignore b/.gitignore index 20122e6644..eae043fe6c 100644 --- a/.gitignore +++ b/.gitignore @@ -109,10 +109,5 @@ dist # Apple macOS folder attributes file **/.DS_Store -# n8N - ignore root level temp config folder -.n8n -n8n/.n8n -n8n/.config - # IDE custom workspace settings .vscode/settings.json diff --git a/README.md b/README.md index ec17250735..020a947546 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ [![img](https://img.shields.io/badge/Lifecycle-Experimental-339999)](https://github.com/bcgov/repomountie/blob/master/doc/lifecycle-badges.md) [![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=bcgov_biohubbc&metric=alert_status)](https://sonarcloud.io/dashboard?id=bcgov_biohubbc) [![codecov](https://codecov.io/gh/bcgov/biohubbc/branch/dev/graph/badge.svg?token=CF2ZR3T3U2)](https://codecov.io/gh/bcgov/biohubbc) [![BioHubBC](https://img.shields.io/endpoint?url=https://dashboard.cypress.io/badge/simple/w8oxci/dev&style=flat&logo=cypress)](https://dashboard.cypress.io/projects/w8oxci/runs) -# BioDiversityHub BC - updated +# Species Inventory Management System (SIMS) Sub-project under the SEISM Capital project, the source of BC’s species inventory data. @@ -104,9 +104,9 @@ _Note: Run all commands in a terminal that supports make. On Mac you can use the ## Initialize the `./env` file. -This will copy `./env_config/env.docker` to `./.env`. -This should only need to be run once. -This file may need additional editing to provide secrets for external services (like S3). +This will copy `./env_config/env.docker` to `./.env`. . +The `.env` file may need additional editing to provide secrets for external services (like S3). +The `.env` file should never be committed! ``` make env @@ -115,6 +115,28 @@ make env Result of running `make env` for the first time: ![make env screenshot](README/images/make/running_make_env.png "Running `make env`") +### Modifying Environment Variables + +There are several places where environment variables need to be defined, depending on whether you are running the apps +locally or in Openshift. + +Below are all of the relevant files that need to be updated when modifying environment variables. + +#### Local Development + +- `env.docker` +- `docker-compose.yml` +- `app/src/contexts/configContext.tsx` + +#### Deployed to OpenShift + +- `[api/app/database]/.pipeline/**` +- `.pipeline/configMaps/sims.configmap.yaml` + - Changes to the configmap also need to be reflected in OpenShift. See [.pipeline/configMaps/README.md](.pipeline/configMaps/README.md) +- `server/index.js` +- `app/src/contexts/configContext.tsx` +- OpenShift Secrets (tools, dev, test, prod) + ## Start all Applications Starts all applications (database, api, app). @@ -166,7 +188,7 @@ After you've run `make clean`, running `make web` will launch new containers, wi make clean ``` -## View the logs for a container +## Viewing the logs for a container ### API @@ -192,26 +214,32 @@ make log-db make log-db-setup ``` -## Run Linter and Fix Issues +## Linting and Formatting -Will run the projects code linter and attempt to fix all issues found. +### Run Linter and Fix Issues -_Note: Not all formatting issues can be auto-fixed._ +Will run the projects code linter and attempt to fix all issues found. ``` make lint-fix ``` -## Run Formatter and Fix Issues +### Run Formatter and Fix Issues Will run the projects code formatter and attempt to fix all issues found. -_Note: Not all formatting issues can be auto-fixed._ - ``` make format-fix ``` +### Run Both Linter and Formatter and Fix Issues + +Will run both the projects code linter and code formatter and attempt to fix all issues found. + +``` +make fix +``` + ## Shell Into a Docker Container (database, api, app, etc) See `./Makefile` for all available commands. @@ -296,20 +324,7 @@ If you already had PostgreSQL (PSQL) installed, it is likely that the default po ## The App Works Locally But Not In OpenShift -Ensure that any new environment variables have been included in all of the necessary files. - -Local Development - -- `env.docker` -- `docker-compose.yml` -- `app/src/contexts/configContext.tsx` - -Deployed to OpenShift - -- `[api/app/database]/.pipeline/**` -- `server/index.js` -- `app/src/contexts/configContext.tsx` -- OpenShift Secrets [dev,test,prod] +See the section on [Modifying Environment Variables](#modifying-environment-variables) # Helpful Tools diff --git a/README/Initial Setup.md b/README/Initial Setup.md index ca39cc6af3..e0f06cb082 100644 --- a/README/Initial Setup.md +++ b/README/Initial Setup.md @@ -110,7 +110,7 @@ Result of running `make env` for the first time: ## Start all Applications -Starts all applications (database, api, app, and n8n). +Starts all applications (database, api, app). ``` make web @@ -129,10 +129,6 @@ app: - `localhost:7100` -n8n: - -- `localhost:5100` - # Helpful Makefile Commands See `./Makefile` for all available commands. @@ -209,7 +205,7 @@ _Note: Not all formatting issues can be auto-fixed._ make format-fix ``` -## Shell Into a Docker Container (database, api, app, n8n, etc) +## Shell Into a Docker Container (database, api, app, etc) See `./Makefile` for all available commands. diff --git a/README/Occurrence Template.md b/README/Occurrence Template.md deleted file mode 100644 index 1115f984cb..0000000000 --- a/README/Occurrence Template.md +++ /dev/null @@ -1,69 +0,0 @@ -# _NOTE: NEEDS UPDATING_ - -# BioHub Templates - -## Observation Templates - -## Adding a new template - -1. Get the summary template from Confluence BioHub BC & SIMS -> Data and Standards -> SIMS Templates -2. Use the examples in the folder below to create a new validation object: - -``` -biohubbc/database/src/migrations/template_methodology_species_validations/new_template.ts -``` - -3. Add any new `Picklist Values` to: - -``` -biohubbc/database/src/template_methodology_species_validations/picklist_variables/v0.2.ts -``` - -4. Create a migration for the new template validation object. Use previous template migrations as examples to get started. Your migration file name should start with a timestamp in the format: YYYYMMDDHHmmss - -``` -biohubbc/database/src/migrations/YYYYMMDDHHmmss_new_migration.ts -``` - -5. Run make commands to migrate the database. - -``` -// run database setup and check the logs -make db-setup -make log-db-setup -``` - -If the migration fails for any reason, make your changes and re-run the process - -``` -// cleans the existing DB and rebuilds it -make clean db-setup -make log-db-setup -``` - -A successful migration will look something like this - -![Successful Migration](./images/templates/successful%20migration.png) - -6. Update Template Properties with `sims_template_id` and `sims_csm_id` values. These are required so the system can find the correct template to validate with. - - 1. Open the template in excel - 2. Navigate to File tab in the ribbon - 3. Then select Info -> Properties -> Advanced Properties - ![Advanced Properties](./images/templates/advanced%20properties.png) - - 4. In the new panel navigate to the Custom tab - - ![Custom Tab](./images/templates/custom%20tab.png) - - 5. Add `sims_template_id`. This values comes from `template_id` in the `template_methodology_species` table - 6. Add `sims_csm_id`. This values comes from `field_method_id` in the `template_methodology_species` table - -7. Add tempalte to resource page in SIMS (BioHub) - -``` -biohubbc/app/src/resources/ResourcesPage.tsx -``` - -8. [Connect](./S3%20Browser.md) and add file to S3 Bucket templates folder -9. Update original template in confluence if anything has changed (fixed fields, spelling, worksheet names ect.) diff --git a/README/Summary Template.md b/README/Summary Template.md deleted file mode 100644 index 11b021e0af..0000000000 --- a/README/Summary Template.md +++ /dev/null @@ -1,52 +0,0 @@ -# _NOTE: NEEDS UPDATING_ - -# Summary Templates - -The summary templates operate slightly differently than the Occurence templates. They should all be the same shape of tempalte. The Picklist values for each of these templates changs so that is something to be aware of. This readme will outline a few important places in code as well as the current (Sept. 13, 2022) requirements for the summary templates. These templates don't require migrations as all the validation is done in code. - -## Setup - -1. Get the summary template from Confluence BioHub BC & SIMS -> Data and Standards -> SIMS Templates -2. Compare template to [required fields](#required-fields-and-types) -3. Test template validation -4. Add tempalte to resource page in SIMS (BioHub) -5. [Connect](./S3%20Browser.md) and add file to S3 Bucket templates folder - -### Important locations - -``` -// this file outlines all the actions that a template goes through -// this is where the file is uploaded to S3 -// this is where the file is prepped and validated as well -biohubbc/api/src/project/{projectId}/survey/{surveyId}/summary/submission/upload.ts - -// Resource Page lists all templates available to users -biohubbc/app/src/resources/ResourcesPage.tsx -``` - -### Required Fields and types - -| Column | Type | Description | -| ------------------------------ | -------- | -------------------------------------------------- | -| Study Area | Text | -| Population Unit | Text | -| Block ID/SU ID | Text | -| Parameter | Picklist | Parameter Statistic, species specific descriptions | -| Stratum | Numeric | -| Observed | Numeric | -| Estimated | Numeric | -| Sightability Model | Picklist | Sightability Model | -| Sightability Correction Factor | Numeric | -| SE | Numeric | -| Coefficient of Variation (%) | Numeric | -| Confidence Level (%) | Numeric | -| Lower CL | Numeric | -| Upper CL | Numeric | -| Total Suvey Area (km2) | Numeric | -| Area Flown (km2) | Numeric | -| Total Kilometers Surveyed (km) | Numeric | -| Best Parameter Value Flag | Picklist | Best Paramter Flag | -| Outlier Blocks Removed | Text | -| Total Marked Animals Observed | Numeric | -| Marked Animals Available | Numeric | -| Parameter Comments | Text | diff --git a/README/images/templates/advanced properties.png b/README/images/templates/advanced properties.png deleted file mode 100644 index 4d98a07c32c0e781bebde48bb7cf5ccc733cacc3..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 78401 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 z%0A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

E88nkU0s%-prBSfgq4jQ#Apo^ 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 W|EKFHHQq|>;$$S{CGuYCfBqjp3TrR` diff --git a/README/images/templates/open shift secrets.png b/README/images/templates/open shift secrets.png deleted file mode 100644 index b5a36f42aa4a1e10f9029732ad30b7a6c9d53308..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 29238 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 { setColumnVisibilityMenuAnchorEl(event.currentTarget)} + title="Configure Columns" disabled={disabled}> diff --git a/app/src/features/surveys/observations/observations-table/export-button/ExportHeadersButton.tsx b/app/src/features/surveys/observations/observations-table/export-button/ExportHeadersButton.tsx index 1cd20eb70d..3234319e7e 100644 --- a/app/src/features/surveys/observations/observations-table/export-button/ExportHeadersButton.tsx +++ b/app/src/features/surveys/observations/observations-table/export-button/ExportHeadersButton.tsx @@ -37,7 +37,7 @@ const ExportHeadersButton = () => { }; return ( - + ); diff --git a/app/src/features/surveys/observations/observations-table/import-observations/ImportObservationsButton.tsx b/app/src/features/surveys/observations/observations-table/import-obsevations/ImportObservationsButton.tsx similarity index 53% rename from app/src/features/surveys/observations/observations-table/import-observations/ImportObservationsButton.tsx rename to app/src/features/surveys/observations/observations-table/import-obsevations/ImportObservationsButton.tsx index 82d92ac38b..78c4ab2666 100644 --- a/app/src/features/surveys/observations/observations-table/import-observations/ImportObservationsButton.tsx +++ b/app/src/features/surveys/observations/observations-table/import-obsevations/ImportObservationsButton.tsx @@ -11,10 +11,36 @@ import { useBiohubApi } from 'hooks/useBioHubApi'; import { useContext, useState } from 'react'; export interface IImportObservationsButtonProps { - disabled: boolean; + /** + * If true, the button will be disabled. + * + * @type {boolean} + * @memberof IImportObservationsButtonProps + */ + disabled?: boolean; + /** + * Callback fired when the import process is started. + * + * @memberof IImportObservationsButtonProps + */ onStart?: () => void; + /** + * Callback fired when the import process is successful. + * + * @memberof IImportObservationsButtonProps + */ onSuccess?: () => void; + /** + * Callback fired when the import process encounters an error. + * + * @memberof IImportObservationsButtonProps + */ onError?: () => void; + /** + * Callback fired when the import process is complete (success or error). + * + * @memberof IImportObservationsButtonProps + */ onFinish?: () => void; } @@ -37,45 +63,43 @@ export const ImportObservationsButton = (props: IImportObservationsButtonProps) * @return {*} */ const handleImportObservations = async (file: File) => { - return biohubApi.observation.uploadCsvForImport(projectId, surveyId, file).then((response) => { - setOpen(false); - + try { onStart?.(); - biohubApi.observation - .processCsvSubmission(projectId, surveyId, response.submissionId) - .then(() => { - dialogContext.setSnackbar({ - snackbarMessage: ( - - {ObservationsTableI18N.importRecordsSuccessSnackbarMessage} - - ), - open: true - }); + const uploadResponse = await biohubApi.observation.uploadCsvForImport(projectId, surveyId, file); + + await biohubApi.observation.processCsvSubmission(projectId, surveyId, uploadResponse.submissionId); + + setOpen(false); + + dialogContext.setSnackbar({ + snackbarMessage: ( + + {ObservationsTableI18N.importRecordsSuccessSnackbarMessage} + + ), + open: true + }); - onSuccess?.(); - }) - .catch((apiError: any) => { - dialogContext.setErrorDialog({ - dialogTitle: ObservationsTableI18N.importRecordsErrorDialogTitle, - dialogText: ObservationsTableI18N.importRecordsErrorDialogText, - dialogErrorDetails: [apiError.message], - open: true, - onClose: () => { - dialogContext.setErrorDialog({ open: false }); - }, - onOk: () => { - dialogContext.setErrorDialog({ open: false }); - } - }); + onSuccess?.(); + } catch (apiError: any) { + dialogContext.setErrorDialog({ + dialogTitle: ObservationsTableI18N.importRecordsErrorDialogTitle, + dialogText: ObservationsTableI18N.importRecordsErrorDialogText, + dialogErrorDetails: [apiError.message], + open: true, + onClose: () => { + dialogContext.setErrorDialog({ open: false }); + }, + onOk: () => { + dialogContext.setErrorDialog({ open: false }); + } + }); - onError?.(); - }) - .finally(() => { - onFinish?.(); - }); - }); + onError?.(); + } finally { + onFinish?.(); + } }; return ( @@ -93,6 +117,7 @@ export const ImportObservationsButton = (props: IImportObservationsButtonProps) dialogTitle="Import Observation CSV" onClose={() => setOpen(false)} onUpload={handleImportObservations} + uploadButtonLabel="Import" FileUploadProps={{ dropZoneProps: { maxNumFiles: 1, acceptedFileExtensions: '.csv' }, status: UploadFileStatus.STAGED diff --git a/app/src/features/surveys/observations/sampling-sites/SamplingSiteHeader.tsx b/app/src/features/surveys/observations/sampling-sites/SamplingSiteHeader.tsx index 35e8d9725e..d159c0b59f 100644 --- a/app/src/features/surveys/observations/sampling-sites/SamplingSiteHeader.tsx +++ b/app/src/features/surveys/observations/sampling-sites/SamplingSiteHeader.tsx @@ -20,6 +20,13 @@ export interface ISamplingSiteHeaderProps { title: string; breadcrumb: string; } + +/** + * Renders the header of the Sampling Site page. + * + * @param {*} props + * @return {*} + */ export const SamplingSiteHeader: React.FC = (props) => { const history = useHistory(); const formikProps = useFormikContext(); diff --git a/app/src/features/surveys/observations/sampling-sites/SamplingSitePage.tsx b/app/src/features/surveys/observations/sampling-sites/SamplingSitePage.tsx index 3f212f359c..32e91ed3fe 100644 --- a/app/src/features/surveys/observations/sampling-sites/SamplingSitePage.tsx +++ b/app/src/features/surveys/observations/sampling-sites/SamplingSitePage.tsx @@ -40,6 +40,11 @@ export interface ICreateSamplingSiteRequest { stratums: IGetSurveyStratum[]; } +/** + * Renders the body content of the Sampling Site page. + * + * @return {*} + */ const SamplingSitePage = () => { const history = useHistory(); const biohubApi = useBiohubApi(); diff --git a/app/src/features/surveys/observations/sampling-sites/components/SamplingSiteMapControl.tsx b/app/src/features/surveys/observations/sampling-sites/components/SamplingSiteMapControl.tsx index 7501a9a6a5..ed319b1f20 100644 --- a/app/src/features/surveys/observations/sampling-sites/components/SamplingSiteMapControl.tsx +++ b/app/src/features/surveys/observations/sampling-sites/components/SamplingSiteMapControl.tsx @@ -223,7 +223,7 @@ const SamplingSiteMapControl = (props: ISamplingSiteMapControlProps) => { {samplingSiteGeoJsonFeatures.length > 0 && ( - + // Array of sampling site features const samplingSiteGeoJsonFeatures: Feature[] = useMemo(() => get(values, name), [values, name]); - const updateStaticLayers = (geoJsonFeatures: Feature[]) => { - setUpdatedBounds(calculateUpdatedMapBounds(geoJsonFeatures)); + const updateStaticLayers = useCallback( + (geoJsonFeatures: Feature[]) => { + setUpdatedBounds(calculateUpdatedMapBounds(geoJsonFeatures)); - const staticLayers: IStaticLayer[] = [ - { - layerName: 'Sampling Sites', - features: samplingSiteGeoJsonFeatures.map((feature: Feature, index) => ({ geoJSON: feature, key: index })) - } - ]; + const staticLayers: IStaticLayer[] = [ + { + layerName: 'Sampling Sites', + features: samplingSiteGeoJsonFeatures.map((feature: Feature, index) => ({ geoJSON: feature, key: index })) + } + ]; - setStaticLayers(staticLayers); - }; + setStaticLayers(staticLayers); + }, + [samplingSiteGeoJsonFeatures] + ); const [editedGeometry, setEditedGeometry] = useState(undefined); @@ -218,7 +221,7 @@ const SamplingSiteEditMapControl = (props: ISamplingSiteEditMapControlProps) => {samplingSiteGeoJsonFeatures.length > 0 && ( - + { - const surveyContext = useContext(SurveyContext); - const codesContext = useContext(CodesContext); - const dialogContext = useContext(DialogContext); + const surveyContext = useSurveyContext(); + const codesContext = useCodesContext(); + const dialogContext = useDialogContext(); + const observationsPageContext = useObservationsPageContext(); const biohubApi = useBiohubApi(); useEffect(() => { @@ -251,7 +243,7 @@ const SamplingSiteList = () => { vertical: 'top', horizontal: 'right' }}> - + @@ -284,7 +276,8 @@ const SamplingSiteList = () => { color="primary" component={RouterLink} to={'sampling'} - startIcon={}> + startIcon={} + disabled={observationsPageContext.isDisabled}> Add { }} aria-label="header-settings" disabled={!checkboxSelectedIds.length} - onClick={handleHeaderMenuClick}> + onClick={handleHeaderMenuClick} + title="Bulk Actions"> @@ -371,148 +365,13 @@ const SamplingSiteList = () => { {surveyContext.sampleSiteDataLoader.data?.sampleSites.map((sampleSite) => { return ( - - - } - aria-controls="panel1bh-content" - sx={{ - flex: '1 1 auto', - py: 0, - pr: 8.5, - pl: 0, - height: 55, - overflow: 'hidden', - '& .MuiAccordionSummary-content': { - flex: '1 1 auto', - py: 0, - pl: 0, - overflow: 'hidden', - whiteSpace: 'nowrap' - } - }}> - - { - event.stopPropagation(); - handleCheckboxChange(sampleSite.survey_sample_site_id); - }} - inputProps={{ 'aria-label': 'controlled' }} - /> - - - {sampleSite.name} - - - - ) => - handleSampleSiteMenuClick(event, sampleSite.survey_sample_site_id) - } - aria-label="sample-site-settings"> - - - - - - {sampleSite.sample_methods?.map((sampleMethod) => { - return ( - - - - {sampleMethod.sample_periods?.map((samplePeriod) => { - return ( - - - - - - - {`${samplePeriod.start_date} ${samplePeriod.start_time ?? ''} - ${ - samplePeriod.end_date - } ${samplePeriod.end_time ?? ''}`} - - - - ); - })} - - - ); - })} - - - + /> ); })} diff --git a/app/src/features/surveys/observations/sampling-sites/list/SamplingSiteListMethod.tsx b/app/src/features/surveys/observations/sampling-sites/list/SamplingSiteListMethod.tsx new file mode 100644 index 0000000000..4a287d3e40 --- /dev/null +++ b/app/src/features/surveys/observations/sampling-sites/list/SamplingSiteListMethod.tsx @@ -0,0 +1,61 @@ +import grey from '@mui/material/colors/grey'; +import List from '@mui/material/List'; +import ListItem from '@mui/material/ListItem'; +import ListItemText from '@mui/material/ListItemText'; +import { SamplingSiteListPeriod } from 'features/surveys/observations/sampling-sites/list/SamplingSiteListPeriod'; +import { useCodesContext } from 'hooks/useContext'; +import { IGetSampleMethodRecord } from 'interfaces/useSurveyApi.interface'; +import { useEffect } from 'react'; +import { getCodesName } from 'utils/Utils'; + +export interface ISamplingSiteListMethodProps { + sampleMethod: IGetSampleMethodRecord; +} + +/** + * Renders a list item for a single sampling method. + * + * @param {ISamplingSiteListMethodProps} props + * @return {*} + */ +export const SamplingSiteListMethod = (props: ISamplingSiteListMethodProps) => { + const { sampleMethod } = props; + + const codesContext = useCodesContext(); + + useEffect(() => { + codesContext.codesDataLoader.load(); + }, [codesContext.codesDataLoader]); + + return ( + + + + {sampleMethod.sample_periods?.map((samplePeriod) => { + return ( + + ); + })} + + + ); +}; diff --git a/app/src/features/surveys/observations/sampling-sites/list/SamplingSiteListPeriod.tsx b/app/src/features/surveys/observations/sampling-sites/list/SamplingSiteListPeriod.tsx new file mode 100644 index 0000000000..bfcdfde6d5 --- /dev/null +++ b/app/src/features/surveys/observations/sampling-sites/list/SamplingSiteListPeriod.tsx @@ -0,0 +1,64 @@ +import { mdiCalendarRange } from '@mdi/js'; +import Icon from '@mdi/react'; +import ListItem from '@mui/material/ListItem'; +import ListItemIcon from '@mui/material/ListItemIcon'; +import ListItemText from '@mui/material/ListItemText'; +import Typography from '@mui/material/Typography'; +import { ImportObservationsButton } from 'features/surveys/observations/sampling-sites/list/import-observations/ImportObservationsButton'; +import { useObservationsContext, useObservationsPageContext } from 'hooks/useContext'; +import { IGetSamplePeriodRecord } from 'interfaces/useSurveyApi.interface'; + +export interface ISamplingSiteListPeriodProps { + samplePeriod: IGetSamplePeriodRecord; +} + +/** + * Renders a list item for a single sampling period. + * + * @param {ISamplingSiteListPeriodProps} props + * @return {*} + */ +export const SamplingSiteListPeriod = (props: ISamplingSiteListPeriodProps) => { + const { samplePeriod } = props; + + const observationsContext = useObservationsContext(); + const observationsPageContext = useObservationsPageContext(); + + return ( + + + + + + + {`${samplePeriod.start_date} ${samplePeriod.start_time ?? ''} - ${samplePeriod.end_date} ${ + samplePeriod.end_time ?? '' + }`} + + + { + observationsPageContext.setIsDisabled(true); + observationsPageContext.setIsLoading(true); + }} + onSuccess={() => { + observationsContext.observationsDataLoader.refresh(); + }} + onFinish={() => { + observationsPageContext.setIsDisabled(false); + observationsPageContext.setIsLoading(false); + }} + processOptions={{ surveySamplePeriodId: samplePeriod.survey_sample_period_id }} + /> + + ); +}; diff --git a/app/src/features/surveys/observations/sampling-sites/list/SamplingSiteListSite.tsx b/app/src/features/surveys/observations/sampling-sites/list/SamplingSiteListSite.tsx new file mode 100644 index 0000000000..1be36f2f51 --- /dev/null +++ b/app/src/features/surveys/observations/sampling-sites/list/SamplingSiteListSite.tsx @@ -0,0 +1,128 @@ +import { mdiChevronDown, mdiDotsVertical } from '@mdi/js'; +import Icon from '@mdi/react'; +import Accordion from '@mui/material/Accordion'; +import AccordionDetails from '@mui/material/AccordionDetails'; +import AccordionSummary from '@mui/material/AccordionSummary'; +import Box from '@mui/material/Box'; +import Checkbox from '@mui/material/Checkbox'; +import grey from '@mui/material/colors/grey'; +import IconButton from '@mui/material/IconButton'; +import List from '@mui/material/List'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import { SamplingSiteListMethod } from 'features/surveys/observations/sampling-sites/list/SamplingSiteListMethod'; +import { IGetSampleLocationDetails } from 'interfaces/useSurveyApi.interface'; + +export interface ISamplingSiteListSiteProps { + sampleSite: IGetSampleLocationDetails; + isChecked: boolean; + handleSampleSiteMenuClick: (event: React.MouseEvent, sample_site_id: number) => void; + handleCheckboxChange: (sampleSiteId: number) => void; +} + +/** + * Renders a list item for a single sampling site. + * + * @param {ISamplingSiteListSiteProps} props + * @return {*} + */ +export const SamplingSiteListSite = (props: ISamplingSiteListSiteProps) => { + const { sampleSite, isChecked, handleSampleSiteMenuClick, handleCheckboxChange } = props; + + return ( + + + } + aria-controls="panel1bh-content" + sx={{ + flex: '1 1 auto', + py: 0, + pr: 8.5, + pl: 0, + height: 55, + overflow: 'hidden', + '& .MuiAccordionSummary-content': { + flex: '1 1 auto', + py: 0, + pl: 0, + overflow: 'hidden', + whiteSpace: 'nowrap' + } + }}> + + { + event.stopPropagation(); + handleCheckboxChange(sampleSite.survey_sample_site_id); + }} + inputProps={{ 'aria-label': 'controlled' }} + /> + + + {sampleSite.name} + + + + ) => + handleSampleSiteMenuClick(event, sampleSite.survey_sample_site_id) + } + aria-label="sample-site-settings"> + + + + + + {sampleSite.sample_methods?.map((sampleMethod) => { + return ( + + ); + })} + + + + ); +}; diff --git a/app/src/features/surveys/observations/sampling-sites/list/import-observations/ImportObservationsButton.tsx b/app/src/features/surveys/observations/sampling-sites/list/import-observations/ImportObservationsButton.tsx new file mode 100644 index 0000000000..47f7ee0dea --- /dev/null +++ b/app/src/features/surveys/observations/sampling-sites/list/import-observations/ImportObservationsButton.tsx @@ -0,0 +1,154 @@ +import { mdiImport } from '@mdi/js'; +import Icon from '@mdi/react'; +import IconButton from '@mui/material/IconButton'; +import Typography from '@mui/material/Typography'; +import FileUploadDialog from 'components/dialog/FileUploadDialog'; +import { UploadFileStatus } from 'components/file-upload/FileUploadItem'; +import { ObservationsTableI18N } from 'constants/i18n'; +import { DialogContext } from 'contexts/dialogContext'; +import { SurveyContext } from 'contexts/surveyContext'; +import { useBiohubApi } from 'hooks/useBioHubApi'; +import { useContext, useState } from 'react'; + +export interface IImportObservationsButtonProps { + /** + * If true, the button will be disabled. + * + * @type {boolean} + * @memberof IImportObservationsButtonProps + */ + disabled?: boolean; + /** + * Callback fired when the import process is started. + * + * @memberof IImportObservationsButtonProps + */ + onStart?: () => void; + /** + * Callback fired when the import process is successful. + * + * @memberof IImportObservationsButtonProps + */ + onSuccess?: () => void; + /** + * Callback fired when the import process encounters an error. + * + * @memberof IImportObservationsButtonProps + */ + onError?: () => void; + /** + * Callback fired when the import process is complete (success or error). + * + * @memberof IImportObservationsButtonProps + */ + onFinish?: () => void; + /** + * Options to pass to the process csv submission endpoint. + * + * @type {{ + * surveySamplePeriodId?: number; + * }} + * @memberof IImportObservationsButtonProps + */ + processOptions?: { + /** + * An optional survey sample period id. All imported observation records will be associated to this sample period. + * + * @type {number} + */ + surveySamplePeriodId?: number; + }; +} + +/** + * Renders a button that allows the user to import observation records from a CSV file. + * + * @param {IImportObservationsButtonProps} props + * @return {*} + */ +export const ImportObservationsButton = (props: IImportObservationsButtonProps) => { + const { disabled, onStart, onSuccess, onError, onFinish, processOptions } = props; + + const biohubApi = useBiohubApi(); + + const surveyContext = useContext(SurveyContext); + const { projectId, surveyId } = surveyContext; + + const dialogContext = useContext(DialogContext); + + const [open, setOpen] = useState(false); + + /** + * Callback fired when the user attempts to import observations. + * + * @param {File} file + * @return {*} + */ + const handleImportObservations = async (file: File) => { + try { + onStart?.(); + + const uploadResponse = await biohubApi.observation.uploadCsvForImport(projectId, surveyId, file); + + await biohubApi.observation.processCsvSubmission( + projectId, + surveyId, + uploadResponse.submissionId, + processOptions + ); + + setOpen(false); + + dialogContext.setSnackbar({ + snackbarMessage: ( + + {ObservationsTableI18N.importRecordsSuccessSnackbarMessage} + + ), + open: true + }); + + onSuccess?.(); + } catch (apiError: any) { + dialogContext.setErrorDialog({ + dialogTitle: ObservationsTableI18N.importRecordsErrorDialogTitle, + dialogText: ObservationsTableI18N.importRecordsErrorDialogText, + dialogErrorDetails: [apiError.message], + open: true, + onClose: () => { + dialogContext.setErrorDialog({ open: false }); + }, + onOk: () => { + dialogContext.setErrorDialog({ open: false }); + } + }); + + onError?.(); + } finally { + onFinish?.(); + } + }; + + return ( + <> + setOpen(true)} + disabled={disabled || false} + aria-label="Import Observations"> + + + setOpen(false)} + onUpload={handleImportObservations} + uploadButtonLabel="Import" + FileUploadProps={{ + dropZoneProps: { maxNumFiles: 1, acceptedFileExtensions: '.csv' }, + status: UploadFileStatus.STAGED + }} + /> + + ); +}; diff --git a/app/src/features/surveys/telemetry/telemetry-table/ManualTelemetryTableContainer.tsx b/app/src/features/surveys/telemetry/telemetry-table/ManualTelemetryTableContainer.tsx index 1c6c05a33c..b3ce95da62 100644 --- a/app/src/features/surveys/telemetry/telemetry-table/ManualTelemetryTableContainer.tsx +++ b/app/src/features/surveys/telemetry/telemetry-table/ManualTelemetryTableContainer.tsx @@ -169,6 +169,7 @@ const ManualTelemetryTableContainer = () => { dialogTitle="Import Telemetry CSV" onClose={() => setShowImportDialog(false)} onUpload={handleFileImport} + uploadButtonLabel="Import" FileUploadProps={{ dropZoneProps: { maxNumFiles: 1, acceptedFileExtensions: '.csv' }, status: UploadFileStatus.STAGED diff --git a/app/src/hooks/api/useObservationApi.test.ts b/app/src/hooks/api/useObservationApi.test.ts new file mode 100644 index 0000000000..ff2f18e90b --- /dev/null +++ b/app/src/hooks/api/useObservationApi.test.ts @@ -0,0 +1,66 @@ +import axios from 'axios'; +import MockAdapter from 'axios-mock-adapter'; +import useObservationApi from 'hooks/api/useObservationApi'; + +describe('useObservationApi', () => { + let mock: any; + + beforeEach(() => { + mock = new MockAdapter(axios); + }); + + afterEach(() => { + mock.restore(); + }); + + describe('uploadCsvForImport', () => { + it('works as expected', async () => { + const projectId = 1; + const surveyId = 2; + const file = new File([''], 'file.txt', { type: 'application/plain' }); + + const res = { + submissionId: 1 + }; + + mock.onPost(`/api/project/${projectId}/survey/${surveyId}/observations/upload`).reply(200, res); + + const result = await useObservationApi(axios).uploadCsvForImport(projectId, surveyId, file); + + expect(result).toEqual(res); + }); + }); + + describe('processCsvSubmission', () => { + it('works as expected', async () => { + const projectId = 1; + const surveyId = 2; + const submissionId = 3; + + const res = undefined; + + mock.onPost(`/api/project/${projectId}/survey/${surveyId}/observations/process`).reply(200, res); + + const result = await useObservationApi(axios).processCsvSubmission(projectId, surveyId, submissionId); + + expect(result).toEqual(res); + }); + + it('works as expected with options', async () => { + const projectId = 1; + const surveyId = 2; + const submissionId = 3; + const options = { + surveySamplePeriodId: 4 + }; + + const res = undefined; + + mock.onPost(`/api/project/${projectId}/survey/${surveyId}/observations/process`).reply(200, res); + + const result = await useObservationApi(axios).processCsvSubmission(projectId, surveyId, submissionId, options); + + expect(result).toEqual(res); + }); + }); +}); diff --git a/app/src/hooks/api/useObservationApi.ts b/app/src/hooks/api/useObservationApi.ts index 3ad0c222f7..e6564eadc6 100644 --- a/app/src/hooks/api/useObservationApi.ts +++ b/app/src/hooks/api/useObservationApi.ts @@ -133,6 +133,9 @@ const useObservationApi = (axios: AxiosInstance) => { * @param {number} projectId * @param {number} surveyId * @param {File} file + * @param {{ + * samplingPeriodId: number; + * }} [options] * @param {CancelTokenSource} [cancelTokenSource] * @param {(progressEvent: AxiosProgressEvent) => void} [onProgress] * @return {*} {Promise<{ submissionId: number }>} @@ -153,7 +156,6 @@ const useObservationApi = (axios: AxiosInstance) => { formData, { cancelToken: cancelTokenSource?.token, - onUploadProgress: onProgress } ); @@ -167,11 +169,22 @@ const useObservationApi = (axios: AxiosInstance) => { * @param {number} projectId * @param {number} surveyId * @param {number} submissionId - * @return {*} + * @param {{ + * surveySamplePeriodId?: number; + * }} [options] + * @return {*} {Promise} */ - const processCsvSubmission = async (projectId: number, surveyId: number, submissionId: number) => { - const { data } = await axios.post(`/api/project/${projectId}/survey/${surveyId}/observations/process`, { - observation_submission_id: submissionId + const processCsvSubmission = async ( + projectId: number, + surveyId: number, + submissionId: number, + options?: { + surveySamplePeriodId?: number; + } + ): Promise => { + const { data } = await axios.post(`/api/project/${projectId}/survey/${surveyId}/observations/process`, { + observation_submission_id: submissionId, + options }); return data; diff --git a/app/src/hooks/useContext.tsx b/app/src/hooks/useContext.tsx index 78f17dee4f..512079926e 100644 --- a/app/src/hooks/useContext.tsx +++ b/app/src/hooks/useContext.tsx @@ -2,6 +2,7 @@ import { CodesContext, ICodesContext } from 'contexts/codesContext'; import { ConfigContext, IConfig } from 'contexts/configContext'; import { DialogContext, IDialogContext } from 'contexts/dialogContext'; import { IObservationsContext, ObservationsContext } from 'contexts/observationsContext'; +import { IObservationsPageContext, ObservationsPageContext } from 'contexts/observationsPageContext'; import { IObservationsTableContext, ObservationsTableContext } from 'contexts/observationsTableContext'; import { IProjectContext, ProjectContext } from 'contexts/projectContext'; import { ISurveyContext, SurveyContext } from 'contexts/surveyContext'; @@ -111,6 +112,23 @@ export const useObservationsContext = (): IObservationsContext => { return context; }; +/** + * Returns an instance of `IObservationsPageContext` from `ObservationsPageContext`. + * + * @return {*} {IObservationsPageContext} + */ +export const useObservationsPageContext = (): IObservationsPageContext => { + const context = useContext(ObservationsPageContext); + + if (!context) { + throw Error( + 'ObservationsPageContext is undefined, please verify you are calling useObservationsPageContext() as child of an component.' + ); + } + + return context; +}; + /** * Returns an instance of `IObservationsTableContext` from `ObservationsTableContext`. * diff --git a/app/tsconfig.json b/app/tsconfig.json index fcbba6241a..689a7e580c 100644 --- a/app/tsconfig.json +++ b/app/tsconfig.json @@ -25,5 +25,8 @@ "strict": true, "typeRoots": ["node_modules/@types", "src/types"] }, - "include": ["src"] + "include": ["src"], + "ts-node": { + "swc": true + } } diff --git a/database/.prettierrc b/database/.prettierrc index a064d97523..bc87cb6766 100644 --- a/database/.prettierrc +++ b/database/.prettierrc @@ -6,11 +6,11 @@ "singleQuote": true, "trailingComma": "none", "bracketSpacing": true, - "jsxBracketSameLine": true, + "bracketSameLine": true, "requirePragma": false, "insertPragma": false, "proseWrap": "never", "endOfLine": "lf", "arrowParens": "always", "htmlWhitespaceSensitivity": "ignore" -} \ No newline at end of file +} diff --git a/database/package-lock.json b/database/package-lock.json index f3da4095a9..8aaaf6ebf5 100644 --- a/database/package-lock.json +++ b/database/package-lock.json @@ -9,6 +9,7 @@ "version": "0.0.0", "license": "Apache-2.0", "dependencies": { + "@swc/core": "^1.3.76", "knex": "^2.4.2", "pg": "^8.7.1", "typescript": "^4.2.4" @@ -262,6 +263,206 @@ "node": ">= 8" } }, + "node_modules/@swc/core": { + "version": "1.4.12", + "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.4.12.tgz", + "integrity": "sha512-QljRxTaUajSLB9ui93cZ38/lmThwIw/BPxjn+TphrYN6LPU3vu9/ykjgHtlpmaXDDcngL4K5i396E7iwwEUxYg==", + "hasInstallScript": true, + "dependencies": { + "@swc/counter": "^0.1.2", + "@swc/types": "^0.1.5" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/swc" + }, + "optionalDependencies": { + "@swc/core-darwin-arm64": "1.4.12", + "@swc/core-darwin-x64": "1.4.12", + "@swc/core-linux-arm-gnueabihf": "1.4.12", + "@swc/core-linux-arm64-gnu": "1.4.12", + "@swc/core-linux-arm64-musl": "1.4.12", + "@swc/core-linux-x64-gnu": "1.4.12", + "@swc/core-linux-x64-musl": "1.4.12", + "@swc/core-win32-arm64-msvc": "1.4.12", + "@swc/core-win32-ia32-msvc": "1.4.12", + "@swc/core-win32-x64-msvc": "1.4.12" + }, + "peerDependencies": { + "@swc/helpers": "^0.5.0" + }, + "peerDependenciesMeta": { + "@swc/helpers": { + "optional": true + } + } + }, + "node_modules/@swc/core-darwin-arm64": { + "version": "1.4.12", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.4.12.tgz", + "integrity": "sha512-BZUUq91LGJsLI2BQrhYL3yARkcdN4TS3YGNS6aRYUtyeWrGCTKHL90erF2BMU2rEwZLLkOC/U899R4o4oiSHfA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-darwin-x64": { + "version": "1.4.12", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.4.12.tgz", + "integrity": "sha512-Wkk8rq1RwCOgg5ybTlfVtOYXLZATZ+QjgiBNM7pIn03A5/zZicokNTYd8L26/mifly2e74Dz34tlIZBT4aTGDA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm-gnueabihf": { + "version": "1.4.12", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.4.12.tgz", + "integrity": "sha512-8jb/SN67oTQ5KSThWlKLchhU6xnlAlnmnLCCOKK1xGtFS6vD+By9uL+qeEY2krV98UCRTf68WSmC0SLZhVoz5A==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm64-gnu": { + "version": "1.4.12", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.4.12.tgz", + "integrity": "sha512-DhW47DQEZKCdSq92v5F03rqdpjRXdDMqxfu4uAlZ9Uo1wJEGvY23e1SNmhji2sVHsZbBjSvoXoBLk0v00nSG8w==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm64-musl": { + "version": "1.4.12", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.4.12.tgz", + "integrity": "sha512-PR57pT3TssnCRvdsaKNsxZy9N8rFg9AKA1U7W+LxbZ/7Z7PHc5PjxF0GgZpE/aLmU6xOn5VyQTlzjoamVkt05g==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-x64-gnu": { + "version": "1.4.12", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.4.12.tgz", + "integrity": "sha512-HLZIWNHWuFIlH+LEmXr1lBiwGQeCshKOGcqbJyz7xpqTh7m2IPAxPWEhr/qmMTMsjluGxeIsLrcsgreTyXtgNA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-x64-musl": { + "version": "1.4.12", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.4.12.tgz", + "integrity": "sha512-M5fBAtoOcpz2YQAFtNemrPod5BqmzAJc8pYtT3dVTn1MJllhmLHlphU8BQytvoGr1PHgJL8ZJBlBGdt70LQ7Mw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-arm64-msvc": { + "version": "1.4.12", + "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.4.12.tgz", + "integrity": "sha512-K8LjjgZ7VQFtM+eXqjfAJ0z+TKVDng3r59QYn7CL6cyxZI2brLU3lNknZcUFSouZD+gsghZI/Zb8tQjVk7aKDQ==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-ia32-msvc": { + "version": "1.4.12", + "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.4.12.tgz", + "integrity": "sha512-hflO5LCxozngoOmiQbDPyvt6ODc5Cu9AwTJP9uH/BSMPdEQ6PCnefuUOJLAKew2q9o+NmDORuJk+vgqQz9Uzpg==", + "cpu": [ + "ia32" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-x64-msvc": { + "version": "1.4.12", + "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.4.12.tgz", + "integrity": "sha512-3A4qMtddBDbtprV5edTB/SgJn9L+X5TL7RGgS3eWtEgn/NG8gA80X/scjf1v2MMeOsrcxiYhnemI2gXCKuQN2g==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/counter": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", + "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==" + }, + "node_modules/@swc/types": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.6.tgz", + "integrity": "sha512-/JLo/l2JsT/LRd80C3HfbmVpxOAJ11FO2RCEslFrgzLltoP9j8XIbsyDcfCt2WWyX+CM96rBoNM+IToAkFOugg==", + "dependencies": { + "@swc/counter": "^0.1.3" + } + }, "node_modules/@tsconfig/node10": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz", diff --git a/database/package.json b/database/package.json index 08fa4c8274..8cc795e623 100644 --- a/database/package.json +++ b/database/package.json @@ -23,6 +23,7 @@ "fix": "npm-run-all -l -s lint-fix format-fix" }, "dependencies": { + "@swc/core": "^1.3.76", "knex": "^2.4.2", "pg": "^8.7.1", "typescript": "^4.2.4" diff --git a/database/tsconfig.json b/database/tsconfig.json index 7be0e43c6d..a2b46bb5c9 100644 --- a/database/tsconfig.json +++ b/database/tsconfig.json @@ -24,5 +24,8 @@ "strict": true, "typeRoots": ["node_modules/@types"] }, - "include": ["src"] + "include": ["src"], + "ts-node": { + "swc": true + } } From 45197381d3e0901fa5751c0c69b3499977f67826 Mon Sep 17 00:00:00 2001 From: Nick Phura Date: Thu, 18 Apr 2024 13:52:11 -0700 Subject: [PATCH 05/31] BugFix: Sample Method Edit Bug (#1274) * Fix sample method edit bug --- api/package-lock.json | 447 ++++++++---------- .../sample-site/{surveySampleSiteId}/index.ts | 3 +- .../surveys/components/EditSamplingMethod.tsx | 5 +- .../surveys/components/MethodForm.tsx | 4 - .../surveys/components/SamplingMethodForm.tsx | 12 +- .../edit/components/SampleMethodEditForm.tsx | 12 +- 6 files changed, 207 insertions(+), 276 deletions(-) diff --git a/api/package-lock.json b/api/package-lock.json index c7bd235700..9edcdb3e5f 100644 --- a/api/package-lock.json +++ b/api/package-lock.json @@ -521,9 +521,9 @@ } }, "node_modules/@colors/colors": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.6.0.tgz", - "integrity": "sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", + "integrity": "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==", "engines": { "node": ">=0.1.90" } @@ -1197,9 +1197,9 @@ } }, "node_modules/@types/qs": { - "version": "6.9.14", - "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.14.tgz", - "integrity": "sha512-5khscbd3SwWMhFqylJBLQ0zIu7c1K6Vz0uBIt915BI3zV0q1nfjRQD3RqSBcPaO6PHEF4ov/t9y89fSiyThlPA==" + "version": "6.9.15", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.15.tgz", + "integrity": "sha512-uXHQKES6DQKKCLh441Xv/dwxOq1TVS3JPUMlEqoEglvlhR6Mxnlew/Xq/LRVHpLyk7iK3zODe1qYHIMltO7XGg==" }, "node_modules/@types/range-parser": { "version": "1.2.7", @@ -1599,6 +1599,15 @@ } } }, + "node_modules/ansi-colors": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.1.tgz", + "integrity": "sha512-JoX0apGbHaUJBNl6yF+p6JAFYZ666/hhCGKN5t9QFjbJQKUU/g8MNbFDbvfrgKXvI1QpZplPOnwIo99lX/AAmA==", + "dev": true, + "engines": { + "node": ">=6" + } + }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -2010,27 +2019,6 @@ "node": ">=0.8.0" } }, - "node_modules/busboy/node_modules/isarray": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", - "integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==" - }, - "node_modules/busboy/node_modules/readable-stream": { - "version": "1.1.14", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.1.14.tgz", - "integrity": "sha512-+MeVjFf4L44XUkhM1eYbD8fyEsxcV81pqMSR5gblfcLCHfZvbrqy4/qYHE+/R5HoBUT11WV5O08Cr1n3YXkWVQ==", - "dependencies": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.1", - "isarray": "0.0.1", - "string_decoder": "~0.10.x" - } - }, - "node_modules/busboy/node_modules/string_decoder": { - "version": "0.10.31", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", - "integrity": "sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ==" - }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -2090,9 +2078,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001609", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001609.tgz", - "integrity": "sha512-JFPQs34lHKx1B5t1EpQpWH4c+29zIyn/haGsbpfq3suuV9v56enjFt23zqijxGTMwy1p/4H2tjnQMY+p1WoAyA==", + "version": "1.0.30001610", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001610.tgz", + "integrity": "sha512-QFutAY4NgaelojVMjY63o6XlZyORPaLfyMnsl3HgnWdJUcX6K0oaJymHjH8PT5Gk7sTm8rvC/c5COUQKXqmOMA==", "dev": true, "funding": [ { @@ -2156,10 +2144,16 @@ } }, "node_modules/chokidar": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", - "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", + "integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==", "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + ], "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", @@ -2172,9 +2166,6 @@ "engines": { "node": ">= 8.10.0" }, - "funding": { - "url": "https://paulmillr.com/funding/" - }, "optionalDependencies": { "fsevents": "~2.3.2" } @@ -2317,6 +2308,33 @@ "typedarray": "^0.0.6" } }, + "node_modules/concat-stream/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/concat-stream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + }, + "node_modules/concat-stream/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/content-disposition": { "version": "0.5.4", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", @@ -2586,6 +2604,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/default-require-extensions/node_modules/strip-bom": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", + "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", + "dev": true, + "engines": { + "node": ">=8" + } + }, "node_modules/define-data-property": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", @@ -2656,27 +2683,6 @@ "node": ">=0.8.0" } }, - "node_modules/dicer/node_modules/isarray": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", - "integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==" - }, - "node_modules/dicer/node_modules/readable-stream": { - "version": "1.1.14", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.1.14.tgz", - "integrity": "sha512-+MeVjFf4L44XUkhM1eYbD8fyEsxcV81pqMSR5gblfcLCHfZvbrqy4/qYHE+/R5HoBUT11WV5O08Cr1n3YXkWVQ==", - "dependencies": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.1", - "isarray": "0.0.1", - "string_decoder": "~0.10.x" - } - }, - "node_modules/dicer/node_modules/string_decoder": { - "version": "0.10.31", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", - "integrity": "sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ==" - }, "node_modules/diff": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/diff/-/diff-5.0.0.tgz", @@ -2740,9 +2746,9 @@ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" }, "node_modules/electron-to-chromium": { - "version": "1.4.735", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.735.tgz", - "integrity": "sha512-pkYpvwg8VyOTQAeBqZ7jsmpCjko1Qc6We1ZtZCjRyYbT5v4AIUKDy5cQTRotQlSSZmMr8jqpEt6JtOj5k7lR7A==", + "version": "1.4.739", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.739.tgz", + "integrity": "sha512-koRkawXOuN9w/ymhTNxGfB8ta4MRKVW0nzifU17G1UwTWlBg0vv7xnz4nxDnRFSBe9nXMGRgICcAzqXc0PmLeA==", "dev": true }, "node_modules/emoji-regex": { @@ -4114,6 +4120,14 @@ "node": ">= 0.4" } }, + "node_modules/interpret": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/interpret/-/interpret-2.2.0.tgz", + "integrity": "sha512-Ju0Bz/cEia55xDwUWEa8+olFpCiQoypjnQySseKtmjNrnps3P+xfpUmGr90T7yjlVJmOtybRvPXhKMbHr+fWnw==", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -4555,18 +4569,6 @@ "node": ">=8" } }, - "node_modules/istanbul-lib-processinfo/node_modules/p-map": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/p-map/-/p-map-3.0.0.tgz", - "integrity": "sha512-d3qXVTF/s+W+CdJ5A29wywV2n8CQQYahlgz2bFiA+4eVNJbHJodPZ+/gXwPGh0bOqA+j8S+6+ckmvLGPk1QpxQ==", - "dev": true, - "dependencies": { - "aggregate-error": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/istanbul-lib-report": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", @@ -4610,15 +4612,6 @@ "node": ">=10" } }, - "node_modules/istanbul-lib-source-maps/node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/istanbul-reports": { "version": "3.1.7", "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.1.7.tgz", @@ -4858,13 +4851,10 @@ } } }, - "node_modules/knex/node_modules/interpret": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/interpret/-/interpret-2.2.0.tgz", - "integrity": "sha512-Ju0Bz/cEia55xDwUWEa8+olFpCiQoypjnQySseKtmjNrnps3P+xfpUmGr90T7yjlVJmOtybRvPXhKMbHr+fWnw==", - "engines": { - "node": ">= 0.10" - } + "node_modules/knex/node_modules/pg-connection-string": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.6.1.tgz", + "integrity": "sha512-w6ZzNu6oMmIzEAYVw+RLK0+nqHPt8K3ZnknKi+g48Ak2pr3dtljJW3o+D/n2zzCG07Zoe9VOX3aiKpj+BN0pjg==" }, "node_modules/knex/node_modules/resolve-from": { "version": "5.0.0", @@ -4912,15 +4902,6 @@ "node": ">=4" } }, - "node_modules/load-json-file/node_modules/strip-bom": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", - "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", - "dev": true, - "engines": { - "node": ">=4" - } - }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -5035,6 +5016,14 @@ "node": ">= 12.0.0" } }, + "node_modules/logform/node_modules/@colors/colors": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.6.0.tgz", + "integrity": "sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==", + "engines": { + "node": ">=0.1.90" + } + }, "node_modules/loupe": { "version": "2.3.7", "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.7.tgz", @@ -5045,12 +5034,14 @@ } }, "node_modules/lru-cache": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-4.0.2.tgz", - "integrity": "sha512-uQw9OqphAGiZhkuPlpFGmdTU2tEuhxTourM/19qGJrxBPHAr/f8BT1a0i/lOclESnGatdJG/UCkP9kZB/Lh1iw==", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", "dependencies": { - "pseudomap": "^1.0.1", - "yallist": "^2.0.0" + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" } }, "node_modules/lru-memoizer": { @@ -5062,6 +5053,20 @@ "lru-cache": "~4.0.0" } }, + "node_modules/lru-memoizer/node_modules/lru-cache": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-4.0.2.tgz", + "integrity": "sha512-uQw9OqphAGiZhkuPlpFGmdTU2tEuhxTourM/19qGJrxBPHAr/f8BT1a0i/lOclESnGatdJG/UCkP9kZB/Lh1iw==", + "dependencies": { + "pseudomap": "^1.0.1", + "yallist": "^2.0.0" + } + }, + "node_modules/lru-memoizer/node_modules/yallist": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-2.1.2.tgz", + "integrity": "sha512-ncTzHV7NvsQZkYe1DW7cbDLm0YpzHmZF5r/iyP3ZnQtMiJ+pjzisCiMNI+Sj+xQF5pXhSHxSB3uDbsBTzY/c2A==" + }, "node_modules/make-dir": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", @@ -5242,42 +5247,6 @@ "node": ">= 14.0.0" } }, - "node_modules/mocha/node_modules/ansi-colors": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.1.tgz", - "integrity": "sha512-JoX0apGbHaUJBNl6yF+p6JAFYZ666/hhCGKN5t9QFjbJQKUU/g8MNbFDbvfrgKXvI1QpZplPOnwIo99lX/AAmA==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/mocha/node_modules/chokidar": { - "version": "3.5.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", - "integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==", - "dev": true, - "funding": [ - { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - ], - "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" - }, - "engines": { - "node": ">= 8.10.0" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, "node_modules/mocha/node_modules/cliui": { "version": "7.0.4", "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", @@ -5918,18 +5887,6 @@ "node": ">=8" } }, - "node_modules/nyc/node_modules/p-map": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/p-map/-/p-map-3.0.0.tgz", - "integrity": "sha512-d3qXVTF/s+W+CdJ5A29wywV2n8CQQYahlgz2bFiA+4eVNJbHJodPZ+/gXwPGh0bOqA+j8S+6+ckmvLGPk1QpxQ==", - "dev": true, - "dependencies": { - "aggregate-error": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/nyc/node_modules/resolve-from": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", @@ -6173,6 +6130,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-map": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-3.0.0.tgz", + "integrity": "sha512-d3qXVTF/s+W+CdJ5A29wywV2n8CQQYahlgz2bFiA+4eVNJbHJodPZ+/gXwPGh0bOqA+j8S+6+ckmvLGPk1QpxQ==", + "dev": true, + "dependencies": { + "aggregate-error": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/p-try": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", @@ -6327,9 +6296,9 @@ "optional": true }, "node_modules/pg-connection-string": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.6.1.tgz", - "integrity": "sha512-w6ZzNu6oMmIzEAYVw+RLK0+nqHPt8K3ZnknKi+g48Ak2pr3dtljJW3o+D/n2zzCG07Zoe9VOX3aiKpj+BN0pjg==" + "version": "2.6.4", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.6.4.tgz", + "integrity": "sha512-v+Z7W/0EO707aNMaAEfiGnGL9sxxumwLl2fJvCQtMn9Fxsg+lPpPkdcyBSv/KFgpGdYkMfn+EI1Or2EHjpgLCA==" }, "node_modules/pg-int8": { "version": "1.0.1", @@ -6379,11 +6348,6 @@ "node": ">=10" } }, - "node_modules/pg/node_modules/pg-connection-string": { - "version": "2.6.4", - "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.6.4.tgz", - "integrity": "sha512-v+Z7W/0EO707aNMaAEfiGnGL9sxxumwLl2fJvCQtMn9Fxsg+lPpPkdcyBSv/KFgpGdYkMfn+EI1Or2EHjpgLCA==" - }, "node_modules/pg/node_modules/pg-types": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", @@ -6685,14 +6649,6 @@ "node": ">= 6.0.0" } }, - "node_modules/prompt/node_modules/@colors/colors": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", - "integrity": "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==", - "engines": { - "node": ">=0.1.90" - } - }, "node_modules/prompt/node_modules/winston": { "version": "2.4.7", "resolved": "https://registry.npmjs.org/winston/-/winston-2.4.7.tgz", @@ -6887,23 +6843,20 @@ } }, "node_modules/readable-stream": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", - "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.1.14.tgz", + "integrity": "sha512-+MeVjFf4L44XUkhM1eYbD8fyEsxcV81pqMSR5gblfcLCHfZvbrqy4/qYHE+/R5HoBUT11WV5O08Cr1n3YXkWVQ==", "dependencies": { "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" + "inherits": "~2.0.1", + "isarray": "0.0.1", + "string_decoder": "~0.10.x" } }, - "node_modules/readable-stream/node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + "node_modules/readable-stream/node_modules/isarray": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", + "integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==" }, "node_modules/readdirp": { "version": "3.6.0", @@ -7199,22 +7152,6 @@ "node": ">=10" } }, - "node_modules/semver/node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/semver/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" - }, "node_modules/send": { "version": "0.18.0", "resolved": "https://registry.npmjs.org/send/-/send-0.18.0.tgz", @@ -7455,6 +7392,15 @@ "node": ">=8" } }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/source-map-support": { "version": "0.5.21", "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", @@ -7465,15 +7411,6 @@ "source-map": "^0.6.0" } }, - "node_modules/source-map-support/node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/spawn-wrap": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/spawn-wrap/-/spawn-wrap-2.0.0.tgz", @@ -7586,17 +7523,9 @@ } }, "node_modules/string_decoder": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", - "dependencies": { - "safe-buffer": "~5.1.0" - } - }, - "node_modules/string_decoder/node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + "version": "0.10.31", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", + "integrity": "sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ==" }, "node_modules/string-width": { "version": "4.2.3", @@ -7690,12 +7619,12 @@ } }, "node_modules/strip-bom": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", - "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", "dev": true, "engines": { - "node": ">=8" + "node": ">=4" } }, "node_modules/strip-json-comments": { @@ -7739,9 +7668,9 @@ } }, "node_modules/swagger-ui-dist": { - "version": "5.15.1", - "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.15.1.tgz", - "integrity": "sha512-Et/WY0NFdKj8sUBOyEx5P3VybsvGl7bo/y9JvgQ22TkH1a/KscQ0ZiQST2YeJ3cwCrIjYTbHbt165fkku0y1Ig==" + "version": "5.15.2", + "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.15.2.tgz", + "integrity": "sha512-60ym8SacBjcnpgqQ7Un2ZRaWjwRoGAVceBxMFap8JtwK0VW/GWg2W/BQVBskN9RuiK9Rh5QUA/pKfd4n1i51yw==" }, "node_modules/swagger-ui-express": { "version": "4.6.3", @@ -7957,15 +7886,6 @@ "node": ">=4.2.0" } }, - "node_modules/ts-mocha/node_modules/yn": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/yn/-/yn-2.0.0.tgz", - "integrity": "sha512-uTv8J/wiWTgUTg+9vLTi//leUl5vDQS6uii/emeTb2ssY7vl6QWf2fFbIIGjnhjvbdKlU0ed7QPgY1htTC86jQ==", - "dev": true, - "engines": { - "node": ">=4" - } - }, "node_modules/ts-node": { "version": "10.9.2", "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", @@ -8018,6 +7938,15 @@ "node": ">=0.3.1" } }, + "node_modules/ts-node/node_modules/yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "dev": true, + "engines": { + "node": ">=6" + } + }, "node_modules/tsconfig-paths": { "version": "3.15.0", "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", @@ -8044,16 +7973,6 @@ "json5": "lib/cli.js" } }, - "node_modules/tsconfig-paths/node_modules/strip-bom": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", - "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", - "dev": true, - "optional": true, - "engines": { - "node": ">=4" - } - }, "node_modules/tunnel-ssh": { "version": "4.1.6", "resolved": "https://registry.npmjs.org/tunnel-ssh/-/tunnel-ssh-4.1.6.tgz", @@ -8480,6 +8399,22 @@ "node": ">= 6" } }, + "node_modules/winston-transport/node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/winston/node_modules/@colors/colors": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.6.0.tgz", + "integrity": "sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==", + "engines": { + "node": ">=0.1.90" + } + }, "node_modules/winston/node_modules/readable-stream": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", @@ -8493,6 +8428,14 @@ "node": ">= 6" } }, + "node_modules/winston/node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/workerpool": { "version": "6.2.1", "resolved": "https://registry.npmjs.org/workerpool/-/workerpool-6.2.1.tgz", @@ -8532,13 +8475,7 @@ "node_modules/xlsx": { "version": "0.19.3", "resolved": "https://cdn.sheetjs.com/xlsx-0.19.3/xlsx-0.19.3.tgz", - "integrity": "sha512-8IfgFctB7fkvqkTGF2MnrDrC6vzE28Wcc1aSbdDQ+4/WFtzfS73YuapbuaPZwGqpR2e0EeDMIrFOJubQVLWFNA==", - "bin": { - "xlsx": "bin/xlsx.njs" - }, - "engines": { - "node": ">=0.8" - } + "integrity": "sha512-8IfgFctB7fkvqkTGF2MnrDrC6vzE28Wcc1aSbdDQ+4/WFtzfS73YuapbuaPZwGqpR2e0EeDMIrFOJubQVLWFNA==" }, "node_modules/xml2js": { "version": "0.4.23", @@ -8574,9 +8511,9 @@ "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==" }, "node_modules/yallist": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-2.1.2.tgz", - "integrity": "sha512-ncTzHV7NvsQZkYe1DW7cbDLm0YpzHmZF5r/iyP3ZnQtMiJ+pjzisCiMNI+Sj+xQF5pXhSHxSB3uDbsBTzY/c2A==" + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" }, "node_modules/yargs": { "version": "15.4.1", @@ -8717,12 +8654,12 @@ } }, "node_modules/yn": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", - "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/yn/-/yn-2.0.0.tgz", + "integrity": "sha512-uTv8J/wiWTgUTg+9vLTi//leUl5vDQS6uii/emeTb2ssY7vl6QWf2fFbIIGjnhjvbdKlU0ed7QPgY1htTC86jQ==", "dev": true, "engines": { - "node": ">=6" + "node": ">=4" } }, "node_modules/yocto-queue": { diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/{surveySampleSiteId}/index.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/{surveySampleSiteId}/index.ts index bc98f6c6d3..0083902818 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/{surveySampleSiteId}/index.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/{surveySampleSiteId}/index.ts @@ -98,8 +98,7 @@ PUT.apiDoc = { minItems: 1, items: { type: 'object', - // TODO: Set additionalProperties = false, which requires more extensive changes. - // Removing the restriction here to properly test editing sampling sites + additionalProperties: false, required: ['method_lookup_id', 'description', 'periods', 'method_response_metric_id'], properties: { survey_sample_site_id: { diff --git a/app/src/features/surveys/components/EditSamplingMethod.tsx b/app/src/features/surveys/components/EditSamplingMethod.tsx index 7cd4797498..18adec2083 100644 --- a/app/src/features/surveys/components/EditSamplingMethod.tsx +++ b/app/src/features/surveys/components/EditSamplingMethod.tsx @@ -1,6 +1,5 @@ import EditDialog from 'components/dialog/EditDialog'; import MethodForm, { - IEditSurveySampleMethodData, ISurveySampleMethodData, SamplingSiteMethodYupSchema, SurveySampleMethodDataInitialValues @@ -8,7 +7,7 @@ import MethodForm, { interface IEditSamplingMethodProps { open: boolean; - initialData?: IEditSurveySampleMethodData; + initialData?: ISurveySampleMethodData; onSubmit: (data: ISurveySampleMethodData, index?: number) => void; onClose: () => void; } @@ -29,7 +28,7 @@ const EditSamplingMethod: React.FC = (props) => { dialogSaveButtonLabel="Update" onCancel={onClose} onSave={(formValues) => { - onSubmit(formValues, initialData?.index); + onSubmit(formValues); }} /> diff --git a/app/src/features/surveys/components/MethodForm.tsx b/app/src/features/surveys/components/MethodForm.tsx index b738972a6d..29aa49d3d3 100644 --- a/app/src/features/surveys/components/MethodForm.tsx +++ b/app/src/features/surveys/components/MethodForm.tsx @@ -36,10 +36,6 @@ export interface ISurveySampleMethodData { method_response_metric_id: number | null; } -export interface IEditSurveySampleMethodData extends ISurveySampleMethodData { - index: number; -} - export const SurveySampleMethodPeriodArrayItemInitialValues = { method_lookup_id: null, survey_sample_period_id: null, diff --git a/app/src/features/surveys/components/SamplingMethodForm.tsx b/app/src/features/surveys/components/SamplingMethodForm.tsx index e543b49318..374e1eb472 100644 --- a/app/src/features/surveys/components/SamplingMethodForm.tsx +++ b/app/src/features/surveys/components/SamplingMethodForm.tsx @@ -21,6 +21,7 @@ import MenuItem from '@mui/material/MenuItem'; import Stack from '@mui/material/Stack'; import Typography from '@mui/material/Typography'; import { CodesContext } from 'contexts/codesContext'; +import { ISurveySampleMethodData } from 'features/surveys/components/MethodForm'; import { useFormikContext } from 'formik'; import { useContext, useEffect, useState } from 'react'; import { TransitionGroup } from 'react-transition-group'; @@ -28,14 +29,13 @@ import { getCodesName } from 'utils/Utils'; import { ICreateSamplingSiteRequest } from '../observations/sampling-sites/SamplingSitePage'; import CreateSamplingMethod from './CreateSamplingMethod'; import EditSamplingMethod from './EditSamplingMethod'; -import { IEditSurveySampleMethodData } from './MethodForm'; const SamplingMethodForm = () => { const { values, errors, setFieldValue, validateField } = useFormikContext(); const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); const [isEditModalOpen, setIsEditModalOpen] = useState(false); const [anchorEl, setAnchorEl] = useState(null); - const [editData, setEditData] = useState(undefined); + const [editData, setEditData] = useState<{ data: ISurveySampleMethodData; index: number } | undefined>(undefined); const codesContext = useContext(CodesContext); useEffect(() => { @@ -44,7 +44,7 @@ const SamplingMethodForm = () => { const handleMenuClick = (event: React.MouseEvent, index: number) => { setAnchorEl(event.currentTarget); - setEditData({ ...values.methods[index], index }); + setEditData({ data: values.methods[index], index }); }; const handleDelete = () => { @@ -75,10 +75,10 @@ const SamplingMethodForm = () => { {/* EDIT SAMPLE METHOD DIALOG */} { - setFieldValue(`methods[${index}]`, data); + onSubmit={(data) => { + setFieldValue(`methods[${editData?.index}]`, data); setAnchorEl(null); setIsEditModalOpen(false); }} diff --git a/app/src/features/surveys/observations/sampling-sites/edit/components/SampleMethodEditForm.tsx b/app/src/features/surveys/observations/sampling-sites/edit/components/SampleMethodEditForm.tsx index 1c720d1fb1..fbd4a613d0 100644 --- a/app/src/features/surveys/observations/sampling-sites/edit/components/SampleMethodEditForm.tsx +++ b/app/src/features/surveys/observations/sampling-sites/edit/components/SampleMethodEditForm.tsx @@ -23,7 +23,7 @@ import Typography from '@mui/material/Typography'; import { CodesContext } from 'contexts/codesContext'; import CreateSamplingMethod from 'features/surveys/components/CreateSamplingMethod'; import EditSamplingMethod from 'features/surveys/components/EditSamplingMethod'; -import { IEditSurveySampleMethodData } from 'features/surveys/components/MethodForm'; +import { ISurveySampleMethodData } from 'features/surveys/components/MethodForm'; import { useFormikContext } from 'formik'; import { useContext, useEffect, useState } from 'react'; import { TransitionGroup } from 'react-transition-group'; @@ -41,7 +41,7 @@ const SampleMethodEditForm = (props: SampleMethodEditFormProps) => { const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); const [isEditModalOpen, setIsEditModalOpen] = useState(false); const [anchorEl, setAnchorEl] = useState(null); - const [editData, setEditData] = useState(undefined); + const [editData, setEditData] = useState<{ data: ISurveySampleMethodData; index: number } | undefined>(undefined); const codesContext = useContext(CodesContext); useEffect(() => { @@ -50,7 +50,7 @@ const SampleMethodEditForm = (props: SampleMethodEditFormProps) => { const handleMenuClick = (event: React.MouseEvent, index: number) => { setAnchorEl(event.currentTarget); - setEditData({ ...values.sampleSite.methods[index], index }); + setEditData({ data: values.sampleSite.methods[index], index }); }; const handleDelete = () => { @@ -81,10 +81,10 @@ const SampleMethodEditForm = (props: SampleMethodEditFormProps) => { {/* EDIT SAMPLE METHOD DIALOG */} { - setFieldValue(`${name}[${index}]`, data); + onSubmit={(data) => { + setFieldValue(`${name}[${editData?.index}]`, data); setAnchorEl(null); setIsEditModalOpen(false); }} From 016f2660abcc704baec3a30e8603e7a0873c737a Mon Sep 17 00:00:00 2001 From: Macgregor Aubertin-Young <108430771+mauberti-bc@users.noreply.github.com> Date: Thu, 25 Apr 2024 15:24:03 -0700 Subject: [PATCH 06/31] Add subcount sign & sampling site list styling changes (#1268) * wip: sample method response metric * wip: observation subcount sign * migration changes & ui updates * migration to add method lookup options * add and edit response metric for sampling methods * order by in sample methods sql * separate sampling sites method and periods into own component * styling sample site list * styling * add method response metric chip to method edit form * styling * make survey areas orange on the survey map * update survey map componenent & include map legend * update survey map legend * colors * method-response-variable * console logs * move geojson transform from zod schema into sql for observation geometry repo * update colours * linter * Merge branch 'dev' of github.com:bcgov/biohubbc into method-response-variable * move legend position * styling sampling site list panel * cleanup * more styling sample periods list * change timelinedot to calendar icon * change observations map marker * styling timeline icons * leaflet sampling site map * icons to indicate geometry type of sampling site * survey map tooltip * undo * remove extra fields from get study area request * remove survey map legend * include start and end date in survey list table * cleanup * console log * Initial working observation import against sampling period * Add better loading/disabled handlers * remove duplicated migration * update styling * linter * Add Knip and SWC * Update import observations * Remove console logs * Remove swc from API to fix unit tests * Remove knip * Remove gulp * Update lock * Add unit tests. fix spelling * Add tests * Fix merge conflicts * import button changes * observation import button styling * include inset sampling site map * sampling site list tsyling * spacing * alternate styling * additionalproperties: false in samplesite openapi spec * remove not null constraint on subcount sign * linter * update survey progress chip deisgn * linter * replace sampling site inset map with survey map component * jsdoc fixes * linter * linter * code smells * code smell * styling * change to direct imports for MUI components * change mui/system to mui/material in imports * prettier * cleanup * sort sampling periods by date and time * typo --------- Co-authored-by: Nick Phura --- .../survey/{surveyId}/sample-site/index.ts | 15 +- .../sample-site/{surveySampleSiteId}/index.ts | 2 - api/src/repositories/code-repository.ts | 4 +- .../sample-location-repository.ts | 2 +- .../repositories/sample-period-repository.ts | 6 +- .../survey-location-repository.ts | 9 +- .../chips/ColouredRectangleChip.tsx | 35 ++++ .../map/components/StaticLayers.tsx | 22 +- app/src/constants/misc.ts | 11 + app/src/constants/spatial.ts | 8 + .../features/projects/view/ProjectHeader.tsx | 7 +- .../surveys/components/EditSamplingMethod.tsx | 33 ++- .../surveys/components/MethodForm.tsx | 13 +- .../surveys/components/SamplingMethodForm.tsx | 64 +++--- .../surveys/components/SurveyProgressChip.tsx | 28 +++ .../SurveySectionFullPageLayout.tsx | 2 +- .../features/surveys/list/SurveysListPage.tsx | 38 +++- .../ImportObservationsButton.tsx | 2 +- .../sampling-sites/SamplingSitePage.tsx | 10 +- .../edit/components/SampleMethodEditForm.tsx | 66 +++--- .../edit/components/SamplingStratumChips.tsx | 49 +++++ .../sampling-sites/list/SamplingSiteList.tsx | 1 - .../list/SamplingSiteListMethod.tsx | 39 ++-- .../list/SamplingSiteListPeriod.tsx | 194 +++++++++++++----- .../list/SamplingSiteListSite.tsx | 51 ++++- .../ImportObservationsButton.tsx | 18 +- .../features/surveys/view/SurveyHeader.tsx | 15 +- app/src/features/surveys/view/SurveyMap.tsx | 191 +---------------- .../features/surveys/view/SurveyMapPopup.tsx | 85 ++++++++ .../surveys/view/SurveyMapTooltip.tsx | 21 ++ .../view/components/SurveyProgressChip.tsx | 43 ---- .../spatial-data/SurveySpatialData.tsx | 144 ++++++++++++- .../spatial-data/SurveySpatialToolbar.tsx | 2 +- .../survey-animals/GeneralAnimalSummary.tsx | 2 +- app/src/interfaces/useSurveyApi.interface.ts | 6 +- app/src/utils/mapUtils.ts | 20 ++ .../20240310163000_observation_sign.ts | 110 ++++++++++ .../seeds/03_basic_project_survey_setup.ts | 15 +- 38 files changed, 929 insertions(+), 454 deletions(-) create mode 100644 app/src/components/chips/ColouredRectangleChip.tsx create mode 100644 app/src/features/surveys/components/SurveyProgressChip.tsx create mode 100644 app/src/features/surveys/observations/sampling-sites/edit/components/SamplingStratumChips.tsx create mode 100644 app/src/features/surveys/view/SurveyMapPopup.tsx create mode 100644 app/src/features/surveys/view/SurveyMapTooltip.tsx delete mode 100644 app/src/features/surveys/view/components/SurveyProgressChip.tsx create mode 100644 database/src/migrations/20240310163000_observation_sign.ts diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/index.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/index.ts index 7581776389..78f0ae1c9c 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/index.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/index.ts @@ -131,6 +131,10 @@ GET.apiDoc = { type: 'integer', minimum: 1 }, + method_response_metric_id: { + type: 'integer', + minimum: 1 + }, description: { type: 'string', maxLength: 250 @@ -173,8 +177,7 @@ GET.apiDoc = { } } } - }, - method_response_metric_id: { type: 'integer', minimum: 1 } + } } } }, @@ -456,7 +459,7 @@ POST.apiDoc = { nullable: true }, create_user: { - type: 'number', + type: 'integer', nullable: true }, update_date: { @@ -464,7 +467,7 @@ POST.apiDoc = { nullable: true }, update_user: { - type: 'string', + type: 'integer', nullable: true }, revision_count: { @@ -500,7 +503,7 @@ POST.apiDoc = { nullable: true }, create_user: { - type: 'string', + type: 'integer', nullable: true }, update_date: { @@ -508,7 +511,7 @@ POST.apiDoc = { nullable: true }, update_user: { - type: 'string', + type: 'integer', nullable: true }, revision_count: { diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/{surveySampleSiteId}/index.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/{surveySampleSiteId}/index.ts index 0083902818..90a5102f96 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/{surveySampleSiteId}/index.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/{surveySampleSiteId}/index.ts @@ -165,7 +165,6 @@ PUT.apiDoc = { type: 'array', items: { type: 'object', - additionalProperties: false, required: ['survey_block_id'], properties: { survey_block_id: { @@ -178,7 +177,6 @@ PUT.apiDoc = { type: 'array', items: { type: 'object', - additionalProperties: false, required: ['survey_stratum_id'], properties: { survey_stratum_id: { diff --git a/api/src/repositories/code-repository.ts b/api/src/repositories/code-repository.ts index 6caa261979..470de98bd1 100644 --- a/api/src/repositories/code-repository.ts +++ b/api/src/repositories/code-repository.ts @@ -43,8 +43,8 @@ export const IAllCodeSets = z.object({ vantage_codes: CodeSet(), survey_jobs: CodeSet(), site_selection_strategies: CodeSet(), - survey_progress: CodeSet(z.object({ id: z.number(), name: z.string(), description: z.string() }).shape), sample_methods: CodeSet(z.object({ id: z.number(), name: z.string(), description: z.string() }).shape), + survey_progress: CodeSet(z.object({ id: z.number(), name: z.string(), description: z.string() }).shape), method_response_metrics: CodeSet(z.object({ id: z.number(), name: z.string(), description: z.string() }).shape) }); @@ -53,7 +53,7 @@ export type IAllCodeSets = z.infer; export class CodeRepository extends BaseRepository { async getSampleMethods() { const sql = SQL` - SELECT method_lookup_id as id, name, description FROM method_lookup; + SELECT method_lookup_id as id, name, description FROM method_lookup ORDER BY name ASC; `; const response = await this.connection.sql(sql); return response.rows; diff --git a/api/src/repositories/sample-location-repository.ts b/api/src/repositories/sample-location-repository.ts index 3d14d12869..6cede7452c 100644 --- a/api/src/repositories/sample-location-repository.ts +++ b/api/src/repositories/sample-location-repository.ts @@ -145,7 +145,7 @@ export class SampleLocationRepository extends BaseRepository { 'start_time', ssp.start_time, 'end_date', ssp.end_date, 'end_time', ssp.end_time - )) as sample_periods + ) ORDER BY ssp.start_date, ssp.start_time) as sample_periods `) ) .from({ ssp: 'survey_sample_period' }) diff --git a/api/src/repositories/sample-period-repository.ts b/api/src/repositories/sample-period-repository.ts index 6857261a70..a86b3bd6cc 100644 --- a/api/src/repositories/sample-period-repository.ts +++ b/api/src/repositories/sample-period-repository.ts @@ -84,7 +84,8 @@ export class SamplePeriodRepository extends BaseRepository { WHERE ssm.survey_sample_method_id = ${surveySampleMethodId} AND - sss.survey_id = ${surveyId};`; + sss.survey_id = ${surveyId} + ORDER BY ssp.start_date, ssp.start_time;`; const response = await this.connection.sql(sql, SamplePeriodRecord); @@ -118,7 +119,8 @@ export class SamplePeriodRepository extends BaseRepository { WHERE survey_sample_period.survey_sample_period_id = ${surveySamplePeriodId} AND - survey_sample_site.survey_id = ${surveyId}; + survey_sample_site.survey_id = ${surveyId} + ORDER BY survey_sample_period.start_date, survey_sample_period.start_time; `; const response = await this.connection.sql(sqlStatement, SamplePeriodHierarchyIds); diff --git a/api/src/repositories/survey-location-repository.ts b/api/src/repositories/survey-location-repository.ts index c8f4284324..b2e47eea7a 100644 --- a/api/src/repositories/survey-location-repository.ts +++ b/api/src/repositories/survey-location-repository.ts @@ -84,7 +84,14 @@ export class SurveyLocationRepository extends BaseRepository { async getSurveyLocationsData(surveyId: number): Promise { const sqlStatement = SQL` SELECT - * + survey_location_id, + name, + description, + geography, + geojson, + geometry, + name, + revision_count FROM survey_location WHERE diff --git a/app/src/components/chips/ColouredRectangleChip.tsx b/app/src/components/chips/ColouredRectangleChip.tsx new file mode 100644 index 0000000000..2dea05b208 --- /dev/null +++ b/app/src/components/chips/ColouredRectangleChip.tsx @@ -0,0 +1,35 @@ +import { Color } from '@mui/material'; +import Chip, { ChipProps } from '@mui/material/Chip'; + +export interface IColouredRectangleChipProps extends ChipProps { + colour: Color; + label: string | JSX.Element; +} + +/** + * Returns a stylized MUI chip of a specified colour + * + * @param props {IColouredRectangleChipProps} + * @returns + */ +const ColouredRectangleChip = (props: IColouredRectangleChipProps) => { + return ( + + ); +}; + +export default ColouredRectangleChip; diff --git a/app/src/components/map/components/StaticLayers.tsx b/app/src/components/map/components/StaticLayers.tsx index 92bda951c8..13528989d4 100644 --- a/app/src/components/map/components/StaticLayers.tsx +++ b/app/src/components/map/components/StaticLayers.tsx @@ -1,5 +1,4 @@ import { Feature } from 'geojson'; -import L from 'leaflet'; import { PropsWithChildren, ReactElement, useMemo } from 'react'; import { FeatureGroup, @@ -11,7 +10,7 @@ import { Tooltip, TooltipProps } from 'react-leaflet'; -import { coloredPoint } from 'utils/mapUtils'; +import { coloredCustomPointMarker, coloredPoint } from 'utils/mapUtils'; export interface IStaticLayerFeature { geoJSON: Feature; @@ -36,6 +35,12 @@ export interface IStaticLayersProps { layers: IStaticLayer[]; } +/** + * Returns static map layers to be displayed in leaflet + * + * @param props {PropsWithChildren} + * @returns + */ const StaticLayers = (props: PropsWithChildren) => { const layerControls: ReactElement[] = useMemo( () => @@ -43,7 +48,6 @@ const StaticLayers = (props: PropsWithChildren) => { .filter((layer) => Boolean(layer.features?.length)) .map((layer) => { const layerColors = layer.layerColors || { color: '#1f7dff', fillColor: '#1f7dff' }; - return ( @@ -54,13 +58,11 @@ const StaticLayers = (props: PropsWithChildren) => { { - if (feature.properties?.radius) { - return new L.Circle([latlng.lat, latlng.lng], feature.properties.radius); - } - - return coloredPoint({ latlng }); - }} + pointToLayer={(_, latlng) => + layer.layerName === 'Observations' + ? coloredCustomPointMarker({ latlng, fillColor: layer.layerColors?.fillColor }) + : coloredPoint({ latlng, fillColor: layer.layerColors?.fillColor }) + } data={item.geoJSON} {...item.GeoJSONProps}> {item.tooltip && ( diff --git a/app/src/constants/misc.ts b/app/src/constants/misc.ts index f52321e41b..993ae26501 100644 --- a/app/src/constants/misc.ts +++ b/app/src/constants/misc.ts @@ -1,3 +1,8 @@ +import { Color } from '@mui/material'; +import blue from '@mui/material/colors/blue'; +import green from '@mui/material/colors/green'; +import purple from '@mui/material/colors/purple'; + export enum AdministrativeActivityType { SYSTEM_ACCESS = 'System Access' } @@ -7,3 +12,9 @@ export enum AdministrativeActivityStatusType { ACTIONED = 'Actioned', REJECTED = 'Rejected' } + +export const SurveyProgressChipColours: Record = { + PLANNING: blue, + 'IN PROGRESS': purple, + COMPLETED: green +}; diff --git a/app/src/constants/spatial.ts b/app/src/constants/spatial.ts index 3f06fbabd4..1f26ecb047 100644 --- a/app/src/constants/spatial.ts +++ b/app/src/constants/spatial.ts @@ -19,3 +19,11 @@ export const ALL_OF_BC_BOUNDARY: Feature = { ] } }; + +export const SURVEY_MAP_LAYER_COLOURS = { + OBSERVATIONS_COLOUR: '#db4f4f', + STUDY_AREA_COLOUR: '#e3a82b', + SAMPLING_SITE_COLOUR: '#1f6fb5', + TELEMETRY_COLOUR: '#ff5454', + DEFAULT_COLOUR: '#a7bfd1' +}; diff --git a/app/src/features/projects/view/ProjectHeader.tsx b/app/src/features/projects/view/ProjectHeader.tsx index 2782043622..1ff904fc41 100644 --- a/app/src/features/projects/view/ProjectHeader.tsx +++ b/app/src/features/projects/view/ProjectHeader.tsx @@ -1,5 +1,6 @@ import { mdiAccountMultipleOutline, + mdiCalendarRange, mdiCalendarTodayOutline, mdiChevronDown, mdiCogOutline, @@ -8,6 +9,7 @@ import { } from '@mdi/js'; import Icon from '@mdi/react'; import Button from '@mui/material/Button'; +import grey from '@mui/material/colors/grey'; import ListItemIcon from '@mui/material/ListItemIcon'; import Menu from '@mui/material/Menu'; import MenuItem from '@mui/material/MenuItem'; @@ -106,9 +108,10 @@ const ProjectHeader = () => { <> {projectData.projectData.project.end_date ? ( - Project Timeline: + + {getFormattedDateRangeString( - DATE_FORMAT.ShortMediumDateFormat, + DATE_FORMAT.MediumDateFormat, projectData.projectData.project.start_date, projectData.projectData.project.end_date )} diff --git a/app/src/features/surveys/components/EditSamplingMethod.tsx b/app/src/features/surveys/components/EditSamplingMethod.tsx index 18adec2083..d9566a119e 100644 --- a/app/src/features/surveys/components/EditSamplingMethod.tsx +++ b/app/src/features/surveys/components/EditSamplingMethod.tsx @@ -14,24 +14,23 @@ interface IEditSamplingMethodProps { const EditSamplingMethod: React.FC = (props) => { const { open, initialData, onSubmit, onClose } = props; + return ( - <> - , - initialValues: initialData || SurveySampleMethodDataInitialValues, - validationSchema: SamplingSiteMethodYupSchema - }} - dialogSaveButtonLabel="Update" - onCancel={onClose} - onSave={(formValues) => { - onSubmit(formValues); - }} - /> - + , + initialValues: initialData || SurveySampleMethodDataInitialValues, + validationSchema: SamplingSiteMethodYupSchema + }} + dialogSaveButtonLabel="Update" + onCancel={onClose} + onSave={(formValues) => { + onSubmit(formValues); + }} + /> ); }; diff --git a/app/src/features/surveys/components/MethodForm.tsx b/app/src/features/surveys/components/MethodForm.tsx index 29aa49d3d3..9da7dac1f5 100644 --- a/app/src/features/surveys/components/MethodForm.tsx +++ b/app/src/features/surveys/components/MethodForm.tsx @@ -1,4 +1,4 @@ -import { mdiArrowRight, mdiCalendarMonthOutline, mdiClockOutline, mdiPlus, mdiTrashCanOutline } from '@mdi/js'; +import { mdiArrowRightThin, mdiCalendarMonthOutline, mdiClockOutline, mdiPlus, mdiTrashCanOutline } from '@mdi/js'; import Icon from '@mdi/react'; import Alert from '@mui/material/Alert'; import Box from '@mui/material/Box'; @@ -18,7 +18,7 @@ import { FieldArray, FieldArrayRenderProps, useFormikContext } from 'formik'; import { useContext, useEffect } from 'react'; import yup from 'utils/YupSchema'; -interface ISurveySampleMethodPeriodData { +export interface ISurveySampleMethodPeriodData { survey_sample_period_id: number | null; survey_sample_method_id: number | null; start_date: string; @@ -99,6 +99,11 @@ export const SamplingSiteMethodYupSchema = yup.object({ .min(1, 'At least one time period is required') }); +/** + * Returns a form for editing a sampling method + * + * @returns + */ const MethodForm = () => { const formikProps = useFormikContext(); const { values, errors } = formikProps; @@ -161,7 +166,7 @@ const MethodForm = () => { sx={{ mb: 1 }}> - Time Periods + Periods { - + diff --git a/app/src/features/surveys/components/SamplingMethodForm.tsx b/app/src/features/surveys/components/SamplingMethodForm.tsx index 374e1eb472..7bf949e947 100644 --- a/app/src/features/surveys/components/SamplingMethodForm.tsx +++ b/app/src/features/surveys/components/SamplingMethodForm.tsx @@ -1,4 +1,4 @@ -import { mdiCalendarRangeOutline, mdiPencilOutline, mdiPlus, mdiTrashCanOutline } from '@mdi/js'; +import { mdiPencilOutline, mdiPlus, mdiTrashCanOutline } from '@mdi/js'; import Icon from '@mdi/react'; import MoreVertIcon from '@mui/icons-material/MoreVert'; import Alert from '@mui/material/Alert'; @@ -9,13 +9,10 @@ import Card from '@mui/material/Card'; import CardContent from '@mui/material/CardContent'; import CardHeader from '@mui/material/CardHeader'; import Collapse from '@mui/material/Collapse'; -import { grey } from '@mui/material/colors'; +import grey from '@mui/material/colors/grey'; import Divider from '@mui/material/Divider'; import IconButton from '@mui/material/IconButton'; -import List from '@mui/material/List'; -import ListItem from '@mui/material/ListItem'; import ListItemIcon from '@mui/material/ListItemIcon'; -import ListItemText from '@mui/material/ListItemText'; import Menu, { MenuProps } from '@mui/material/Menu'; import MenuItem from '@mui/material/MenuItem'; import Stack from '@mui/material/Stack'; @@ -26,10 +23,16 @@ import { useFormikContext } from 'formik'; import { useContext, useEffect, useState } from 'react'; import { TransitionGroup } from 'react-transition-group'; import { getCodesName } from 'utils/Utils'; +import SamplingSiteListPeriod from '../observations/sampling-sites/list/SamplingSiteListPeriod'; import { ICreateSamplingSiteRequest } from '../observations/sampling-sites/SamplingSitePage'; import CreateSamplingMethod from './CreateSamplingMethod'; import EditSamplingMethod from './EditSamplingMethod'; +/** + * Renders a form for creating sampling methods + * + * @returns + */ const SamplingMethodForm = () => { const { values, errors, setFieldValue, validateField } = useFormikContext(); const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); @@ -142,14 +145,24 @@ const SamplingMethodForm = () => { + {getCodesName(codesContext.codesDataLoader.data, 'sample_methods', item.method_lookup_id || 0)} + + {getCodesName( + codesContext.codesDataLoader.data, + 'method_response_metrics', + item.method_response_metric_id || 0 + )} + + + } action={ ) => @@ -163,9 +176,9 @@ const SamplingMethodForm = () => { - + {item.description && ( { )} - - Time Periods + + Periods - - {item.periods.map((period) => ( - - - - - - - ))} - + + + diff --git a/app/src/features/surveys/components/SurveyProgressChip.tsx b/app/src/features/surveys/components/SurveyProgressChip.tsx new file mode 100644 index 0000000000..68899908c6 --- /dev/null +++ b/app/src/features/surveys/components/SurveyProgressChip.tsx @@ -0,0 +1,28 @@ +import { ChipProps } from '@mui/material'; +import blueGrey from '@mui/material/colors/blueGrey'; +import ColouredRectangleChip from 'components/chips/ColouredRectangleChip'; +import { SurveyProgressChipColours } from 'constants/misc'; +import { useCodesContext } from 'hooks/useContext'; +import { getCodesName } from 'utils/Utils'; + +interface ISurveyProgressChipProps extends ChipProps { + progress_id: number; +} + +/** + * Returns stylized ColouredRectangleChip for displaying Survey progress + * + * @param props + * @returns + */ +const SurveyProgressChip = (props: ISurveyProgressChipProps) => { + const codesContext = useCodesContext(); + + const codeName = + getCodesName(codesContext.codesDataLoader.data, 'survey_progress', props.progress_id)?.toUpperCase() ?? ''; + const codeColour = SurveyProgressChipColours[codeName] ?? blueGrey; + + return ; +}; + +export default SurveyProgressChip; diff --git a/app/src/features/surveys/components/SurveySectionFullPageLayout.tsx b/app/src/features/surveys/components/SurveySectionFullPageLayout.tsx index 98a5af2891..245c40fa6f 100644 --- a/app/src/features/surveys/components/SurveySectionFullPageLayout.tsx +++ b/app/src/features/surveys/components/SurveySectionFullPageLayout.tsx @@ -1,6 +1,6 @@ import Box from '@mui/material/Box'; import CircularProgress from '@mui/material/CircularProgress'; -import Stack from '@mui/system/Stack'; +import Stack from '@mui/material/Stack'; import { ProjectContext } from 'contexts/projectContext'; import { SurveyContext } from 'contexts/surveyContext'; import { useContext } from 'react'; diff --git a/app/src/features/surveys/list/SurveysListPage.tsx b/app/src/features/surveys/list/SurveysListPage.tsx index 55340eb666..9ddc5ba032 100644 --- a/app/src/features/surveys/list/SurveysListPage.tsx +++ b/app/src/features/surveys/list/SurveysListPage.tsx @@ -9,14 +9,15 @@ import Typography from '@mui/material/Typography'; import { GridColDef, GridPaginationModel, GridSortModel } from '@mui/x-data-grid'; import { StyledDataGrid } from 'components/data-grid/StyledDataGrid'; import { ProjectRoleGuard } from 'components/security/Guards'; +import { DATE_FORMAT } from 'constants/dateTimeFormats'; import { PROJECT_PERMISSION, SYSTEM_ROLE } from 'constants/roles'; import { ProjectContext } from 'contexts/projectContext'; import { SurveyBasicFieldsObject } from 'interfaces/useSurveyApi.interface'; import { useContext, useEffect, useState } from 'react'; import { Link as RouterLink } from 'react-router-dom'; import { ApiPaginationRequestOptions } from 'types/misc'; -import { firstOrNull } from 'utils/Utils'; -import SurveyProgressChip from '../view/components/SurveyProgressChip'; +import { firstOrNull, getFormattedDate } from 'utils/Utils'; +import SurveyProgressChip from '../components/SurveyProgressChip'; const pageSizeOptions = [10, 25, 50]; @@ -27,6 +28,7 @@ const pageSizeOptions = [10, 25, 50]; */ const SurveysListPage = () => { const projectContext = useContext(ProjectContext); + const [paginationModel, setPaginationModel] = useState({ page: 0, pageSize: pageSizeOptions[0] @@ -72,10 +74,36 @@ const SurveysListPage = () => { { field: 'progress', headerName: 'Progress', - flex: 0.5, + flex: 0.25, + disableColumnMenu: true, + renderCell: (params) => ( + + + + ) + }, + { + field: 'start_date', + headerName: 'Start Date', + flex: 0.3, disableColumnMenu: true, - sortable: false, - renderCell: (params) => + renderCell: (params) => ( + {getFormattedDate(DATE_FORMAT.MediumDateFormat, params.row.start_date)} + ) + }, + { + field: 'end_date', + headerName: 'End Date', + flex: 0.3, + disableColumnMenu: true, + renderCell: (params) => + params.row.end_date ? ( + {getFormattedDate(DATE_FORMAT.MediumDateFormat, params.row.end_date)} + ) : ( + + None + + ) } ]; diff --git a/app/src/features/surveys/observations/observations-table/import-obsevations/ImportObservationsButton.tsx b/app/src/features/surveys/observations/observations-table/import-obsevations/ImportObservationsButton.tsx index 78c4ab2666..b71460b6d4 100644 --- a/app/src/features/surveys/observations/observations-table/import-obsevations/ImportObservationsButton.tsx +++ b/app/src/features/surveys/observations/observations-table/import-obsevations/ImportObservationsButton.tsx @@ -109,7 +109,7 @@ export const ImportObservationsButton = (props: IImportObservationsButtonProps) color="primary" startIcon={} onClick={() => setOpen(true)} - disabled={disabled}> + disabled={disabled || false}> Import { const history = useHistory(); const biohubApi = useBiohubApi(); - const surveyContext = useContext(SurveyContext); - const dialogContext = useContext(DialogContext); + const surveyContext = useSurveyContext(); + const dialogContext = useDialogContext(); const formikRef = useRef>(null); const [isSubmitting, setIsSubmitting] = useState(false); @@ -148,7 +147,6 @@ const SamplingSitePage = () => { return ( <> - { const { name } = props; @@ -153,14 +157,28 @@ const SampleMethodEditForm = (props: SampleMethodEditFormProps) => { + {getCodesName( + codesContext.codesDataLoader.data, + 'sample_methods', + item.method_lookup_id || 0 + )} + + {getCodesName( + codesContext.codesDataLoader.data, + 'method_response_metrics', + item.method_response_metric_id || 0 + )} + + + } action={ ) => @@ -176,7 +194,7 @@ const SampleMethodEditForm = (props: SampleMethodEditFormProps) => { pt: 0, pb: '12px !important' }}> - + {item.description && ( { {item.description} )} - - Time Periods + Periods - - {item.periods.map((period) => ( - - - - - - - ))} - + + + diff --git a/app/src/features/surveys/observations/sampling-sites/edit/components/SamplingStratumChips.tsx b/app/src/features/surveys/observations/sampling-sites/edit/components/SamplingStratumChips.tsx new file mode 100644 index 0000000000..3c7960b073 --- /dev/null +++ b/app/src/features/surveys/observations/sampling-sites/edit/components/SamplingStratumChips.tsx @@ -0,0 +1,49 @@ +import { Color, colors } from '@mui/material'; +import Stack from '@mui/material/Stack'; +import ColouredRectangleChip from 'components/chips/ColouredRectangleChip'; +import { SurveyContext } from 'contexts/surveyContext'; +import { IGetSampleLocationDetails } from 'interfaces/useSurveyApi.interface'; +import { useContext } from 'react'; + +interface IStratumChipColours { + stratum: string; + colour: Color; +} + +interface ISamplingStratumChips { + sampleSite: IGetSampleLocationDetails; +} + +/** + * Returns horizontal stack of ColouredRectangleChip for displaying sample stratums + * + * @param props + * @returns + */ +const SamplingStratumChips = (props: ISamplingStratumChips) => { + const surveyContext = useContext(SurveyContext); + + // Determine colours for stratum labels + const orderedColours = [colors.purple, colors.blue, colors.pink, colors.teal, colors.cyan, colors.orange]; + const stratums = surveyContext.surveyDataLoader.data?.surveyData.site_selection.stratums; + const stratumChipColours: IStratumChipColours[] = + stratums?.map((stratum, index) => ({ + stratum: stratum.name, + colour: orderedColours[index % orderedColours.length] + })) ?? []; + + return ( + + {props.sampleSite.sample_stratums?.map((stratum, index) => ( + colour.stratum === stratum.name)?.colour ?? colors.grey} + label={stratum.name} + title="Stratum" + /> + ))} + + ); +}; + +export default SamplingStratumChips; diff --git a/app/src/features/surveys/observations/sampling-sites/list/SamplingSiteList.tsx b/app/src/features/surveys/observations/sampling-sites/list/SamplingSiteList.tsx index 93ba5b69a0..38f6959ede 100644 --- a/app/src/features/surveys/observations/sampling-sites/list/SamplingSiteList.tsx +++ b/app/src/features/surveys/observations/sampling-sites/list/SamplingSiteList.tsx @@ -339,7 +339,6 @@ const SamplingSiteList = () => { diff --git a/app/src/features/surveys/observations/sampling-sites/list/SamplingSiteListMethod.tsx b/app/src/features/surveys/observations/sampling-sites/list/SamplingSiteListMethod.tsx index 4a287d3e40..dbf9bb27f0 100644 --- a/app/src/features/surveys/observations/sampling-sites/list/SamplingSiteListMethod.tsx +++ b/app/src/features/surveys/observations/sampling-sites/list/SamplingSiteListMethod.tsx @@ -1,12 +1,11 @@ -import grey from '@mui/material/colors/grey'; import List from '@mui/material/List'; import ListItem from '@mui/material/ListItem'; import ListItemText from '@mui/material/ListItemText'; -import { SamplingSiteListPeriod } from 'features/surveys/observations/sampling-sites/list/SamplingSiteListPeriod'; -import { useCodesContext } from 'hooks/useContext'; +import { useCodesContext, useObservationsContext, useObservationsPageContext } from 'hooks/useContext'; import { IGetSampleMethodRecord } from 'interfaces/useSurveyApi.interface'; import { useEffect } from 'react'; import { getCodesName } from 'utils/Utils'; +import SamplingSiteListPeriod from './SamplingSiteListPeriod'; export interface ISamplingSiteListMethodProps { sampleMethod: IGetSampleMethodRecord; @@ -22,6 +21,8 @@ export const SamplingSiteListMethod = (props: ISamplingSiteListMethodProps) => { const { sampleMethod } = props; const codesContext = useCodesContext(); + const observationsPageContext = useObservationsPageContext(); + const observationsContext = useObservationsContext(); useEffect(() => { codesContext.codesDataLoader.load(); @@ -29,33 +30,33 @@ export const SamplingSiteListMethod = (props: ISamplingSiteListMethodProps) => { return ( - - {sampleMethod.sample_periods?.map((samplePeriod) => { - return ( - - ); - })} - + {sampleMethod.sample_periods?.length && ( + + + + )} ); }; diff --git a/app/src/features/surveys/observations/sampling-sites/list/SamplingSiteListPeriod.tsx b/app/src/features/surveys/observations/sampling-sites/list/SamplingSiteListPeriod.tsx index bfcdfde6d5..7c05ee9c06 100644 --- a/app/src/features/surveys/observations/sampling-sites/list/SamplingSiteListPeriod.tsx +++ b/app/src/features/surveys/observations/sampling-sites/list/SamplingSiteListPeriod.tsx @@ -1,64 +1,154 @@ -import { mdiCalendarRange } from '@mdi/js'; +import { mdiArrowRightThin, mdiCalendarRange } from '@mdi/js'; import Icon from '@mdi/react'; -import ListItem from '@mui/material/ListItem'; -import ListItemIcon from '@mui/material/ListItemIcon'; -import ListItemText from '@mui/material/ListItemText'; +import { Timeline, TimelineConnector, TimelineContent, TimelineDot, TimelineItem, TimelineSeparator } from '@mui/lab'; +import Box from '@mui/material/Box'; +import grey from '@mui/material/colors/grey'; import Typography from '@mui/material/Typography'; -import { ImportObservationsButton } from 'features/surveys/observations/sampling-sites/list/import-observations/ImportObservationsButton'; -import { useObservationsContext, useObservationsPageContext } from 'hooks/useContext'; +import { IObservationsContext } from 'contexts/observationsContext'; +import { IObservationsPageContext } from 'contexts/observationsPageContext'; +import dayjs from 'dayjs'; +import { ISurveySampleMethodPeriodData } from 'features/surveys/components/MethodForm'; import { IGetSamplePeriodRecord } from 'interfaces/useSurveyApi.interface'; +import { ImportObservationsButton } from './import-observations/ImportObservationsButton'; -export interface ISamplingSiteListPeriodProps { - samplePeriod: IGetSamplePeriodRecord; +interface ISamplingSiteListPeriodProps { + samplePeriods: (IGetSamplePeriodRecord | ISurveySampleMethodPeriodData)[]; + observationsPageContext?: IObservationsPageContext; + observationsContext?: IObservationsContext; } - /** - * Renders a list item for a single sampling period. - * - * @param {ISamplingSiteListPeriodProps} props - * @return {*} + * Renders sampling periods for a sampling method + * @param props {ISamplingSiteListPeriodProps} + * @returns */ -export const SamplingSiteListPeriod = (props: ISamplingSiteListPeriodProps) => { - const { samplePeriod } = props; +const SamplingSiteListPeriod = (props: ISamplingSiteListPeriodProps) => { + const formatDate = (dt: Date, time: boolean) => dayjs(dt).format(time ? 'MMM D, YYYY h:mm A' : 'MMM D, YYYY'); + + const { observationsPageContext, observationsContext } = props; + + const dateSx = { + fontSize: '0.85rem', + color: 'textSecondary' + }; - const observationsContext = useObservationsContext(); - const observationsPageContext = useObservationsPageContext(); + const timeSx = { + fontSize: '0.85rem', + color: 'text.secondary' + }; return ( - - - - - - - {`${samplePeriod.start_date} ${samplePeriod.start_time ?? ''} - ${samplePeriod.end_date} ${ - samplePeriod.end_time ?? '' - }`} - - - { - observationsPageContext.setIsDisabled(true); - observationsPageContext.setIsLoading(true); - }} - onSuccess={() => { - observationsContext.observationsDataLoader.refresh(); - }} - onFinish={() => { - observationsPageContext.setIsDisabled(false); - observationsPageContext.setIsLoading(false); - }} - processOptions={{ surveySamplePeriodId: samplePeriod.survey_sample_period_id }} - /> - + + {props.samplePeriods + .sort((a, b) => { + const startDateA = new Date(a.start_date); + const startDateB = new Date(b.start_date); + + if (startDateA === startDateB) { + if (a.start_time && b.start_time) { + if (a.start_time < b.start_time) return 1; + if (a.start_time > b.start_time) return -1; + return 0; + } + if (a.start_time && !b.start_time) { + return -1; + } + } + if (startDateA < startDateB) { + return -1; + } + if (startDateA > startDateB) { + return 1; + } + return 0; + }) + .map((samplePeriod, index) => ( + + + {props.samplePeriods.length > 1 ? ( + + + {index < props.samplePeriods.length - 1 && ( + + )} + + ) : ( + + + + )} + + + + + + {formatDate(samplePeriod.start_date as unknown as Date, false)} + + + {samplePeriod.start_time} + + + + + + + + {formatDate(samplePeriod.end_date as unknown as Date, false)} + + + {samplePeriod.end_time} + + + {observationsPageContext && observationsContext && samplePeriod?.survey_sample_period_id && ( + + { + observationsPageContext.setIsDisabled(true); + observationsPageContext.setIsLoading(true); + }} + onSuccess={() => { + observationsContext.observationsDataLoader.refresh(); + }} + onFinish={() => { + observationsPageContext.setIsDisabled(false); + observationsPageContext.setIsLoading(false); + }} + processOptions={{ surveySamplePeriodId: samplePeriod.survey_sample_period_id }} + /> + + )} + + + + ))} + ); }; + +export default SamplingSiteListPeriod; diff --git a/app/src/features/surveys/observations/sampling-sites/list/SamplingSiteListSite.tsx b/app/src/features/surveys/observations/sampling-sites/list/SamplingSiteListSite.tsx index 1be36f2f51..fa3817f77c 100644 --- a/app/src/features/surveys/observations/sampling-sites/list/SamplingSiteListSite.tsx +++ b/app/src/features/surveys/observations/sampling-sites/list/SamplingSiteListSite.tsx @@ -1,17 +1,21 @@ -import { mdiChevronDown, mdiDotsVertical } from '@mdi/js'; +import { mdiChevronDown, mdiDotsVertical, mdiMapMarker, mdiVectorLine, mdiVectorSquare } from '@mdi/js'; import Icon from '@mdi/react'; import Accordion from '@mui/material/Accordion'; import AccordionDetails from '@mui/material/AccordionDetails'; import AccordionSummary from '@mui/material/AccordionSummary'; import Box from '@mui/material/Box'; import Checkbox from '@mui/material/Checkbox'; +import blue from '@mui/material/colors/blue'; import grey from '@mui/material/colors/grey'; import IconButton from '@mui/material/IconButton'; import List from '@mui/material/List'; import Stack from '@mui/material/Stack'; import Typography from '@mui/material/Typography'; +import { IStaticLayer } from 'components/map/components/StaticLayers'; import { SamplingSiteListMethod } from 'features/surveys/observations/sampling-sites/list/SamplingSiteListMethod'; +import SurveyMap from 'features/surveys/view/SurveyMap'; import { IGetSampleLocationDetails } from 'interfaces/useSurveyApi.interface'; +import SamplingStratumChips from '../edit/components/SamplingStratumChips'; export interface ISamplingSiteListSiteProps { sampleSite: IGetSampleLocationDetails; @@ -29,6 +33,28 @@ export interface ISamplingSiteListSiteProps { export const SamplingSiteListSite = (props: ISamplingSiteListSiteProps) => { const { sampleSite, isChecked, handleSampleSiteMenuClick, handleCheckboxChange } = props; + const staticLayers: IStaticLayer[] = [ + { + layerName: 'Sample Sites', + layerColors: { color: blue[500], fillColor: blue[500] }, + features: [ + { + key: sampleSite.survey_sample_site_id, + geoJSON: sampleSite.geojson + } + ] + } + ]; + + let icon; + if (sampleSite.geojson.geometry.type === 'Point') { + icon = { path: mdiMapMarker, title: 'Point sampling site' }; + } else if (sampleSite.geojson.geometry.type === 'LineString') { + icon = { path: mdiVectorLine, title: 'Transect sampling site' }; + } else { + icon = { path: mdiVectorSquare, title: 'Polygon sampling site' }; + } + return ( { }}> {sampleSite.name} + + + { + {sampleSite.sample_stratums && sampleSite.sample_stratums?.length > 0 && ( + + + + )} - {sampleSite.sample_methods?.map((sampleMethod) => { + {sampleSite.sample_methods?.map((sampleMethod, index) => { return ( ); })} + + + ); diff --git a/app/src/features/surveys/observations/sampling-sites/list/import-observations/ImportObservationsButton.tsx b/app/src/features/surveys/observations/sampling-sites/list/import-observations/ImportObservationsButton.tsx index 47f7ee0dea..4b0a1ef9b5 100644 --- a/app/src/features/surveys/observations/sampling-sites/list/import-observations/ImportObservationsButton.tsx +++ b/app/src/features/surveys/observations/sampling-sites/list/import-observations/ImportObservationsButton.tsx @@ -1,6 +1,4 @@ -import { mdiImport } from '@mdi/js'; -import Icon from '@mdi/react'; -import IconButton from '@mui/material/IconButton'; +import Button from '@mui/material/Button'; import Typography from '@mui/material/Typography'; import FileUploadDialog from 'components/dialog/FileUploadDialog'; import { UploadFileStatus } from 'components/file-upload/FileUploadItem'; @@ -131,13 +129,15 @@ export const ImportObservationsButton = (props: IImportObservationsButtonProps) return ( <> - setOpen(true)} - disabled={disabled || false} - aria-label="Import Observations"> - - + disabled={disabled || false}> + Import + { } subTitleJSX={ - Survey Timeline: + {getFormattedDateRangeString( - DATE_FORMAT.ShortMediumDateFormat, + DATE_FORMAT.MediumDateFormat, surveyWithDetails.surveyData.survey_details.start_date, surveyWithDetails.surveyData.survey_details.end_date )} - - + + + } buttonJSX={ diff --git a/app/src/features/surveys/view/SurveyMap.tsx b/app/src/features/surveys/view/SurveyMap.tsx index 0f86c8045f..8594b35fdd 100644 --- a/app/src/features/surveys/view/SurveyMap.tsx +++ b/app/src/features/surveys/view/SurveyMap.tsx @@ -1,23 +1,16 @@ -import Box from '@mui/material/Box'; -import Skeleton from '@mui/material/Skeleton'; -import Stack from '@mui/material/Stack'; -import useTheme from '@mui/material/styles/useTheme'; -import Typography from '@mui/material/Typography'; import { SkeletonMap } from 'components/loading/SkeletonLoaders'; import BaseLayerControls from 'components/map/components/BaseLayerControls'; import { SetMapBounds } from 'components/map/components/Bounds'; import FullScreenScrollingEventHandler from 'components/map/components/FullScreenScrollingEventHandler'; -import StaticLayers, { IStaticLayer, IStaticLayerFeature } from 'components/map/components/StaticLayers'; +import StaticLayers, { IStaticLayer } from 'components/map/components/StaticLayers'; import { MapBaseCss } from 'components/map/styles/MapBaseCss'; import { ALL_OF_BC_BOUNDARY, MAP_DEFAULT_CENTER } from 'constants/spatial'; -import { CodesContext } from 'contexts/codesContext'; import { SurveyContext } from 'contexts/surveyContext'; import { Feature } from 'geojson'; import { LatLngBoundsExpression } from 'leaflet'; -import { useContext, useMemo, useState } from 'react'; +import { useContext, useMemo } from 'react'; import { LayersControl, MapContainer as LeafletMapContainer } from 'react-leaflet'; import { calculateUpdatedMapBounds } from 'utils/mapBoundaryUploadHelpers'; -import { getCodesName } from 'utils/Utils'; export interface ISurveyMapPointMetadata { label: string; @@ -29,6 +22,13 @@ export interface ISurveyMapSupplementaryLayer { * The name of the layer */ layerName: string; + /** + * The colour of the layer + */ + layerColors?: { + color: string; + fillColor: string; + }; /** * The array of map points */ @@ -62,97 +62,13 @@ export interface ISurveyMapPoint { } interface ISurveyMapProps { + staticLayers: IStaticLayer[]; supplementaryLayers: ISurveyMapSupplementaryLayer[]; isLoading: boolean; } -interface ISurveyMapPopupProps { - isLoading: boolean; - title: string; - metadata: ISurveyMapPointMetadata[]; -} - -const SurveyMapPopup = (props: ISurveyMapPopupProps) => { - const theme = useTheme(); - - return ( - - {props.isLoading ? ( - - - - - - - - - - - - - - - - ) : ( - - - {props.title} - - - {props.metadata.map((metadata) => ( - - - {metadata.label}: - - - {metadata.value} - - - ))} - - - )} - - ); -}; - const SurveyMap = (props: ISurveyMapProps) => { - const [mapPointMetadata, setMapPointMetadata] = useState>({}); - const surveyContext = useContext(SurveyContext); - const codesContext = useContext(CodesContext); const studyAreaLocations = useMemo( () => surveyContext.surveyDataLoader.data?.surveyData.locations ?? [], @@ -180,91 +96,6 @@ const SurveyMap = (props: ISurveyMapProps) => { } }, [props.supplementaryLayers, studyAreaLocations, sampleSites]); - const staticLayers: IStaticLayer[] = [ - { - layerName: 'Study Areas', - features: studyAreaLocations.flatMap((location) => { - return location.geojson.map((feature, index) => { - return { - key: `${location.survey_location_id}-${index}`, - geoJSON: feature, - popup: ( - - ) - }; - }); - }) - }, - { - layerName: 'Sample Sites', - layerColors: { color: '#1f7dff', fillColor: '#1f7dff' }, - features: sampleSites.map((sampleSite, index) => { - return { - key: `${sampleSite.survey_sample_site_id}-${index}`, - geoJSON: sampleSite.geojson, - popup: ( - - getCodesName(codesContext.codesDataLoader.data, 'sample_methods', method.method_lookup_id) ?? '' - ) - .filter(Boolean) - .join(', ') - } - ]} - /> - ) - }; - }) - }, - ...props.supplementaryLayers.map((supplementaryLayer) => { - return { - layerName: supplementaryLayer.layerName, - layerColors: { fillColor: '#1f7dff', color: '#FFFFFF' }, - features: supplementaryLayer.mapPoints.map((mapPoint: ISurveyMapPoint): IStaticLayerFeature => { - const isLoading = !mapPointMetadata[mapPoint.key]; - - return { - key: mapPoint.key, - geoJSON: mapPoint.feature, - GeoJSONProps: { - onEachFeature: (_, layer) => { - layer.on({ - popupopen: () => { - if (mapPointMetadata[mapPoint.key]) { - return; - } - - mapPoint.onLoadMetadata().then((metadata) => { - setMapPointMetadata((prev) => ({ ...prev, [mapPoint.key]: metadata })); - }); - } - }); - } - }, - popup: ( - - ) - }; - }) - }; - }) - ]; - return ( <> {props.isLoading ? ( @@ -282,7 +113,7 @@ const SurveyMap = (props: ISurveyMapProps) => { - + )} diff --git a/app/src/features/surveys/view/SurveyMapPopup.tsx b/app/src/features/surveys/view/SurveyMapPopup.tsx new file mode 100644 index 0000000000..7fdb8a8caa --- /dev/null +++ b/app/src/features/surveys/view/SurveyMapPopup.tsx @@ -0,0 +1,85 @@ +import Box from '@mui/material/Box'; +import Skeleton from '@mui/material/Skeleton'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import { ISurveyMapPointMetadata } from './SurveyMap'; + +interface ISurveyMapPopupProps { + isLoading: boolean; + title: string; + metadata: ISurveyMapPointMetadata[]; +} + +/** + * Returns a popup component for displaying information about a leaflet map layer upon being clicked + * + * @param props {ISurveyMapPopupProps} + * @returns + */ +const SurveyMapPopup = (props: ISurveyMapPopupProps) => { + return ( + + {props.isLoading ? ( + + + + + + + + + + + + + + + + ) : ( + + + {props.title} + + + {props.metadata.map((metadata) => ( + + + {metadata.label}: + + + {metadata.value} + + + ))} + + + )} + + ); +}; + +export default SurveyMapPopup; diff --git a/app/src/features/surveys/view/SurveyMapTooltip.tsx b/app/src/features/surveys/view/SurveyMapTooltip.tsx new file mode 100644 index 0000000000..d672a8d57d --- /dev/null +++ b/app/src/features/surveys/view/SurveyMapTooltip.tsx @@ -0,0 +1,21 @@ +import Typography from '@mui/material/Typography'; + +interface ISurveyMapTooltipProps { + label: string; +} + +/** + * Returns a popup component for displaying information about a leaflet map layer upon hover + * + * @param props {ISurveyMapPopupProps} + * @returns + */ +const SurveyMapTooltip = (props: ISurveyMapTooltipProps) => { + return ( + + {props.label} + + ); +}; + +export default SurveyMapTooltip; diff --git a/app/src/features/surveys/view/components/SurveyProgressChip.tsx b/app/src/features/surveys/view/components/SurveyProgressChip.tsx deleted file mode 100644 index 1a99cc183f..0000000000 --- a/app/src/features/surveys/view/components/SurveyProgressChip.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import Chip, { ChipProps } from '@mui/material/Chip'; -import { grey } from '@mui/material/colors'; -import { CodesContext } from 'contexts/codesContext'; -import { useContext } from 'react'; - -interface ISurveyProgressChipProps extends ChipProps { - progress_id: number; -} - -const SurveyProgressChip = (props: ISurveyProgressChipProps) => { - const codesContext = useContext(CodesContext); - const codes = codesContext.codesDataLoader.data; - - const codeName = codes?.survey_progress.find((code) => code.id === props.progress_id)?.name; - - const colorLookup: Record = { - Planning: '#84aac4', - 'In progress': '#dbaa81', - Completed: '#91bf9b' - }; - - // Providing a default color in case codeName is undefined - const color = codeName ? colorLookup[codeName] : grey[200]; - - return ( - {codeName}} - sx={{ - backgroundColor: color, - ml: '5px', - '& .MuiChip-label': { - mt: '1px', - letterSpacing: '0.03rem', - color: '#fff' - } - }} - {...props} - /> - ); -}; - -export default SurveyProgressChip; diff --git a/app/src/features/surveys/view/components/spatial-data/SurveySpatialData.tsx b/app/src/features/surveys/view/components/spatial-data/SurveySpatialData.tsx index 3c36b2a886..e9dd0a0bf0 100644 --- a/app/src/features/surveys/view/components/spatial-data/SurveySpatialData.tsx +++ b/app/src/features/surveys/view/components/spatial-data/SurveySpatialData.tsx @@ -1,6 +1,9 @@ import { mdiBroadcast, mdiEye } from '@mdi/js'; import Box from '@mui/material/Box'; import Paper from '@mui/material/Paper'; +import { IStaticLayer, IStaticLayerFeature } from 'components/map/components/StaticLayers'; +import { DATE_FORMAT } from 'constants/dateTimeFormats'; +import { SURVEY_MAP_LAYER_COLOURS } from 'constants/spatial'; import { CodesContext } from 'contexts/codesContext'; import { SurveyContext } from 'contexts/surveyContext'; import { TelemetryDataContext } from 'contexts/telemetryDataContext'; @@ -12,8 +15,11 @@ import useDataLoader from 'hooks/useDataLoader'; import { ITelemetry } from 'hooks/useTelemetryApi'; import { ISimpleCritterWithInternalId } from 'interfaces/useSurveyApi.interface'; import { useContext, useEffect, useMemo, useState } from 'react'; +import { getCodesName, getFormattedDate } from 'utils/Utils'; import { IAnimalDeployment } from '../../survey-animals/telemetry-device/device'; import SurveyMap, { ISurveyMapPoint, ISurveyMapPointMetadata, ISurveyMapSupplementaryLayer } from '../../SurveyMap'; +import SurveyMapPopup from '../../SurveyMapPopup'; +import SurveyMapTooltip from '../../SurveyMapTooltip'; import SurveySpatialObservationDataTable from './SurveySpatialObservationDataTable'; import SurveySpatialTelemetryDataTable from './SurveySpatialTelemetryDataTable'; import SurveySpatialToolbar, { SurveySpatialDatasetViewEnum } from './SurveySpatialToolbar'; @@ -36,6 +42,18 @@ const SurveySpatialData = () => { observationsGeometryDataLoader.load(); + const [mapPointMetadata, setMapPointMetadata] = useState>({}); + + const studyAreaLocations = useMemo( + () => surveyContext.surveyDataLoader.data?.surveyData.locations ?? [], + [surveyContext.surveyDataLoader.data] + ); + + const sampleSites = useMemo( + () => surveyContext.sampleSiteDataLoader.data?.sampleSites ?? [], + [surveyContext.sampleSiteDataLoader.data] + ); + useEffect(() => { if (surveyContext.deploymentDataLoader.data) { const deploymentIds = surveyContext.deploymentDataLoader.data.map((item) => item.deployment_id); @@ -151,13 +169,19 @@ const SurveySpatialData = () => { { label: 'Taxon ID', value: String(response.itis_tsn) }, { label: 'Count', value: String(response.count) }, { - label: 'Location', + label: 'Coords', value: [response.latitude, response.longitude] .filter((coord): coord is number => coord !== null) .map((coord) => coord.toFixed(6)) .join(', ') }, - { label: 'Date', value: dayjs(`${response.observation_date} ${response.observation_time}`).toISOString() } + { + label: 'Date', + value: getFormattedDate( + response.observation_time ? DATE_FORMAT.ShortMediumDateTimeFormat : DATE_FORMAT.ShortMediumDateFormat, + `${response.observation_date} ${response.observation_time}` + ) + } ]; } }; @@ -169,15 +193,15 @@ const SurveySpatialData = () => { let isLoading = false; if (activeView === SurveySpatialDatasetViewEnum.OBSERVATIONS) { isLoading = - codesContext.codesDataLoader.isLoading || - surveyContext.sampleSiteDataLoader.isLoading || + codesContext.codesDataLoader.isLoading ?? + surveyContext.sampleSiteDataLoader.isLoading ?? observationsContext.observationsDataLoader.isLoading; } if (activeView === SurveySpatialDatasetViewEnum.TELEMETRY) { isLoading = - codesContext.codesDataLoader.isLoading || - surveyContext.deploymentDataLoader.isLoading || + codesContext.codesDataLoader.isLoading ?? + surveyContext.deploymentDataLoader.isLoading ?? surveyContext.critterDataLoader.isLoading; } @@ -187,6 +211,10 @@ const SurveySpatialData = () => { return [ { layerName: 'Observations', + layerColors: { + fillColor: SURVEY_MAP_LAYER_COLOURS.OBSERVATIONS_COLOUR, + color: SURVEY_MAP_LAYER_COLOURS.OBSERVATIONS_COLOUR + }, popupRecordTitle: 'Observation Record', mapPoints: observationPoints } @@ -195,6 +223,10 @@ const SurveySpatialData = () => { return [ { layerName: 'Telemetry', + layerColors: { + fillColor: SURVEY_MAP_LAYER_COLOURS.TELEMETRY_COLOUR, + color: SURVEY_MAP_LAYER_COLOURS.TELEMETRY_COLOUR + }, popupRecordTitle: 'Telemetry Record', mapPoints: telemetryPoints } @@ -205,6 +237,103 @@ const SurveySpatialData = () => { } }, [activeView, observationPoints, telemetryPoints]); + const staticLayers: IStaticLayer[] = [ + { + layerName: 'Study Areas', + layerColors: { + color: SURVEY_MAP_LAYER_COLOURS.STUDY_AREA_COLOUR, + fillColor: SURVEY_MAP_LAYER_COLOURS.STUDY_AREA_COLOUR + }, + features: studyAreaLocations.flatMap((location) => { + return location.geojson.map((feature, index) => { + return { + key: `${location.survey_location_id}-${index}`, + geoJSON: feature, + popup: ( + + ), + tooltip: + }; + }); + }) + }, + { + layerName: 'Sample Sites', + layerColors: { + color: SURVEY_MAP_LAYER_COLOURS.SAMPLING_SITE_COLOUR, + fillColor: SURVEY_MAP_LAYER_COLOURS.SAMPLING_SITE_COLOUR + }, + features: sampleSites.map((sampleSite, index) => { + return { + key: `${sampleSite.survey_sample_site_id}-${index}`, + geoJSON: sampleSite.geojson, + popup: ( + + getCodesName(codesContext.codesDataLoader.data, 'sample_methods', method.method_lookup_id) ?? '' + ) + .filter(Boolean) + .join(', ') + } + ]} + /> + ), + tooltip: + }; + }) + }, + ...supplementaryLayers.map((supplementaryLayer) => { + return { + layerName: supplementaryLayer.layerName, + layerColors: { + fillColor: supplementaryLayer.layerColors?.fillColor ?? SURVEY_MAP_LAYER_COLOURS.DEFAULT_COLOUR, + color: supplementaryLayer.layerColors?.color ?? SURVEY_MAP_LAYER_COLOURS.DEFAULT_COLOUR + }, + features: supplementaryLayer.mapPoints.map((mapPoint: ISurveyMapPoint): IStaticLayerFeature => { + const isLoading = !mapPointMetadata[mapPoint.key]; + + return { + key: mapPoint.key, + geoJSON: mapPoint.feature, + GeoJSONProps: { + onEachFeature: (_, layer) => { + layer.on({ + popupopen: () => { + if (mapPointMetadata[mapPoint.key]) { + return; + } + mapPoint.onLoadMetadata().then((metadata) => { + setMapPointMetadata((prev) => ({ ...prev, [mapPoint.key]: metadata })); + }); + } + }); + } + }, + popup: ( + + ), + tooltip: + }; + }) + }; + }) + ]; + return ( { /> - + + {activeView === SurveySpatialDatasetViewEnum.OBSERVATIONS && ( diff --git a/app/src/features/surveys/view/components/spatial-data/SurveySpatialToolbar.tsx b/app/src/features/surveys/view/components/spatial-data/SurveySpatialToolbar.tsx index 074c0ca34c..194d06a963 100644 --- a/app/src/features/surveys/view/components/spatial-data/SurveySpatialToolbar.tsx +++ b/app/src/features/surveys/view/components/spatial-data/SurveySpatialToolbar.tsx @@ -124,7 +124,7 @@ const SurveySpatialToolbar = (props: ISurveySptialToolbarProps) => { - + => { weight: 1 }); }; + +/** + * Returns custom map marker for symbolizing observations + * + * @param fillColor + * @returns + */ +export const generateCustomPointMarkerUrl = (fillColor?: string) => + 'data:image/svg+xml;base64,' + + btoa( + `` + ); + +export const coloredCustomPointMarker = (point: MapPointProps): L.Marker => { + return new L.Marker(point.latlng, { + icon: L.icon({ iconUrl: generateCustomPointMarkerUrl(point?.fillColor), iconSize: [20, 15], iconAnchor: [12, 12] }) + }); +}; diff --git a/database/src/migrations/20240310163000_observation_sign.ts b/database/src/migrations/20240310163000_observation_sign.ts new file mode 100644 index 0000000000..a95e51c088 --- /dev/null +++ b/database/src/migrations/20240310163000_observation_sign.ts @@ -0,0 +1,110 @@ +import { Knex } from 'knex'; + +/** + * Create new tables with initial data: + * - observation_subcount_sign + * + * Update existing tables: + * - Add 'observation_subcount_sign_id' column to subcount table, referencing observation_subcount_sign table + * + * @export + * @param {Knex} knex + * @return {*} {Promise} + */ +export async function up(knex: Knex): Promise { + await knex.raw(`--sql + ---------------------------------------------------------------------------------------- + -- Create observation subcount sign lookup table + ---------------------------------------------------------------------------------------- + SET search_path = biohub; + + CREATE TABLE observation_subcount_sign ( + observation_subcount_sign_id integer GENERATED ALWAYS AS IDENTITY (START WITH 1 INCREMENT BY 1), + name varchar(32) NOT NULL, + description varchar(256), + record_end_date timestamptz(6), + 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 observation_subcount_sign_pk PRIMARY KEY (observation_subcount_sign_id) + ); + + COMMENT ON TABLE observation_subcount_sign IS 'This table is intended to store options that users can select for the sign of a subcount.'; + COMMENT ON COLUMN observation_subcount_sign.observation_subcount_sign_id IS 'Primary key for observation_subcount_sign.'; + COMMENT ON COLUMN observation_subcount_sign.name IS 'Name of the sign option.'; + COMMENT ON COLUMN observation_subcount_sign.description IS 'Description of the sign option.'; + COMMENT ON COLUMN observation_subcount_sign.record_end_date IS 'End date of the sign option.'; + COMMENT ON COLUMN observation_subcount_sign.create_date IS 'The datetime the record was created.'; + COMMENT ON COLUMN observation_subcount_sign.create_user IS 'The id of the user who created the record as identified in the system user table.'; + COMMENT ON COLUMN observation_subcount_sign.update_date IS 'The datetime the record was updated.'; + COMMENT ON COLUMN observation_subcount_sign.update_user IS 'The id of the user who updated the record as identified in the system user table.'; + COMMENT ON COLUMN observation_subcount_sign.revision_count IS 'Revision count used for concurrency control.'; + + ---------------------------------------------------------------------------------------- + -- Add triggers for user data + ---------------------------------------------------------------------------------------- + CREATE TRIGGER audit_observation_subcount_sign BEFORE INSERT OR UPDATE OR DELETE ON biohub.observation_subcount_sign FOR EACH ROW EXECUTE PROCEDURE tr_audit_trigger(); + CREATE TRIGGER journal_observation_subcount_sign AFTER INSERT OR UPDATE OR DELETE ON biohub.observation_subcount_sign FOR EACH ROW EXECUTE PROCEDURE tr_journal_trigger(); + + ---------------------------------------------------------------------------------------- + -- Modify observation_subcount table to include sign + ---------------------------------------------------------------------------------------- + ALTER TABLE observation_subcount ADD COLUMN observation_subcount_sign_id INTEGER; + COMMENT ON COLUMN observation_subcount.observation_subcount_sign_id IS 'Foreign key referencing a record in the observation_subcount_sign table.'; + ALTER TABLE observation_subcount ADD CONSTRAINT observation_subcount_sign_fk1 FOREIGN KEY (observation_subcount_sign_id) REFERENCES observation_subcount_sign(observation_subcount_sign_id); + + ---------------------------------------------------------------------------------------- + -- Indexes on foreign keys + ---------------------------------------------------------------------------------------- + CREATE UNIQUE INDEX observation_subcount_sign_nuk1 ON observation_subcount_sign(name, (record_end_date is NULL)) where record_end_date is null; + CREATE INDEX observation_subcount_idx2 ON observation_subcount(observation_subcount_sign_id); + + ---------------------------------------------------------------------------------------- + -- Add initial values + ---------------------------------------------------------------------------------------- + INSERT INTO observation_subcount_sign (name, description) + VALUES + ( + 'Direct sighting', + 'Observing the species visually, in person or an image.' + ), + ( + 'Sound', + 'Detecting the species through noises like calls or songs.' + ), + ( + 'Tracks', + 'Observing footprints or marks left by the species.' + ), + ( + 'Refugia', + 'Observing shelters or structures created by the species, like nests, dens, or burrows.' + ), + ( + 'Hair', + 'Detecting the species by visually analyzing hair or fur left by the species.' + ), + ( + 'DNA', + 'Detecting the species by analyzing genetic material, such as hair or feces.' + ); + + ---------------------------------------------------------------------------------------- + -- Add view for observation subcount sign table + ---------------------------------------------------------------------------------------- + SET SEARCH_PATH=biohub_dapi_v1; + + CREATE VIEW observation_subcount_sign AS SELECT * FROM biohub.observation_subcount_sign; + + ---------------------------------------------------------------------------------------- + -- Replace observation_subcount view to include observation_subcount_sign_id + ---------------------------------------------------------------------------------------- + CREATE OR REPLACE VIEW observation_subcount AS SELECT * FROM biohub.observation_subcount; + `); +} + +export async function down(knex: Knex): Promise { + await knex.raw(``); +} diff --git a/database/src/seeds/03_basic_project_survey_setup.ts b/database/src/seeds/03_basic_project_survey_setup.ts index e8980d30e1..abc521b42b 100644 --- a/database/src/seeds/03_basic_project_survey_setup.ts +++ b/database/src/seeds/03_basic_project_survey_setup.ts @@ -88,6 +88,15 @@ export async function seed(knex: Knex): Promise { ${insertSurveySamplePeriodData(surveyId)} `); + const response1 = await knex.raw(insertSurveyObservationData(surveyId, 20)); + await knex.raw(insertObservationSubCount(response1.rows[0].survey_observation_id)); + + const response2 = await knex.raw(insertSurveyObservationData(surveyId, 20)); + await knex.raw(insertObservationSubCount(response2.rows[0].survey_observation_id)); + + const response3 = await knex.raw(insertSurveyObservationData(surveyId, 20)); + await knex.raw(insertObservationSubCount(response3.rows[0].survey_observation_id)); + for (let k = 0; k < NUM_SEED_OBSERVATIONS_PER_SURVEY; k++) { const createObservationResponse = await knex.raw( // set the number of observations to minimum 20 times the number of subcounts (which are set to a number @@ -608,12 +617,14 @@ const insertObservationSubCount = (surveyObservationId: number) => ` INSERT INTO observation_subcount ( survey_observation_id, - subcount + subcount, + observation_subcount_sign_id ) VALUES ( ${surveyObservationId}, - $$${faker.number.int({ min: 1, max: 20 })}$$ + $$${faker.number.int({ min: 1, max: 20 })}$$, + $$${faker.number.int({ min: 1, max: 3 })}$$ ); `; From e7530ebb52abea8fd572f9c601cb5b3de88d91f4 Mon Sep 17 00:00:00 2001 From: Mac Deluca <99926243+MacQSL@users.noreply.github.com> Date: Wed, 1 May 2024 10:58:36 -0700 Subject: [PATCH 07/31] Simsbiohub 496 bctw deployments script (#1272) BCTW deployments transferred to SIMS. Uses JQ + JS + Bash to generate SIMS SQL. --- Makefile | 8 +- scripts/bctw-deployments/.gitignore | 5 + scripts/bctw-deployments/README.md | 112 + scripts/bctw-deployments/files/input.dev.json | 9146 +++++++++++++++++ scripts/bctw-deployments/main.js | 216 + scripts/bctw-deployments/run.sh | 27 + 6 files changed, 9513 insertions(+), 1 deletion(-) create mode 100644 scripts/bctw-deployments/.gitignore create mode 100644 scripts/bctw-deployments/README.md create mode 100644 scripts/bctw-deployments/files/input.dev.json create mode 100755 scripts/bctw-deployments/main.js create mode 100755 scripts/bctw-deployments/run.sh diff --git a/Makefile b/Makefile index 28fe0a8c4e..0cccbcc54e 100644 --- a/Makefile +++ b/Makefile @@ -23,7 +23,7 @@ env: | setup ## Copies the default ./env_config/env.docker to ./.env postgres: | close build-postgres run-postgres ## Performs all commands necessary to run the postgres project (db) in docker backend: | close build-backend run-backend ## Performs all commands necessary to run all backend projects (db, api) in docker -web: | close build-web run-web ## Performs all commands necessary to run all backend+web projects (db, api, app) in docker +web: | close build-web check-env run-web ## Performs all commands necessary to run all backend+web projects (db, api, app) in docker db-setup: | build-db-setup run-db-setup ## Performs all commands necessary to run the database migrations and seeding db-migrate: | build-db-migrate run-db-migrate ## Performs all commands necessary to run the database migrations @@ -62,6 +62,12 @@ prune: ## Deletes ALL docker artifacts (even those not associated to this projec @docker system prune --all --volumes -f @docker volume prune --all -f +check-env: ## Check for missing env vars + @echo "===============================================" + @echo "Make: check-env - check for missing env vars" + @echo "===============================================" + @awk_output=$$(awk -F= 'NR==FNR{a[$$1]; next} !($$1 in a)' .env env_config/env.docker); if [ -z "$$awk_output" ]; then echo "Up to date"; else echo "Missing ENV variables!\n$$awk_output"; fi + ## ------------------------------------------------------------------------------ ## Build/Run Postgres DB Commands ## - Builds all of the SIMS postgres db projects (db, db_setup) diff --git a/scripts/bctw-deployments/.gitignore b/scripts/bctw-deployments/.gitignore new file mode 100644 index 0000000000..023037a2f2 --- /dev/null +++ b/scripts/bctw-deployments/.gitignore @@ -0,0 +1,5 @@ +# Ignore all files in this directory +files/* + +!files/input.dev.json + diff --git a/scripts/bctw-deployments/README.md b/scripts/bctw-deployments/README.md new file mode 100644 index 0000000000..184761dbf7 --- /dev/null +++ b/scripts/bctw-deployments/README.md @@ -0,0 +1,112 @@ +# Generate SQL from existing BCTW Caribou deployments + +## Purpose +SIMS needs to be updated to include existing BCTW deployments. This script combines BCTW deployments with +matching critters and injects Caribou region herd geometries. + +## Evironment Notes +This script has been developed with a Linux environment and will only work with WSL2 or native Linux distro. + +## Pre requisites +Packages: + - jq + - docker + - node + +1. BCTW SQL - Export valid telemetry collar deployments as JSON. +```sql +SELECT + deployment_id, + critter_id, + attachment_start +FROM collar_animal_assignment +WHERE bctw.is_valid(valid_to) +AND attachment_start IS NOT NULL; +``` +2. Critterbase SQL - Export all caribou as JSON. +```sql +SELECT + c.critter_id, + u.unit_name +FROM + critter c +JOIN critter_collection_unit cc +ON + c.critter_id = cc.critter_id +JOIN xref_collection_unit u +ON + cc.collection_unit_id = u.collection_unit_id +WHERE + c.itis_tsn = 180701; +``` +3. Create JSON file from previous outputs as single array. + +- critter_deployments.json +```json +[ + { + "critter_id": "A", + "unit_name": "Atlin" + }, + { + "critter_id": "B", + "unit_name": "Atlin" + }, + { + "deployment_id": "C", + "critter_id": "A", + "attachment_start": "2024-01-01", + }, + { + "deployment_id": "D", + "critter_id": "B", + "attachment_start": "2024-01-01", + }, + ... +] + +``` + +## How to run +1. Create input file. +2. Call run.sh with input file as argument. + +```bash +./run.sh {input-filename}.json + +# Dev dataset example +./run.sh input.dev.json +``` + +## Requirements +- Ticket: [SIMSBIOHUB-496](https://apps.nrs.gov.bc.ca/int/jira/browse/SIMSBIOHUB-496) + +As a biologist who had uploaded telemetry deployments to BCTW through the BCTW UI, I want to see those deployment IDs in a SIMS Survey. + +When deciding what Survey to put a deployment ID in, group by deployment year and caribou herd. + +For example, +- Telkwa herd would be a Project +- Telkwa herd 2021 would be a Survey +- Telkwa herd 2022 would be a Survey +- Porcupine herd 2022 would be a Survey + +#### For every Survey, make a new Project with the following values: + +- Name: Caribou herd name - BCTW Telemetry +- Program: Wildlife +- Dates: Jan. 1 - Dec. 31 of {year} +- Objectives: Telemetry deployments for Caribou herd name in year. + +#### For each Survey, include these values: + +- Name: Caribou herd name - Year - BCTW Telemetry +- Type: Monitoring +- Start date: Date of the earliest telemetry deployment in the Survey +- End date: Date of the last telemetry deployment in the Survey +- Species: Caribou +- Ecological Variables: Mortality, Distribution +- Site selection strategy: "Oportunistic" (have to add this as an option) +- Study area: Relevant herd's boundary + + diff --git a/scripts/bctw-deployments/files/input.dev.json b/scripts/bctw-deployments/files/input.dev.json new file mode 100644 index 0000000000..8a0fc35d29 --- /dev/null +++ b/scripts/bctw-deployments/files/input.dev.json @@ -0,0 +1,9146 @@ +[ + { + "critter_id" : "6a3d8634-f8df-4aac-a0ff-8ebb5cab07d8", + "unit_name" : "Atlin" + }, + { + "critter_id" : "e77e14e8-1dac-475c-bdb1-2c6bdaf97578", + "unit_name" : "Itcha-Ilgachuz" + }, + { + "critter_id" : "d3f8323f-e441-4191-9274-093c805ffc64", + "unit_name" : "Itcha-Ilgachuz" + }, + { + "critter_id" : "a63c0afa-47c2-4688-b8a3-fc4463d0d14e", + "unit_name" : "Little Rancheria" + }, + { + "critter_id" : "ed30fd30-5011-4f0e-8df1-edae7c006554", + "unit_name" : "Itcha-Ilgachuz" + }, + { + "critter_id" : "f4208d6e-2572-42f0-8c05-5fc7856345c5", + "unit_name" : "Tweedsmuir" + }, + { + "critter_id" : "a134a301-ce7f-4c3e-a116-3aaae6f582aa", + "unit_name" : "Itcha-Ilgachuz" + }, + { + "critter_id" : "7a8bca4e-177e-46fb-81eb-31788a4466a1", + "unit_name" : "Itcha-Ilgachuz" + }, + { + "critter_id" : "c06e8b7e-c108-4862-b184-309fee888150", + "unit_name" : "Itcha-Ilgachuz" + }, + { + "critter_id" : "cd71e3fe-840f-40b6-8a92-f8c25f1e37f5", + "unit_name" : "Maxhamish" + }, + { + "critter_id" : "d8d64480-818b-47da-bb16-684c8e18734d", + "unit_name" : "Itcha-Ilgachuz" + }, + { + "critter_id" : "7aecb1fc-7778-4768-b0cf-2e4ea4a3d9fd", + "unit_name" : "Itcha-Ilgachuz" + }, + { + "critter_id" : "aee7cdbd-f02b-49ca-8d08-5b306b82e979", + "unit_name" : "Itcha-Ilgachuz" + }, + { + "critter_id" : "c79f3b59-9c75-400e-82cb-637bb086300e", + "unit_name" : "Itcha-Ilgachuz" + }, + { + "critter_id" : "f0dc2059-dc54-4ce2-8987-5f0e65bf807a", + "unit_name" : "Itcha-Ilgachuz" + }, + { + "critter_id" : "f754f6a6-c08f-4aee-8ef5-943dfe493fba", + "unit_name" : "Itcha-Ilgachuz" + }, + { + "critter_id" : "a0585186-b93e-40f0-a9c0-c5392e3f8b00", + "unit_name" : "Itcha-Ilgachuz" + }, + { + "critter_id" : "804ae9cf-7e8d-472e-92ae-cc13f2e0dfa0", + "unit_name" : "Itcha-Ilgachuz" + }, + { + "critter_id" : "75c82af6-f274-493c-a8b3-9b3ad23a781a", + "unit_name" : "Itcha-Ilgachuz" + }, + { + "critter_id" : "fe4a231a-788f-4cb5-927d-736cee995be8", + "unit_name" : "Itcha-Ilgachuz" + }, + { + "critter_id" : "2cdef57a-098f-4ebc-873c-1eb75c8aebb4", + "unit_name" : "Itcha-Ilgachuz" + }, + { + "critter_id" : "fc619dea-7e6b-4f47-adda-fda830526afe", + "unit_name" : "Itcha-Ilgachuz" + }, + { + "critter_id" : "0455d6ff-b76b-45e6-8241-3dd53918cdea", + "unit_name" : "Itcha-Ilgachuz" + }, + { + "critter_id" : "82fc1fba-c538-43f8-84d9-60b243b7df77", + "unit_name" : "Itcha-Ilgachuz" + }, + { + "critter_id" : "bab2d15f-3d27-48a0-819d-2a37e18adc65", + "unit_name" : "Itcha-Ilgachuz" + }, + { + "critter_id" : "88802a51-e314-442d-8b82-842cc8cb5605", + "unit_name" : "Itcha-Ilgachuz" + }, + { + "critter_id" : "d4a2bd73-72e8-4738-9f40-4bc00472d8e6", + "unit_name" : "Itcha-Ilgachuz" + }, + { + "critter_id" : "6263f59e-726b-4273-92fe-08dbbf1c7638", + "unit_name" : "Itcha-Ilgachuz" + }, + { + "critter_id" : "b64a3a66-3fa5-4af9-a31f-095cd5e57404", + "unit_name" : "Itcha-Ilgachuz" + }, + { + "critter_id" : "56075f99-07f0-4ddf-ac82-64a342a7d5df", + "unit_name" : "Itcha-Ilgachuz" + }, + { + "critter_id" : "29f82991-712a-424e-a37c-852d6a2a1e9b", + "unit_name" : "Itcha-Ilgachuz" + }, + { + "critter_id" : "0006cd82-3da7-4674-9394-1d68d69716e7", + "unit_name" : "Itcha-Ilgachuz" + }, + { + "critter_id" : "0ec87511-70e5-487c-ad0a-137d674cec3a", + "unit_name" : "Itcha-Ilgachuz" + }, + { + "critter_id" : "0cd68d8c-cb02-42e9-8c38-3221eacd0996", + "unit_name" : "Itcha-Ilgachuz" + }, + { + "critter_id" : "f35b011b-d002-49f7-9628-79980df4e5b3", + "unit_name" : "Itcha-Ilgachuz" + }, + { + "critter_id" : "9c7ec486-cb21-4cce-962c-6595c46741f4", + "unit_name" : "Itcha-Ilgachuz" + }, + { + "critter_id" : "7c674fae-74ff-4116-a2a1-37d6dad9d2c4", + "unit_name" : "Itcha-Ilgachuz" + }, + { + "critter_id" : "30069510-1352-44dd-8f5e-aedb1cdaa88c", + "unit_name" : "Itcha-Ilgachuz" + }, + { + "critter_id" : "9342b7b1-2b68-4b40-aded-4a0fad51964d", + "unit_name" : "Itcha-Ilgachuz" + }, + { + "critter_id" : "798b852d-ee0c-47f9-9747-f035408b2cbf", + "unit_name" : "Wolverine" + }, + { + "critter_id" : "0907e89c-0b3b-4889-a1d4-428cb9c37a51", + "unit_name" : "Itcha-Ilgachuz" + }, + { + "critter_id" : "ee00b66c-f058-4a99-a297-840544599085", + "unit_name" : "Itcha-Ilgachuz" + }, + { + "critter_id" : "37e85a05-3606-46dc-baf5-95770c1ad48e", + "unit_name" : "Itcha-Ilgachuz" + }, + { + "critter_id" : "ff8c80d3-1626-49b7-9b85-3758289f8de2", + "unit_name" : "Itcha-Ilgachuz" + }, + { + "critter_id" : "e3a61332-9d1a-48fe-ae13-d31b3bb7e752", + "unit_name" : "Itcha-Ilgachuz" + }, + { + "critter_id" : "12ef22f4-93ca-4f22-a652-e306ba0ba8c9", + "unit_name" : "Little Rancheria" + }, + { + "critter_id" : "f0504cb6-52ba-4659-9703-853a399e7c1c", + "unit_name" : "Itcha-Ilgachuz" + }, + { + "critter_id" : "94bf52b1-b4af-4586-9677-25818da9a037", + "unit_name" : "Itcha-Ilgachuz" + }, + { + "critter_id" : "a4c2455e-b20e-49c8-88ce-d51f6784f0df", + "unit_name" : "Itcha-Ilgachuz" + }, + { + "critter_id" : "149998a1-5094-49ce-b0ea-ca49ab692021", + "unit_name" : "Itcha-Ilgachuz" + }, + { + "critter_id" : "6683aae2-fe68-45c3-8151-b0ed485ea2d8", + "unit_name" : "Itcha-Ilgachuz" + }, + { + "critter_id" : "992a6251-6b3b-49d3-966b-2e452fa5ecb2", + "unit_name" : "Itcha-Ilgachuz" + }, + { + "critter_id" : "d03604df-f188-4139-a23a-839bc4f29b4f", + "unit_name" : "Itcha-Ilgachuz" + }, + { + "critter_id" : "93abad3b-abd6-47e2-aa27-48b5c578e6d3", + "unit_name" : "Itcha-Ilgachuz" + }, + { + "critter_id" : "7499e6e9-f382-4482-afd0-f60618fc2942", + "unit_name" : "Tweedsmuir" + }, + { + "critter_id" : "aed49a38-5a94-4111-a63f-e29915bbf00a", + "unit_name" : "Itcha-Ilgachuz" + }, + { + "critter_id" : "ace1d379-436f-4929-b039-fc2b64747187", + "unit_name" : "Itcha-Ilgachuz" + }, + { + "critter_id" : "e9fe538e-926b-41e6-b72b-7c97500887e0", + "unit_name" : "Itcha-Ilgachuz" + }, + { + "critter_id" : "681f16b5-6b02-4bda-ae65-3d55e0a6ee20", + "unit_name" : "Itcha-Ilgachuz" + }, + { + "critter_id" : "6437ba57-0f1d-437d-8442-3a579e26eff3", + "unit_name" : "Itcha-Ilgachuz" + }, + { + "critter_id" : "5715e178-ee94-4586-93a1-df3e3a00e477", + "unit_name" : "Itcha-Ilgachuz" + }, + { + "critter_id" : "e7d771cc-7365-48f5-9dd0-629c1a5a03e1", + "unit_name" : "Itcha-Ilgachuz" + }, + { + "critter_id" : "ead00f5e-ab6b-40d2-868a-768a6d7bb11b", + "unit_name" : "Itcha-Ilgachuz" + }, + { + "critter_id" : "e535b48a-2773-4eec-b7ef-7746a6f67f92", + "unit_name" : "Itcha-Ilgachuz" + }, + { + "critter_id" : "6ff3eb3d-e756-4c3a-a0f9-32079013b5b3", + "unit_name" : "Itcha-Ilgachuz" + }, + { + "critter_id" : "c9ca97ad-bd90-4fc6-ada9-136468b9709b", + "unit_name" : "Itcha-Ilgachuz" + }, + { + "critter_id" : "b4718b72-d5b1-4aa4-b1b3-fa862dbd4ed8", + "unit_name" : "Little Rancheria" + }, + { + "critter_id" : "5526eae6-2203-40dc-b458-fa99f7e3c670", + "unit_name" : "Itcha-Ilgachuz" + }, + { + "critter_id" : "818ceccb-0351-4d38-8d9c-3be61a66c690", + "unit_name" : "Wolverine" + }, + { + "critter_id" : "ce31b26a-243b-4378-91c5-1f0733441162", + "unit_name" : "Itcha-Ilgachuz" + }, + { + "critter_id" : "ee4a7d05-6fac-473b-be18-da3283abdc59", + "unit_name" : "Graham" + }, + { + "critter_id" : "b66e9153-de22-4b07-b9d8-a7315ab73e0c", + "unit_name" : "Itcha-Ilgachuz" + }, + { + "critter_id" : "5c1bad57-7f3d-4d26-a135-18f5e78c7834", + "unit_name" : "Little Rancheria" + }, + { + "critter_id" : "e481c7fa-5e40-4331-8d7c-10cb7e821fac", + "unit_name" : "Itcha-Ilgachuz" + }, + { + "critter_id" : "59fecfb0-0a0d-4400-b596-546865285bcc", + "unit_name" : "Itcha-Ilgachuz" + }, + { + "critter_id" : "4bc23d12-da38-4ff0-bd0c-3ae3d6a9d7b8", + "unit_name" : "Itcha-Ilgachuz" + }, + { + "critter_id" : "ba9e6a8b-5b58-44b5-b6d3-0b5fb0029f90", + "unit_name" : "Itcha-Ilgachuz" + }, + { + "critter_id" : "f91ae2d3-4f5b-4502-aceb-264351709695", + "unit_name" : "Itcha-Ilgachuz" + }, + { + "critter_id" : "e801a45f-c205-4c1f-890c-c6112c5e963b", + "unit_name" : "Itcha-Ilgachuz" + }, + { + "critter_id" : "b25d32f3-a45b-4db4-9d20-ac4597baa80c", + "unit_name" : "Tweedsmuir" + }, + { + "critter_id" : "af69aefd-1489-4c3a-98e3-71eb028a2159", + "unit_name" : "Little Rancheria" + }, + { + "critter_id" : "9ed42a0a-9c65-4d31-9ef9-8ad2788d290a", + "unit_name" : "Little Rancheria" + }, + { + "critter_id" : "0e605033-9b20-44bf-95c1-c5f3864c72b9", + "unit_name" : "Snake-Sahtaneh" + }, + { + "critter_id" : "892e7a45-47f0-486b-a8f4-b2d716e6c983", + "unit_name" : "Itcha-Ilgachuz" + }, + { + "critter_id" : "b21e2841-1502-4ca4-82bf-524b29d1122e", + "unit_name" : "Itcha-Ilgachuz" + }, + { + "critter_id" : "e1a66520-9fdc-41c8-ad04-8ee5ade25513", + "unit_name" : "Itcha-Ilgachuz" + }, + { + "critter_id" : "f0419f13-f2f0-462f-9805-f207eb39394d", + "unit_name" : "Itcha-Ilgachuz" + }, + { + "critter_id" : "2e03112e-03bc-44ba-8856-7232cb3ee2f6", + "unit_name" : "Itcha-Ilgachuz" + }, + { + "critter_id" : "a14bf816-05d6-41ad-a381-8f626374c1d6", + "unit_name" : "Itcha-Ilgachuz" + }, + { + "critter_id" : "0a14917f-f816-4578-80bc-43d95d8c60a4", + "unit_name" : "Itcha-Ilgachuz" + }, + { + "critter_id" : "258d8e66-a66e-4cca-b9ac-e79c9ad8b379", + "unit_name" : "Itcha-Ilgachuz" + }, + { + "critter_id" : "5e967921-1968-44f1-a3fd-5d95adf5b6fa", + "unit_name" : "Itcha-Ilgachuz" + }, + { + "critter_id" : "2d02ba3c-027d-4964-8893-353070d2d52a", + "unit_name" : "Tweedsmuir" + }, + { + "critter_id" : "a0ae699e-88e9-42b3-9334-21e206a81495", + "unit_name" : "Wolverine" + }, + { + "critter_id" : "88be15fa-e771-44fc-9c63-c8198549f060", + "unit_name" : "Wolverine" + }, + { + "critter_id" : "d84d5192-5cc1-4fad-a619-cd76597b7164", + "unit_name" : "Wolverine" + }, + { + "critter_id" : "13dec3e6-3149-4be8-9e9f-255c04638f48", + "unit_name" : "Itcha-Ilgachuz" + }, + { + "critter_id" : "5223d7a3-1df2-4fe1-9342-84a1c8456b0d", + "unit_name" : "Tweedsmuir" + }, + { + "critter_id" : "874766e4-849d-458c-be51-ee454c07a762", + "unit_name" : "Tweedsmuir" + }, + { + "critter_id" : "e6f7871c-4823-4e59-b947-dbcf83339159", + "unit_name" : "Tweedsmuir" + }, + { + "critter_id" : "49aaec31-009b-49ae-9d0b-cb0638089a59", + "unit_name" : "Wolverine" + }, + { + "critter_id" : "b9c82866-5ca6-4475-bf76-5256b9f47b66", + "unit_name" : "Tweedsmuir" + }, + { + "critter_id" : "220d9506-0b5a-4f42-a70e-1000dd11c882", + "unit_name" : "Tweedsmuir" + }, + { + "critter_id" : "90159364-2296-4a93-b901-7b6d1c2b7c78", + "unit_name" : "Wolverine" + }, + { + "critter_id" : "d247071f-2aea-495b-bc28-fd96322bc73e", + "unit_name" : "Tweedsmuir" + }, + { + "critter_id" : "5a5161d9-1dbc-410b-9e92-6e0cb474671b", + "unit_name" : "Tweedsmuir" + }, + { + "critter_id" : "aa3ac68e-1201-47d5-8b98-5356de345bfe", + "unit_name" : "Tweedsmuir" + }, + { + "critter_id" : "b2e3e471-7589-4980-b372-6708f107fc74", + "unit_name" : "Tweedsmuir" + }, + { + "critter_id" : "15d57bca-7195-413f-93b7-b4c451671775", + "unit_name" : "Tweedsmuir" + }, + { + "critter_id" : "275ef6e8-c6d6-47e3-b39b-a54a2dc60306", + "unit_name" : "Tweedsmuir" + }, + { + "critter_id" : "66047565-28aa-44e8-be1f-a5cb0ddeb7f5", + "unit_name" : "Tweedsmuir" + }, + { + "critter_id" : "a106f358-99b8-4cf7-9eac-ab8a5c1b97a5", + "unit_name" : "Tweedsmuir" + }, + { + "critter_id" : "869bbfdb-8d8c-4c81-95d7-f67ed51cbe27", + "unit_name" : "Wolverine" + }, + { + "critter_id" : "b393838a-0ceb-4acf-8efc-c0262db35c17", + "unit_name" : "Little Rancheria" + }, + { + "critter_id" : "cd3e9c00-9505-4184-8a8d-c032ef37de48", + "unit_name" : "Tweedsmuir" + }, + { + "critter_id" : "e3b35b46-2744-458a-9792-215a6c3588cc", + "unit_name" : "Maxhamish" + }, + { + "critter_id" : "4a108879-59c0-4167-b687-d2b8903fd2ad", + "unit_name" : "Tweedsmuir" + }, + { + "critter_id" : "05b4a0d7-e31b-49d0-a34d-9a39d7556d00", + "unit_name" : "Tweedsmuir" + }, + { + "critter_id" : "f53106ef-df3d-4c34-84e2-744b87adf37d", + "unit_name" : "Tweedsmuir" + }, + { + "critter_id" : "139c1e72-0986-473d-8b85-f64722d25760", + "unit_name" : "Little Rancheria" + }, + { + "critter_id" : "c6b0a6c7-71ca-421a-96d6-1878fec07b05", + "unit_name" : "Tweedsmuir" + }, + { + "critter_id" : "d22efa1d-971f-48cf-9643-dc1431c4dcd7", + "unit_name" : "Wolverine" + }, + { + "critter_id" : "3e4c8346-7c30-4426-8521-96a5b8453635", + "unit_name" : "Tweedsmuir" + }, + { + "critter_id" : "8fa2d657-fd82-4930-a38f-a95374bd9bc4", + "unit_name" : "Wolverine" + }, + { + "critter_id" : "42c2c01a-be75-4d99-a691-ff6441dbe119", + "unit_name" : "Central Selkirks" + }, + { + "critter_id" : "bdc9d48d-ed5e-4bfe-863c-16dbad8aaa06", + "unit_name" : "Wolverine" + }, + { + "critter_id" : "75529e7b-bae9-4b99-8f60-dfb4c2f1967e", + "unit_name" : "Wolverine" + }, + { + "critter_id" : "a45704af-f21b-4bd3-96e8-4a5feaf3e8fa", + "unit_name" : "Wolverine" + }, + { + "critter_id" : "70c9826f-44dd-4eba-94e7-31fc98189212", + "unit_name" : "Telkwa" + }, + { + "critter_id" : "9b747d66-3265-4943-b234-93cc1a1c8925", + "unit_name" : "Little Rancheria" + }, + { + "critter_id" : "cefb3beb-2a44-47c0-a679-41e17796d2c4", + "unit_name" : "Little Rancheria" + }, + { + "critter_id" : "eba2063b-b520-43be-9817-953e00373b39", + "unit_name" : "Little Rancheria" + }, + { + "critter_id" : "eb1e0491-9298-4c12-ba1a-5a75086ce7ea", + "unit_name" : "Tweedsmuir" + }, + { + "critter_id" : "9d0c0b60-8366-437b-ba7c-e80b57564034", + "unit_name" : "Graham" + }, + { + "critter_id" : "60249924-5dae-4b23-b4cd-e3e4871eeeb5", + "unit_name" : "Horseranch" + }, + { + "critter_id" : "31d2a16e-ba45-4850-9d5c-7b381107e905", + "unit_name" : "Tsenaglode" + }, + { + "critter_id" : "fdcf3454-935e-4d72-847d-71cd1ff3f2ce", + "unit_name" : "Columbia South" + }, + { + "critter_id" : "2d708a24-2e87-4f4e-b38c-c0cb17eaf5c5", + "unit_name" : "Atlin" + }, + { + "critter_id" : "73f29518-5200-4588-8411-341022092778", + "unit_name" : "Chinchaga" + }, + { + "critter_id" : "0298c168-a243-4ab3-8fd5-24432517e77c", + "unit_name" : "Rabbit" + }, + { + "critter_id" : "d88531d7-b1d8-4c4f-86b7-591363750dad", + "unit_name" : "Tweedsmuir" + }, + { + "critter_id" : "59a33f28-0cca-44a3-8e40-8d7d1de2f968", + "unit_name" : "Frog" + }, + { + "critter_id" : "59a33f28-0cca-44a3-8e40-8d7d1de2f968", + "unit_name" : "Unit A" + }, + { + "critter_id" : "d7bff264-609e-4d48-ac2e-c7f938ee4823", + "unit_name" : "Finlay" + }, + { + "critter_id" : "bda29fa0-2f98-4e6e-89e1-e07b5209e081", + "unit_name" : "Frog" + }, + { + "critter_id" : "826aa7b4-31cf-44d5-80cd-fc159e42d7e7", + "unit_name" : "Frog" + }, + { + "critter_id" : "4b2848eb-c722-4409-93dc-1b3c0e5918a8", + "unit_name" : "Unit A" + }, + { + "critter_id" : "4b2848eb-c722-4409-93dc-1b3c0e5918a8", + "unit_name" : "Gataga" + }, + { + "critter_id" : "d6520c4a-9882-442d-9ae8-7bc3031a7716", + "unit_name" : "Thutade" + }, + { + "critter_id" : "c98a5d9a-fc59-457f-94e3-0d2d19d9f583", + "unit_name" : "Little Rancheria" + }, + { + "critter_id" : "c98a5d9a-fc59-457f-94e3-0d2d19d9f583", + "unit_name" : "Unit A" + }, + { + "critter_id" : "fc2e5a7c-4233-4a8a-a86a-28992af28ec3", + "unit_name" : "Unit B" + }, + { + "critter_id" : "fc2e5a7c-4233-4a8a-a86a-28992af28ec3", + "unit_name" : "Gataga" + }, + { + "critter_id" : "24c729d1-7fff-453f-91d2-e590aca13abc", + "unit_name" : "Wells Gray North" + }, + { + "critter_id" : "2665c271-80e1-48fa-91b2-58339b733338", + "unit_name" : "Finlay" + }, + { + "critter_id" : "2665c271-80e1-48fa-91b2-58339b733338", + "unit_name" : "Unit A" + }, + { + "critter_id" : "222c73f2-a393-49fb-8150-ad7ff26f32ea", + "unit_name" : "Swan Lake" + }, + { + "critter_id" : "222c73f2-a393-49fb-8150-ad7ff26f32ea", + "unit_name" : "Unit A" + }, + { + "critter_id" : "f985a5dd-82eb-4d77-a590-a5c9d9ea6067", + "unit_name" : "Spatsizi" + }, + { + "critter_id" : "ce945741-9b88-4f0a-bd60-09ccf65bf4ef", + "unit_name" : "Edziza" + }, + { + "critter_id" : "737d1025-9957-4d21-9251-c410f1a3e55d", + "unit_name" : "Finlay" + }, + { + "critter_id" : "a82bc327-5a87-427d-8516-35e92d6026d5", + "unit_name" : "Unit A" + }, + { + "critter_id" : "a82bc327-5a87-427d-8516-35e92d6026d5", + "unit_name" : "Frog" + }, + { + "critter_id" : "2904ffc0-4334-4631-a9c4-a89bcfcb7d71", + "unit_name" : "Unit A" + }, + { + "critter_id" : "2904ffc0-4334-4631-a9c4-a89bcfcb7d71", + "unit_name" : "Swan Lake" + }, + { + "critter_id" : "63417b9d-202a-4a7b-8ea4-f41ad3b3a4a5", + "unit_name" : "Rabbit" + }, + { + "critter_id" : "66edc84f-b481-45bb-a42c-cebc71c815ad", + "unit_name" : "Frog" + }, + { + "critter_id" : "66edc84f-b481-45bb-a42c-cebc71c815ad", + "unit_name" : "Unit A" + }, + { + "critter_id" : "7864673c-76c7-4a8c-b7ff-c33b42ba9f33", + "unit_name" : "Frog" + }, + { + "critter_id" : "a97cfecc-e810-4961-8656-e42666167a83", + "unit_name" : "Unit A" + }, + { + "critter_id" : "5c639cf7-c8fd-48ac-b65b-ad867920c476", + "unit_name" : "Unit A" + }, + { + "critter_id" : "050a29f5-92a1-40a5-a2d2-75317bd00543", + "unit_name" : "Rabbit" + }, + { + "critter_id" : "050a29f5-92a1-40a5-a2d2-75317bd00543", + "unit_name" : "Unit A" + }, + { + "critter_id" : "bf554737-2dd7-4427-b9d4-26c761a1271e", + "unit_name" : "Horseranch" + }, + { + "critter_id" : "bf554737-2dd7-4427-b9d4-26c761a1271e", + "unit_name" : "Unit A" + }, + { + "critter_id" : "8b4bc405-8088-4f7e-a4fa-84c960ca93ce", + "unit_name" : "Frog" + }, + { + "critter_id" : "6f298eef-9fc6-4a0b-9f56-3b1c68f3dc13", + "unit_name" : "Scott" + }, + { + "critter_id" : "6f298eef-9fc6-4a0b-9f56-3b1c68f3dc13", + "unit_name" : "Unit A" + }, + { + "critter_id" : "7ddb4299-7509-4cca-9fe7-51be5d125f3b", + "unit_name" : "Pink Mountain" + }, + { + "critter_id" : "12dce835-4c2f-46e0-ae25-f3cc7354b35c", + "unit_name" : "Takla" + }, + { + "critter_id" : "12dce835-4c2f-46e0-ae25-f3cc7354b35c", + "unit_name" : "Unit B" + }, + { + "critter_id" : "beded371-6b5c-4a27-a4dd-3299baf9b2a7", + "unit_name" : "Chinchaga" + }, + { + "critter_id" : "69764d05-835b-4cb4-bdbf-676051cf17c9", + "unit_name" : "Tweedsmuir" + }, + { + "critter_id" : "5c8c837e-6add-4269-839c-a57a761aad09", + "unit_name" : "Tweedsmuir" + }, + { + "critter_id" : "7a5a7314-869d-4746-a5a0-d114bdc20370", + "unit_name" : "Tweedsmuir" + }, + { + "critter_id" : "ef93f814-77b3-434a-9502-70c26bf7d6b9", + "unit_name" : "Tweedsmuir" + }, + { + "critter_id" : "43201d4d-f16f-4f8e-8413-dde5d4a195e6", + "unit_name" : "Columbia North" + }, + { + "critter_id" : "54ef0caa-790f-42a3-834d-142db6b3b50e", + "unit_name" : "Itcha-Ilgachuz" + }, + { + "critter_id" : "12ab62b2-31de-4cbe-b182-506fc79c9924", + "unit_name" : "Chinchaga" + }, + { + "critter_id" : "fbf49452-c2ac-4a6b-969e-23314488908a", + "unit_name" : "Tweedsmuir" + }, + { + "critter_id" : "4012087a-6fa0-4c0e-bcf5-0fd0cdda5b4f", + "unit_name" : "Wolverine" + }, + { + "critter_id" : "4c646192-1338-42fd-95f6-32e1cd5e6316", + "unit_name" : "Itcha-Ilgachuz" + }, + { + "critter_id" : "d9a04d94-a21f-4e33-8ae7-a97dcda61262", + "unit_name" : "Wolverine" + }, + { + "critter_id" : "4bd8fe08-f0e1-41fd-99b3-494fab00a763", + "unit_name" : "Itcha-Ilgachuz" + }, + { + "critter_id" : "0e634a79-de08-4db0-8a33-71dfdfdd9405", + "unit_name" : "Itcha-Ilgachuz" + }, + { + "critter_id" : "c20c4b75-e3b1-48eb-9924-5ae66f1e14ab", + "unit_name" : "Itcha-Ilgachuz" + }, + { + "critter_id" : "a2e6262e-346c-4ec6-8367-a4697cc1f362", + "unit_name" : "Finlay" + } + , + { + "deployment_id" : "9f0e9eb5-bd6b-417e-a81d-58ac53198e79", + "critter_id" : "78052eee-2156-4d23-843b-a821c86cccdd", + "attachment_start" : "2023-08-18T00:16:53.131Z" + }, + { + "deployment_id" : "10bff477-396d-4f25-b5a9-6cb2c7df0f40", + "critter_id" : "3c054312-19f8-4e60-b8f1-878c26b72687", + "attachment_start" : "2019-03-25T07:00:00.000Z" + }, + { + "deployment_id" : "d9a0aa17-51a5-4143-9329-5c7c5669f0fa", + "critter_id" : "23e8e62f-a3ab-46a8-8ec7-3c4a04a74130", + "attachment_start" : "2019-03-25T07:00:00.000Z" + }, + { + "deployment_id" : "10f944c5-ce13-467d-8149-6a926936e77b", + "critter_id" : "b55b2367-7dc6-400e-967f-d3a2664aacdb", + "attachment_start" : "2019-03-25T07:00:00.000Z" + }, + { + "deployment_id" : "01ae9793-2dd5-4200-be83-b5b5cb083e06", + "critter_id" : "9d0c0b60-8366-437b-ba7c-e80b57564034", + "attachment_start" : "2018-03-07T08:00:00.000Z" + }, + { + "deployment_id" : "f1ef7f2d-2807-4eec-b005-bdd711d3c74a", + "critter_id" : "bee0087e-5fab-4ff9-8175-8869d15b3055", + "attachment_start" : "2016-03-16T07:00:00.000Z" + }, + { + "deployment_id" : "1c6e9d4f-84c4-4f62-a3a6-48be4f420fb1", + "critter_id" : "4e569e26-c176-4199-bf19-74b383042649", + "attachment_start" : "2016-03-16T07:00:00.000Z" + }, + { + "deployment_id" : "c0956965-4253-4b1e-a450-1e01e3e4c253", + "critter_id" : "1ac35d3e-ce04-4c19-a6a8-b8c9d74cfe83", + "attachment_start" : "2019-01-29T08:00:00.000Z" + }, + { + "deployment_id" : "d31374b2-7e03-46d5-8b54-dffcc90b2e61", + "critter_id" : "11a6d3be-55b2-47fb-9122-6bf54f28d276", + "attachment_start" : "2016-03-16T07:00:00.000Z" + }, + { + "deployment_id" : "834f52f7-73e5-4b9b-99a8-a02d3fb4f69e", + "critter_id" : "865baeb3-aa5c-402f-84df-17ea177faf06", + "attachment_start" : "2017-02-08T08:00:00.000Z" + }, + { + "deployment_id" : "1e27b1c6-c651-4959-8569-659a778acf35", + "critter_id" : "f7f9a63a-af29-438c-9209-1af175d91212", + "attachment_start" : "2017-02-08T08:00:00.000Z" + }, + { + "deployment_id" : "d0aa2d60-11bb-4916-97f7-9b261eb5f731", + "critter_id" : "a7cf6a54-8a29-420b-8958-f41d2bc94594", + "attachment_start" : "2017-02-08T08:00:00.000Z" + }, + { + "deployment_id" : "6e770d5c-45a4-4c74-915e-25cc86d89138", + "critter_id" : "14919a42-d942-407d-8f55-27698ac4f034", + "attachment_start" : "2018-01-10T08:00:00.000Z" + }, + { + "deployment_id" : "da41a898-25ac-4dd8-aa8e-ad7f4c768955", + "critter_id" : "aa0477b4-bade-4152-a0b1-427d0f63bd03", + "attachment_start" : "2018-03-07T08:00:00.000Z" + }, + { + "deployment_id" : "8d0576cf-ed78-43f7-8a96-0e21ba827ad9", + "critter_id" : "dc03df78-3671-4fa2-a123-74f2631138ce", + "attachment_start" : "2018-01-10T08:00:00.000Z" + }, + { + "deployment_id" : "715af443-812e-483a-be5a-962f462333c1", + "critter_id" : "47af1d47-f7c8-4d39-b137-79e1db24cef9", + "attachment_start" : "2018-01-10T08:00:00.000Z" + }, + { + "deployment_id" : "1b8bec2d-f17e-4997-95fa-774aef074cec", + "critter_id" : "12dc55d9-f8da-4a4b-8597-d871dd68d430", + "attachment_start" : "2018-01-10T08:00:00.000Z" + }, + { + "deployment_id" : "7296390f-8b6f-4aaa-a41d-5d0bd2109d19", + "critter_id" : "aa1349c5-22a7-4305-b12d-8b02ed423836", + "attachment_start" : "2018-01-10T08:00:00.000Z" + }, + { + "deployment_id" : "74938cbe-ce71-4818-86f7-d0aedd860cf5", + "critter_id" : "c2474159-b9ae-4eba-be7a-5ab44cbd1f54", + "attachment_start" : "2018-01-10T08:00:00.000Z" + }, + { + "deployment_id" : "369f2999-05c8-43de-b68e-b309451fbcb5", + "critter_id" : "958fc223-2a4f-4d87-9fee-d1fff480ec85", + "attachment_start" : "2018-01-19T08:00:00.000Z" + }, + { + "deployment_id" : "dea95f91-e16c-4207-b765-c30f28fc3952", + "critter_id" : "5e60feeb-6f61-4827-973d-ca3c11dd83dd", + "attachment_start" : "2018-01-10T08:00:00.000Z" + }, + { + "deployment_id" : "8211c4e7-b76e-437a-bada-3e0b7266baae", + "critter_id" : "dda98af4-0311-47ad-a297-2f95c5246509", + "attachment_start" : "2018-03-07T08:00:00.000Z" + }, + { + "deployment_id" : "dce7b510-cb88-45db-870e-c6d8272deec8", + "critter_id" : "55c70975-2519-4e71-8514-98d9f934a640", + "attachment_start" : "2018-12-07T08:00:00.000Z" + }, + { + "deployment_id" : "da448c55-c2c3-40ae-a7ec-b25131532faa", + "critter_id" : "c65b5ac8-9eda-4403-aa78-d64dbe441b2b", + "attachment_start" : "2018-12-09T08:00:00.000Z" + }, + { + "deployment_id" : "54583775-b035-48f3-abc8-7e550bc87b54", + "critter_id" : "b653bbad-6409-401e-a978-be298688513d", + "attachment_start" : "2018-12-05T08:00:00.000Z" + }, + { + "deployment_id" : "d6277440-5aba-4e96-829d-c1efbdb5f137", + "critter_id" : "facdc1f7-bbed-4d5f-88ed-b9306317f1f0", + "attachment_start" : "2018-12-07T08:00:00.000Z" + }, + { + "deployment_id" : "58ee0dd4-f111-42d6-a409-cd2f592ff7f4", + "critter_id" : "197b59b1-ce05-497c-ae70-dcb3fd5eddc0", + "attachment_start" : "2018-12-07T08:00:00.000Z" + }, + { + "deployment_id" : "78fa8816-47e4-4510-8f0d-396f6162314e", + "critter_id" : "2655d11d-e6d8-4949-84cb-9e91f2b2072d", + "attachment_start" : "2018-12-06T08:00:00.000Z" + }, + { + "deployment_id" : "a6dd962b-d3d8-4152-b736-e94cb6494cac", + "critter_id" : "af0a1370-7f76-458d-8a40-dff3d26b166f", + "attachment_start" : "2018-12-08T08:00:00.000Z" + }, + { + "deployment_id" : "3b708ce0-449d-4952-9667-f004d4bde2e3", + "critter_id" : "754d6c8f-75c1-41a7-8b83-b77bed59e18b", + "attachment_start" : "2018-12-04T08:00:00.000Z" + }, + { + "deployment_id" : "cbd115ad-42b2-4640-89fe-1eac5694dfa8", + "critter_id" : "b0842162-a039-4cde-b793-d7538fbd6ef5", + "attachment_start" : "2018-12-07T08:00:00.000Z" + }, + { + "deployment_id" : "14f3a25d-296f-4280-a821-e42934f83914", + "critter_id" : "907f04d9-3f00-4d93-9aa8-b368199a32ad", + "attachment_start" : "2018-12-05T08:00:00.000Z" + }, + { + "deployment_id" : "3ee61e9c-f3b2-409d-9e46-1f70f09aeb97", + "critter_id" : "a76e8f68-1f17-4ff6-a322-fcffe4c25c58", + "attachment_start" : "2018-12-07T08:00:00.000Z" + }, + { + "deployment_id" : "9c527d0f-a519-4d51-8f0c-ca5620fce34a", + "critter_id" : "e3f5ae5f-ef63-4031-bf93-33fdfbc60c29", + "attachment_start" : "2018-12-07T08:00:00.000Z" + }, + { + "deployment_id" : "44c07e4b-aaec-4756-b7a0-3e2eb72e7012", + "critter_id" : "7f0cd0aa-873f-41e1-8fe9-dbd8494d34a0", + "attachment_start" : "2018-12-04T08:00:00.000Z" + }, + { + "deployment_id" : "555babc7-2f84-4207-a59a-95ee8cea6cf6", + "critter_id" : "0b08e0d2-290e-437b-83a7-adea7a0d2af4", + "attachment_start" : "2018-12-06T08:00:00.000Z" + }, + { + "deployment_id" : "906317e5-d934-4b20-8ca7-9a4569108fc6", + "critter_id" : "c32c599d-2546-4fba-a7b1-997df39e8943", + "attachment_start" : "2018-12-04T08:00:00.000Z" + }, + { + "deployment_id" : "4678cd05-0fe4-4fe9-a21e-71c6fd258162", + "critter_id" : "66d1e2ae-28c5-440f-9726-509af9c3bb78", + "attachment_start" : "2020-02-03T08:00:00.000Z" + }, + { + "deployment_id" : "bc829226-8d3c-4a5e-831f-00888e21b12d", + "critter_id" : "8565e7ab-7c72-4385-9622-2382c4007d78", + "attachment_start" : "2019-01-18T08:00:00.000Z" + }, + { + "deployment_id" : "707f495e-ad62-41ec-aea9-6f72ba8b2194", + "critter_id" : "7499e6e9-f382-4482-afd0-f60618fc2942", + "attachment_start" : "2019-01-17T08:00:00.000Z" + }, + { + "deployment_id" : "68e39368-2dbc-44b1-a7c7-9d40fdd632e2", + "critter_id" : "d88531d7-b1d8-4c4f-86b7-591363750dad", + "attachment_start" : "2019-01-18T08:00:00.000Z" + }, + { + "deployment_id" : "13c731bf-f11d-4275-ba81-9e074ae624aa", + "critter_id" : "5223d7a3-1df2-4fe1-9342-84a1c8456b0d", + "attachment_start" : "2019-01-16T08:00:00.000Z" + }, + { + "deployment_id" : "ba7a4a4d-3f4f-4691-be54-e39c61f763db", + "critter_id" : "4dd1119d-4766-4a83-8f0e-4b6d7c1b266b", + "attachment_start" : "2021-03-03T08:00:00.000Z" + }, + { + "deployment_id" : "3b12f9b5-026e-4709-8c2d-b8d8d110a779", + "critter_id" : "874766e4-849d-458c-be51-ee454c07a762", + "attachment_start" : "2018-02-27T08:00:00.000Z" + }, + { + "deployment_id" : "85db1bf1-a102-4c34-9b1a-00259943e8e8", + "critter_id" : "e6f7871c-4823-4e59-b947-dbcf83339159", + "attachment_start" : "2018-02-28T08:00:00.000Z" + }, + { + "deployment_id" : "c734c389-538f-407f-9e94-0cbac5fbbb56", + "critter_id" : "95ff946a-5d4f-4574-a8df-bee267ce66de", + "attachment_start" : "2018-02-27T08:00:00.000Z" + }, + { + "deployment_id" : "6a69a20e-61a6-4316-8b3b-b6f791431ef1", + "critter_id" : "b9c82866-5ca6-4475-bf76-5256b9f47b66", + "attachment_start" : "2018-02-28T08:00:00.000Z" + }, + { + "deployment_id" : "9d6ec1ce-61ab-4bde-89be-0f6f66ac9776", + "critter_id" : "220d9506-0b5a-4f42-a70e-1000dd11c882", + "attachment_start" : "2017-02-16T08:00:00.000Z" + }, + { + "deployment_id" : "a949be5e-e955-474e-b521-f1731f4e97e7", + "critter_id" : "80083d8d-338a-475b-a882-bf5950522b4e", + "attachment_start" : "2018-02-27T08:00:00.000Z" + }, + { + "deployment_id" : "dfdefa60-2fbf-431d-b957-c642992b57ad", + "critter_id" : "d247071f-2aea-495b-bc28-fd96322bc73e", + "attachment_start" : "2017-02-16T08:00:00.000Z" + }, + { + "deployment_id" : "734907ea-72d0-4bc6-b37f-7b86ad8f9bc5", + "critter_id" : "5a5161d9-1dbc-410b-9e92-6e0cb474671b", + "attachment_start" : "2016-02-16T08:00:00.000Z" + }, + { + "deployment_id" : "507ca233-f4e5-43cf-a626-fb9102cb5c3b", + "critter_id" : "aa3ac68e-1201-47d5-8b98-5356de345bfe", + "attachment_start" : "2016-02-16T08:00:00.000Z" + }, + { + "deployment_id" : "23391476-8c86-4e84-83db-f60b3fe002e3", + "critter_id" : "b2e3e471-7589-4980-b372-6708f107fc74", + "attachment_start" : "2014-10-23T07:00:00.000Z" + }, + { + "deployment_id" : "c27c9384-4357-4424-a645-a3cca7140773", + "critter_id" : "7a5a7314-869d-4746-a5a0-d114bdc20370", + "attachment_start" : "2015-01-20T08:00:00.000Z" + }, + { + "deployment_id" : "b1bc630d-78e6-4c22-a42e-730114fbe595", + "critter_id" : "15d57bca-7195-413f-93b7-b4c451671775", + "attachment_start" : "2015-01-26T08:00:00.000Z" + }, + { + "deployment_id" : "198ae102-cb70-468f-bae7-a0d2fa2f6e58", + "critter_id" : "9da0242b-255c-4278-99b3-d575ce89aac2", + "attachment_start" : "2015-01-21T08:00:00.000Z" + }, + { + "deployment_id" : "ac3aaa69-92e3-46a6-8dbf-a376c57c2ba0", + "critter_id" : "275ef6e8-c6d6-47e3-b39b-a54a2dc60306", + "attachment_start" : "2015-01-22T08:00:00.000Z" + }, + { + "deployment_id" : "40c547e5-f7e0-45f7-84b6-d443952943c5", + "critter_id" : "5c8c837e-6add-4269-839c-a57a761aad09", + "attachment_start" : "2015-01-22T08:00:00.000Z" + }, + { + "deployment_id" : "deb719ab-55c7-401f-b718-f672fc545887", + "critter_id" : "88e9b1fe-8bca-4e33-963f-fb302f6d7784", + "attachment_start" : "2018-01-10T08:00:00.000Z" + }, + { + "deployment_id" : "276d9289-d2cc-4a43-a57d-0239a332e0a6", + "critter_id" : "9888f894-955f-4141-a8b7-e1495e63671d", + "attachment_start" : "2018-02-27T08:00:00.000Z" + }, + { + "deployment_id" : "945ec3d8-7370-4434-a09a-8afb1ad3ba4d", + "critter_id" : "8aa5af08-335b-46bb-8fc1-9ad547823bc9", + "attachment_start" : "2018-02-26T08:00:00.000Z" + }, + { + "deployment_id" : "e5d23fca-09ab-4c4e-9e3f-ea810f7a59fb", + "critter_id" : "09bb1641-d545-4611-8a37-523fa1085d80", + "attachment_start" : "2017-02-16T08:00:00.000Z" + }, + { + "deployment_id" : "cf647d25-001a-4d6f-857b-05c502cbae40", + "critter_id" : "a106f358-99b8-4cf7-9eac-ab8a5c1b97a5", + "attachment_start" : "2017-02-14T08:00:00.000Z" + }, + { + "deployment_id" : "eb6444fc-74a6-4675-9f75-d8544f3f16ce", + "critter_id" : "59a0e1cf-953f-4cae-99cd-13538aeda68e", + "attachment_start" : "2018-02-28T08:00:00.000Z" + }, + { + "deployment_id" : "9827f8fe-0d4a-49c4-bae5-7d3f50ee61bd", + "critter_id" : "88be15fa-e771-44fc-9c63-c8198549f060", + "attachment_start" : "2018-03-25T07:00:00.000Z" + }, + { + "deployment_id" : "fa09fba5-f1c1-46c4-9d0e-43eb7660ea4d", + "critter_id" : "8191f457-e35c-4994-880e-fc706e6fa8d0", + "attachment_start" : "2020-02-15T08:00:00.000Z" + }, + { + "deployment_id" : "ee74944f-faed-42a2-bb06-9322c2f5d040", + "critter_id" : "9932ac36-ca75-40c3-b440-50649dd32b24", + "attachment_start" : "2020-02-14T08:00:00.000Z" + }, + { + "deployment_id" : "c55cb6d9-007e-426f-8cbc-1016e12d5651", + "critter_id" : "72040500-395c-4905-933b-1a8d4af191dd", + "attachment_start" : "2020-02-28T08:00:00.000Z" + }, + { + "deployment_id" : "ff2a7576-516a-4fe4-b8c7-8ad47115e857", + "critter_id" : "4b17d7ac-13bc-4e70-a105-0518ab405be3", + "attachment_start" : "2020-03-19T07:00:00.000Z" + }, + { + "deployment_id" : "c1b287b0-4430-420a-a3f2-faf301434ead", + "critter_id" : "a678a09d-1232-4d74-ba75-6e4154578456", + "attachment_start" : "2020-02-29T08:00:00.000Z" + }, + { + "deployment_id" : "3728ea58-b695-4d0f-bb2b-b4e256f4a4f6", + "critter_id" : "cd3e9c00-9505-4184-8a8d-c032ef37de48", + "attachment_start" : "2020-02-14T08:00:00.000Z" + }, + { + "deployment_id" : "5c849e38-5605-4682-9abb-1ebdd1cce3b1", + "critter_id" : "6727f312-5c38-4745-9131-58918b6516be", + "attachment_start" : "2020-03-07T08:00:00.000Z" + }, + { + "deployment_id" : "048df02b-b92a-4148-8893-41458a99549c", + "critter_id" : "019705da-9a7c-4956-b37b-57dceb80b35a", + "attachment_start" : "2020-03-07T08:00:00.000Z" + }, + { + "deployment_id" : "01abf20f-0354-4dad-a319-8d54035a28db", + "critter_id" : "d3af091d-db6b-4f45-916d-d1896309ceed", + "attachment_start" : "2023-02-02T08:00:00.000Z" + }, + { + "deployment_id" : "127b6e1f-d186-4c64-abdd-17d10138451b", + "critter_id" : "20d8d9b9-37bd-44be-9526-c625223174c2", + "attachment_start" : "2020-02-19T08:00:00.000Z" + }, + { + "deployment_id" : "82858962-68f4-451d-8e95-102154c9efcc", + "critter_id" : "4a108879-59c0-4167-b687-d2b8903fd2ad", + "attachment_start" : "2020-03-07T08:00:00.000Z" + }, + { + "deployment_id" : "1d54f8aa-f762-4654-a898-cf153fb24fb7", + "critter_id" : "631f354e-373d-4df1-a773-09f03c664b7f", + "attachment_start" : "2020-03-09T07:00:00.000Z" + }, + { + "deployment_id" : "480ca02f-5689-4eba-97c6-b98f7409848a", + "critter_id" : "3a3879a8-5d48-428a-af32-3b2ab39b180d", + "attachment_start" : "2021-02-27T08:00:00.000Z" + }, + { + "deployment_id" : "1355fb86-8690-40ea-822a-a7dd249442f8", + "critter_id" : "52c79db7-446f-48d5-83ad-38c3eacec751", + "attachment_start" : "2021-03-03T08:00:00.000Z" + }, + { + "deployment_id" : "fa9b243f-1745-4b30-9a8c-b352751790d8", + "critter_id" : "66047565-28aa-44e8-be1f-a5cb0ddeb7f5", + "attachment_start" : "2021-02-18T08:00:00.000Z" + }, + { + "deployment_id" : "89ea7d1f-de65-4a81-bb3c-1ef8f39e6030", + "critter_id" : "fbf49452-c2ac-4a6b-969e-23314488908a", + "attachment_start" : "2021-03-02T08:00:00.000Z" + }, + { + "deployment_id" : "d9c338e8-b369-4a5f-a23e-4b052b3f7eb2", + "critter_id" : "4f9a73a8-f7f4-4180-a688-054921ed3a4c", + "attachment_start" : "2021-03-02T08:00:00.000Z" + }, + { + "deployment_id" : "d7e4bedf-a80f-4835-ac63-9f93a0c898e3", + "critter_id" : "0e2ffdf7-9f31-459c-9d0e-527601599cba", + "attachment_start" : "2021-02-24T08:00:00.000Z" + }, + { + "deployment_id" : "9dedc650-5b66-4c70-9470-555e4e7ad3ad", + "critter_id" : "e33b00d5-41c3-4aab-8b26-9fef75501bc5", + "attachment_start" : "2021-03-03T08:00:00.000Z" + }, + { + "deployment_id" : "0bc7feb4-8073-43cb-b50d-47421ab6136d", + "critter_id" : "c76e6765-82ac-41b3-b883-1455dd76a462", + "attachment_start" : "2021-03-03T08:00:00.000Z" + }, + { + "deployment_id" : "b246f889-f103-467a-ae3a-9be65b8a3dff", + "critter_id" : "52cdafb8-d479-4fae-bfec-5bad7669d182", + "attachment_start" : "2021-02-18T08:00:00.000Z" + }, + { + "deployment_id" : "e2b8ba30-04a0-4f3e-a44a-971754bd7508", + "critter_id" : "99a027a5-1ae5-4135-a5f8-c849892684ba", + "attachment_start" : "2019-02-06T08:00:00.000Z" + }, + { + "deployment_id" : "61a5f1f7-dd65-4eb7-b09b-a88cc52af53a", + "critter_id" : "05b4a0d7-e31b-49d0-a34d-9a39d7556d00", + "attachment_start" : "2021-02-22T08:00:00.000Z" + }, + { + "deployment_id" : "07a0d1b8-31a2-4239-940f-66cfa427c50e", + "critter_id" : "4bd8fe08-f0e1-41fd-99b3-494fab00a763", + "attachment_start" : "2018-03-10T08:00:00.000Z" + }, + { + "deployment_id" : "380aedd4-bdfc-4ace-b385-70699a9669db", + "critter_id" : "0e634a79-de08-4db0-8a33-71dfdfdd9405", + "attachment_start" : "2018-03-12T07:00:00.000Z" + }, + { + "deployment_id" : "3a8c4bf7-d73f-45ae-acf2-74381c193bdc", + "critter_id" : "c20c4b75-e3b1-48eb-9924-5ae66f1e14ab", + "attachment_start" : "2018-03-13T07:00:00.000Z" + }, + { + "deployment_id" : "f31dff25-d919-4f0a-954a-6dc6afa691e5", + "critter_id" : "e77e14e8-1dac-475c-bdb1-2c6bdaf97578", + "attachment_start" : "2018-03-08T08:00:00.000Z" + }, + { + "deployment_id" : "e0b98d5f-fc9d-40ae-9447-c023fb54dd61", + "critter_id" : "d3f8323f-e441-4191-9274-093c805ffc64", + "attachment_start" : "2018-03-08T08:00:00.000Z" + }, + { + "deployment_id" : "b095c260-c788-40d3-8d46-9d6b21d1b39e", + "critter_id" : "ed30fd30-5011-4f0e-8df1-edae7c006554", + "attachment_start" : "2018-03-09T08:00:00.000Z" + }, + { + "deployment_id" : "4b91028d-d4b9-4bb8-89f4-47d8749556e8", + "critter_id" : "a134a301-ce7f-4c3e-a116-3aaae6f582aa", + "attachment_start" : "2018-03-11T08:00:00.000Z" + }, + { + "deployment_id" : "bf4be6c5-a9cc-4720-b821-5c2c919e6e87", + "critter_id" : "7a8bca4e-177e-46fb-81eb-31788a4466a1", + "attachment_start" : "2018-03-11T08:00:00.000Z" + }, + { + "deployment_id" : "39aa2c52-9eb3-442b-a1b4-8f7b577b8b9b", + "critter_id" : "c06e8b7e-c108-4862-b184-309fee888150", + "attachment_start" : "2018-03-11T08:00:00.000Z" + }, + { + "deployment_id" : "a76b50ce-6dbe-45f9-bebf-0b94e243a2fc", + "critter_id" : "d8d64480-818b-47da-bb16-684c8e18734d", + "attachment_start" : "2018-03-11T08:00:00.000Z" + }, + { + "deployment_id" : "cc1beb4a-b9fc-4200-a462-6b8d31040083", + "critter_id" : "7aecb1fc-7778-4768-b0cf-2e4ea4a3d9fd", + "attachment_start" : "2018-03-12T07:00:00.000Z" + }, + { + "deployment_id" : "8c25c998-3c7a-407b-8dfb-a3deed7ad87b", + "critter_id" : "aee7cdbd-f02b-49ca-8d08-5b306b82e979", + "attachment_start" : "2018-03-10T08:00:00.000Z" + }, + { + "deployment_id" : "46bcabde-94c6-4873-9308-3308d0f182bd", + "critter_id" : "c79f3b59-9c75-400e-82cb-637bb086300e", + "attachment_start" : "2018-03-12T07:00:00.000Z" + }, + { + "deployment_id" : "6e69d690-eda7-494b-875f-ca70c5b6343b", + "critter_id" : "f0dc2059-dc54-4ce2-8987-5f0e65bf807a", + "attachment_start" : "2018-03-13T07:00:00.000Z" + }, + { + "deployment_id" : "dd1ba879-fef2-4c17-b63b-94b49ea2dde0", + "critter_id" : "f754f6a6-c08f-4aee-8ef5-943dfe493fba", + "attachment_start" : "2018-03-09T08:00:00.000Z" + }, + { + "deployment_id" : "e711041d-589b-4702-ac28-533c841aed77", + "critter_id" : "a0585186-b93e-40f0-a9c0-c5392e3f8b00", + "attachment_start" : "2018-03-12T07:00:00.000Z" + }, + { + "deployment_id" : "af6e20e2-177f-4cfb-a4a6-21c68c5c0d54", + "critter_id" : "804ae9cf-7e8d-472e-92ae-cc13f2e0dfa0", + "attachment_start" : "2018-03-10T08:00:00.000Z" + }, + { + "deployment_id" : "2f4dc638-f680-4631-9527-452bbe20d1ab", + "critter_id" : "75c82af6-f274-493c-a8b3-9b3ad23a781a", + "attachment_start" : "2018-03-10T08:00:00.000Z" + }, + { + "deployment_id" : "238c0ff8-f17a-44e6-86ea-2afc4902077a", + "critter_id" : "fe4a231a-788f-4cb5-927d-736cee995be8", + "attachment_start" : "2018-03-13T07:00:00.000Z" + }, + { + "deployment_id" : "15aa20c5-6856-4483-abd4-037ef77657d0", + "critter_id" : "2cdef57a-098f-4ebc-873c-1eb75c8aebb4", + "attachment_start" : "2018-03-13T07:00:00.000Z" + }, + { + "deployment_id" : "7f6deeb3-2259-4ecd-ad12-d2e8189e9071", + "critter_id" : "fc619dea-7e6b-4f47-adda-fda830526afe", + "attachment_start" : "2018-03-11T08:00:00.000Z" + }, + { + "deployment_id" : "4b5bcaa4-368c-4ee2-ba74-34ea94c16ff1", + "critter_id" : "0455d6ff-b76b-45e6-8241-3dd53918cdea", + "attachment_start" : "2018-03-10T08:00:00.000Z" + }, + { + "deployment_id" : "810db397-d40a-4f1a-9662-4a2384f83cb0", + "critter_id" : "82fc1fba-c538-43f8-84d9-60b243b7df77", + "attachment_start" : "2018-03-09T08:00:00.000Z" + }, + { + "deployment_id" : "a80dd4e3-7283-4158-81e9-ee086f09b287", + "critter_id" : "bab2d15f-3d27-48a0-819d-2a37e18adc65", + "attachment_start" : "2018-03-12T07:00:00.000Z" + }, + { + "deployment_id" : "8299c33b-d280-4625-ac16-af5b910d87a9", + "critter_id" : "88802a51-e314-442d-8b82-842cc8cb5605", + "attachment_start" : "2018-03-09T08:00:00.000Z" + }, + { + "deployment_id" : "106ace9e-e860-4861-bf2f-4a25e0ae19ba", + "critter_id" : "d4a2bd73-72e8-4738-9f40-4bc00472d8e6", + "attachment_start" : "2018-03-09T08:00:00.000Z" + }, + { + "deployment_id" : "4f9a4e5f-8a48-417e-8acc-bec6db9802fe", + "critter_id" : "6263f59e-726b-4273-92fe-08dbbf1c7638", + "attachment_start" : "2018-03-11T08:00:00.000Z" + }, + { + "deployment_id" : "de8330f5-05e6-4d73-a238-859f4726816f", + "critter_id" : "b64a3a66-3fa5-4af9-a31f-095cd5e57404", + "attachment_start" : "2018-03-10T08:00:00.000Z" + }, + { + "deployment_id" : "6040eb39-7bcc-40bb-9495-e296feefefdc", + "critter_id" : "56075f99-07f0-4ddf-ac82-64a342a7d5df", + "attachment_start" : "2018-03-12T07:00:00.000Z" + }, + { + "deployment_id" : "8d755b81-bfc5-45f1-81d9-920f322e9b7d", + "critter_id" : "29f82991-712a-424e-a37c-852d6a2a1e9b", + "attachment_start" : "2018-03-13T07:00:00.000Z" + }, + { + "deployment_id" : "8c963626-e278-4fae-ac49-2b5a546afc4c", + "critter_id" : "0006cd82-3da7-4674-9394-1d68d69716e7", + "attachment_start" : "2018-03-09T08:00:00.000Z" + }, + { + "deployment_id" : "7d27623f-92d5-40ee-adcd-882255de287e", + "critter_id" : "0ec87511-70e5-487c-ad0a-137d674cec3a", + "attachment_start" : "2018-03-11T08:00:00.000Z" + }, + { + "deployment_id" : "4c841ef4-62de-4af3-9a4e-d1053dcbd3c3", + "critter_id" : "0cd68d8c-cb02-42e9-8c38-3221eacd0996", + "attachment_start" : "2018-03-13T07:00:00.000Z" + }, + { + "deployment_id" : "ada85d30-f38d-41b4-b0df-4542c1be06c1", + "critter_id" : "f35b011b-d002-49f7-9628-79980df4e5b3", + "attachment_start" : "2018-03-12T07:00:00.000Z" + }, + { + "deployment_id" : "2f5add42-0a8f-4181-b30e-a31c9f7f340a", + "critter_id" : "9c7ec486-cb21-4cce-962c-6595c46741f4", + "attachment_start" : "2018-03-09T08:00:00.000Z" + }, + { + "deployment_id" : "ae782719-741f-4e57-95a2-6f589856ed51", + "critter_id" : "7c674fae-74ff-4116-a2a1-37d6dad9d2c4", + "attachment_start" : "2018-03-09T08:00:00.000Z" + }, + { + "deployment_id" : "55b7d5ba-26eb-4dbf-ae8e-4d9eea16b606", + "critter_id" : "30069510-1352-44dd-8f5e-aedb1cdaa88c", + "attachment_start" : "2018-03-10T08:00:00.000Z" + }, + { + "deployment_id" : "7ffcda79-d334-4a9c-87b0-4520ebab4516", + "critter_id" : "9342b7b1-2b68-4b40-aded-4a0fad51964d", + "attachment_start" : "2018-03-12T07:00:00.000Z" + }, + { + "deployment_id" : "e1fa1721-9c07-48c8-b2e7-85ef7204622b", + "critter_id" : "0907e89c-0b3b-4889-a1d4-428cb9c37a51", + "attachment_start" : "2018-03-10T08:00:00.000Z" + }, + { + "deployment_id" : "fe95bbdf-0ca3-4e15-a599-918f99a76393", + "critter_id" : "ee00b66c-f058-4a99-a297-840544599085", + "attachment_start" : "2018-03-10T08:00:00.000Z" + }, + { + "deployment_id" : "3adbcd4a-8c4e-4016-acf2-50a796d9ef39", + "critter_id" : "37e85a05-3606-46dc-baf5-95770c1ad48e", + "attachment_start" : "2018-03-08T08:00:00.000Z" + }, + { + "deployment_id" : "382beab5-3769-46e3-9c66-9f887a48f327", + "critter_id" : "ff8c80d3-1626-49b7-9b85-3758289f8de2", + "attachment_start" : "2018-03-11T08:00:00.000Z" + }, + { + "deployment_id" : "aca444b0-3b2c-4d79-be70-449a5f6119f8", + "critter_id" : "e3a61332-9d1a-48fe-ae13-d31b3bb7e752", + "attachment_start" : "2018-03-12T07:00:00.000Z" + }, + { + "deployment_id" : "d42f84d7-278e-4b5c-988d-b422225741d7", + "critter_id" : "f0504cb6-52ba-4659-9703-853a399e7c1c", + "attachment_start" : "2018-03-10T08:00:00.000Z" + }, + { + "deployment_id" : "9c8ec58a-a3c7-41a5-903c-19fcda7f95b5", + "critter_id" : "94bf52b1-b4af-4586-9677-25818da9a037", + "attachment_start" : "2018-03-10T08:00:00.000Z" + }, + { + "deployment_id" : "052149d8-103f-4f86-a4a4-d57ba6148163", + "critter_id" : "a4c2455e-b20e-49c8-88ce-d51f6784f0df", + "attachment_start" : "2018-03-12T07:00:00.000Z" + }, + { + "deployment_id" : "9fa6d421-bd5c-4755-b91b-6e64f55046fd", + "critter_id" : "149998a1-5094-49ce-b0ea-ca49ab692021", + "attachment_start" : "2018-03-09T08:00:00.000Z" + }, + { + "deployment_id" : "3d03c612-8a6c-47e2-a205-1275098581ea", + "critter_id" : "6683aae2-fe68-45c3-8151-b0ed485ea2d8", + "attachment_start" : "2018-03-10T08:00:00.000Z" + }, + { + "deployment_id" : "580a4762-27d0-4039-989b-acf4d20b0302", + "critter_id" : "992a6251-6b3b-49d3-966b-2e452fa5ecb2", + "attachment_start" : "2019-02-05T08:00:00.000Z" + }, + { + "deployment_id" : "bff8e80b-abae-4c19-bc0d-68841eb80637", + "critter_id" : "d03604df-f188-4139-a23a-839bc4f29b4f", + "attachment_start" : "2019-02-14T08:00:00.000Z" + }, + { + "deployment_id" : "7b151e67-09ad-4226-9025-db38778b5a09", + "critter_id" : "13dec3e6-3149-4be8-9e9f-255c04638f48", + "attachment_start" : "2019-02-04T08:00:00.000Z" + }, + { + "deployment_id" : "3b0889f0-e7ac-4780-9e90-ab61cd0bb1da", + "critter_id" : "93abad3b-abd6-47e2-aa27-48b5c578e6d3", + "attachment_start" : "2019-02-04T08:00:00.000Z" + }, + { + "deployment_id" : "cac8710f-2322-41b8-8ec0-31521b05bf31", + "critter_id" : "aed49a38-5a94-4111-a63f-e29915bbf00a", + "attachment_start" : "2019-02-10T08:00:00.000Z" + }, + { + "deployment_id" : "7f2e6165-81db-40a5-b660-f0b4e90d88da", + "critter_id" : "ace1d379-436f-4929-b039-fc2b64747187", + "attachment_start" : "2019-02-05T08:00:00.000Z" + }, + { + "deployment_id" : "b47c8262-2e5e-4d1e-90bc-305f912587ab", + "critter_id" : "e9fe538e-926b-41e6-b72b-7c97500887e0", + "attachment_start" : "2019-02-06T08:00:00.000Z" + }, + { + "deployment_id" : "1646153c-48b7-4669-baa0-4d7c748e5385", + "critter_id" : "681f16b5-6b02-4bda-ae65-3d55e0a6ee20", + "attachment_start" : "2019-02-04T08:00:00.000Z" + }, + { + "deployment_id" : "513f0d00-5af0-45a1-b27e-089d2563aa32", + "critter_id" : "6437ba57-0f1d-437d-8442-3a579e26eff3", + "attachment_start" : "2019-02-14T08:00:00.000Z" + }, + { + "deployment_id" : "3d277715-5a4a-4fc4-bbf8-abc633d51048", + "critter_id" : "5715e178-ee94-4586-93a1-df3e3a00e477", + "attachment_start" : "2019-02-05T08:00:00.000Z" + }, + { + "deployment_id" : "7c702752-e5a6-4257-be2b-bdbcea35146d", + "critter_id" : "ead00f5e-ab6b-40d2-868a-768a6d7bb11b", + "attachment_start" : "2019-02-11T08:00:00.000Z" + }, + { + "deployment_id" : "3145bb6f-da90-44b7-b498-84fa859cbea8", + "critter_id" : "e535b48a-2773-4eec-b7ef-7746a6f67f92", + "attachment_start" : "2019-02-15T08:00:00.000Z" + }, + { + "deployment_id" : "d613043e-3220-4904-9aa3-9bbb6a2107ca", + "critter_id" : "6ff3eb3d-e756-4c3a-a0f9-32079013b5b3", + "attachment_start" : "2019-02-08T08:00:00.000Z" + }, + { + "deployment_id" : "0e498468-b3e2-45c6-bec7-a6cb5e4c6d06", + "critter_id" : "c9ca97ad-bd90-4fc6-ada9-136468b9709b", + "attachment_start" : "2019-02-05T08:00:00.000Z" + }, + { + "deployment_id" : "b3439286-a1d8-486e-aec8-835eec8ad722", + "critter_id" : "d6c563bf-e721-45ee-a69c-b7610e502e48", + "attachment_start" : "2021-02-16T08:00:00.000Z" + }, + { + "deployment_id" : "38210ecf-41eb-466d-a3ed-6f0c492ee2fb", + "critter_id" : "5526eae6-2203-40dc-b458-fa99f7e3c670", + "attachment_start" : "2019-02-05T08:00:00.000Z" + }, + { + "deployment_id" : "69a80bda-94c3-4977-bd10-92b627fa34ea", + "critter_id" : "beec64dc-cee8-4d4d-b693-60b26e219a2d", + "attachment_start" : "2020-02-12T08:00:00.000Z" + }, + { + "deployment_id" : "cd2c2be1-77e6-461d-9961-5d1c53ceccc2", + "critter_id" : "8dd02948-5726-4c43-918d-b0ef0c3dd7ca", + "attachment_start" : "2020-02-13T08:00:00.000Z" + }, + { + "deployment_id" : "9cd1654d-97f1-4fae-8758-93fe11cd5c06", + "critter_id" : "2a16b0ba-0eed-4507-b678-65041236e78b", + "attachment_start" : "2020-02-12T08:00:00.000Z" + }, + { + "deployment_id" : "719c742e-a7e7-4561-b6b1-b7d57a60218e", + "critter_id" : "01298527-24e8-4bea-a3fa-088edc723737", + "attachment_start" : "2020-02-12T08:00:00.000Z" + }, + { + "deployment_id" : "9a651e57-02a8-49e6-9835-c28f376309a4", + "critter_id" : "ce31b26a-243b-4378-91c5-1f0733441162", + "attachment_start" : "2020-02-07T08:00:00.000Z" + }, + { + "deployment_id" : "fa7a8032-ae2d-4f57-9d29-7b446b796828", + "critter_id" : "b66e9153-de22-4b07-b9d8-a7315ab73e0c", + "attachment_start" : "2020-02-15T08:00:00.000Z" + }, + { + "deployment_id" : "3067ce57-f288-47d7-9a2e-3671dbfe16e3", + "critter_id" : "b0c322f0-d2e7-4751-9d55-2ef38549161f", + "attachment_start" : "2020-02-14T08:00:00.000Z" + }, + { + "deployment_id" : "712e0d93-b6f5-40b6-b46d-937f2712ee4f", + "critter_id" : "60a8d392-02b6-4f8a-b36b-e25ee9516f83", + "attachment_start" : "2020-02-14T08:00:00.000Z" + }, + { + "deployment_id" : "ef2d208a-7060-48a9-a2bc-0fd51cfc4373", + "critter_id" : "5e967921-1968-44f1-a3fd-5d95adf5b6fa", + "attachment_start" : "2020-02-12T08:00:00.000Z" + }, + { + "deployment_id" : "f846378a-2588-49ef-ba44-9e37aca8a439", + "critter_id" : "e481c7fa-5e40-4331-8d7c-10cb7e821fac", + "attachment_start" : "2020-02-12T08:00:00.000Z" + }, + { + "deployment_id" : "ed2a809c-431f-4640-90e8-14fcf898266e", + "critter_id" : "408c17e1-a8e6-4e14-aca8-f5db064a94ce", + "attachment_start" : "2020-02-07T08:00:00.000Z" + }, + { + "deployment_id" : "0e023dc2-186a-4a6e-a291-1b65564ae706", + "critter_id" : "59fecfb0-0a0d-4400-b596-546865285bcc", + "attachment_start" : "2020-02-07T08:00:00.000Z" + }, + { + "deployment_id" : "43d47765-17fe-4bf6-bf85-54a26ec19bc2", + "critter_id" : "4bc23d12-da38-4ff0-bd0c-3ae3d6a9d7b8", + "attachment_start" : "2020-02-12T08:00:00.000Z" + }, + { + "deployment_id" : "100ec15e-6bef-4097-958d-b8607cb19a60", + "critter_id" : "ae876144-e288-4dc4-80b8-a3e25d84caf4", + "attachment_start" : "2021-02-15T08:00:00.000Z" + }, + { + "deployment_id" : "a6939ca3-bf86-4f4c-ba79-1a190e843c13", + "critter_id" : "5f713db2-0cfb-4719-aa58-dfb3cb931deb", + "attachment_start" : "2021-02-16T08:00:00.000Z" + }, + { + "deployment_id" : "06e9ab0b-cd0c-4d90-a969-66f145fe6a1f", + "critter_id" : "ba9e6a8b-5b58-44b5-b6d3-0b5fb0029f90", + "attachment_start" : "2021-02-16T08:00:00.000Z" + }, + { + "deployment_id" : "c4deec85-db3e-47c4-9730-05a02421b5ff", + "critter_id" : "335c56e1-8529-4698-a615-03e3d9fb3e2e", + "attachment_start" : "2021-02-15T08:00:00.000Z" + }, + { + "deployment_id" : "52110db0-b33a-474d-b439-40adbb111ba0", + "critter_id" : "34cc2ebd-68a5-419e-93f6-0136cc37448e", + "attachment_start" : "2021-02-09T08:00:00.000Z" + }, + { + "deployment_id" : "8ffe9779-ac2d-4031-9c0c-81bb68456123", + "critter_id" : "cfaf7ebf-dadc-49b5-a636-ec76e5036c8d", + "attachment_start" : "2021-02-14T08:00:00.000Z" + }, + { + "deployment_id" : "4d499fc6-b3e2-4cf2-b8ed-9a0abb1eb134", + "critter_id" : "03687fb2-d1d1-4733-97b3-46bc10a977e7", + "attachment_start" : "2021-02-15T08:00:00.000Z" + }, + { + "deployment_id" : "72b6f5c9-3db3-4bbb-af37-ed0f8413eb7d", + "critter_id" : "7b4a1a82-d189-44f8-9ba1-10148da2d017", + "attachment_start" : "2021-02-15T08:00:00.000Z" + }, + { + "deployment_id" : "894211be-8155-410e-ad70-b5b370bb6f0d", + "critter_id" : "e10ae35d-33c2-40eb-bee8-104064dbab90", + "attachment_start" : "2021-02-14T08:00:00.000Z" + }, + { + "deployment_id" : "40a7491f-9f0e-4765-9739-6f832f7d6bda", + "critter_id" : "7af8316f-5a73-4c9c-9a12-6ef16518af1e", + "attachment_start" : "2021-02-15T08:00:00.000Z" + }, + { + "deployment_id" : "eb3a9a9f-4130-4d16-b7df-b18bf596dd6f", + "critter_id" : "80850508-355e-4e04-9cbf-cde5fd5173b2", + "attachment_start" : "2021-02-17T08:00:00.000Z" + }, + { + "deployment_id" : "0c16eb72-dd06-4130-92a4-233a7079728b", + "critter_id" : "742cdb5d-4ca9-4d03-8cc8-7607ecbee817", + "attachment_start" : "2021-02-18T08:00:00.000Z" + }, + { + "deployment_id" : "66974354-b93f-40b6-9249-6ed0eaa19cef", + "critter_id" : "d621e5af-18eb-4f7b-8760-3ca7f658f50f", + "attachment_start" : "2021-02-18T08:00:00.000Z" + }, + { + "deployment_id" : "9743ba62-7049-48f7-a574-951400a9fed0", + "critter_id" : "a99d91f8-bd29-4ba2-900e-ec9d68cfe077", + "attachment_start" : "2021-02-16T08:00:00.000Z" + }, + { + "deployment_id" : "06e3783e-7407-40a1-8792-a4d1e4e3a0e7", + "critter_id" : "f91ae2d3-4f5b-4502-aceb-264351709695", + "attachment_start" : "2021-02-11T08:00:00.000Z" + }, + { + "deployment_id" : "2f5216e0-1193-4061-87b9-184e919a930c", + "critter_id" : "76ec3616-afce-486e-b691-b2ce811bbdc7", + "attachment_start" : "2021-02-16T08:00:00.000Z" + }, + { + "deployment_id" : "c53494d8-dd3e-4ad9-9f6a-16a32d629293", + "critter_id" : "c96dc72c-1df9-4763-9abb-bdb1b5b3d3ab", + "attachment_start" : "2021-02-18T08:00:00.000Z" + }, + { + "deployment_id" : "98dbcd0c-a67e-48e7-9ab7-be4139eac1fd", + "critter_id" : "5de630ee-f18f-4ce9-888c-355c374d8c79", + "attachment_start" : "2021-02-16T08:00:00.000Z" + }, + { + "deployment_id" : "d54b381f-8de2-420c-a190-f225ee439cf0", + "critter_id" : "e801a45f-c205-4c1f-890c-c6112c5e963b", + "attachment_start" : "2021-02-16T08:00:00.000Z" + }, + { + "deployment_id" : "2b876ff0-b908-469a-90ac-fbc0404576d3", + "critter_id" : "b6fdad24-f48a-4005-9cc7-77fed39703b3", + "attachment_start" : "2021-02-14T08:00:00.000Z" + }, + { + "deployment_id" : "6b06ba79-1713-4f21-86a8-217a7e93680d", + "critter_id" : "db6fb2ac-599a-472d-9b32-b6d5f4e5a77f", + "attachment_start" : "2021-02-16T08:00:00.000Z" + }, + { + "deployment_id" : "4e98b42f-2aff-425e-ae09-67638742db67", + "critter_id" : "ee606e36-0f3f-42c3-871e-8cb61f3904b3", + "attachment_start" : "2021-02-15T08:00:00.000Z" + }, + { + "deployment_id" : "3e034c57-9d8c-475f-af0d-1f50f5d7dc70", + "critter_id" : "609603ab-f339-43ef-9cd9-07e036fd1928", + "attachment_start" : "2021-02-14T08:00:00.000Z" + }, + { + "deployment_id" : "045aa0dd-a522-4c45-9824-80cd4901fa9b", + "critter_id" : "4acfc1ce-eae8-475a-b912-a7049c48a8a1", + "attachment_start" : "2021-02-11T08:00:00.000Z" + }, + { + "deployment_id" : "6ddd3f32-7dea-4213-8477-f68f40ee22b1", + "critter_id" : "84834030-de9d-4639-9b99-9e62e0f9e451", + "attachment_start" : "2021-02-15T08:00:00.000Z" + }, + { + "deployment_id" : "5598217c-8916-4ad6-beac-8cbae8816f06", + "critter_id" : "892e7a45-47f0-486b-a8f4-b2d716e6c983", + "attachment_start" : "2021-02-14T08:00:00.000Z" + }, + { + "deployment_id" : "0966e95f-2dae-4e5f-a216-5dc2d6189d33", + "critter_id" : "b21e2841-1502-4ca4-82bf-524b29d1122e", + "attachment_start" : "2021-02-17T08:00:00.000Z" + }, + { + "deployment_id" : "4488a9dc-d181-4d2d-aec9-06c2bf11db2c", + "critter_id" : "1f827749-2532-4c73-85a7-e5e44dc37884", + "attachment_start" : "2023-03-03T08:00:00.000Z" + }, + { + "deployment_id" : "2f78b782-a4d3-4e47-957f-5a8a97584524", + "critter_id" : "fb046555-729f-4992-a594-eebb4345bf46", + "attachment_start" : "2021-02-15T08:00:00.000Z" + }, + { + "deployment_id" : "f72971d9-3312-4321-99f6-285ca5a9daad", + "critter_id" : "e1a66520-9fdc-41c8-ad04-8ee5ade25513", + "attachment_start" : "2021-02-14T08:00:00.000Z" + }, + { + "deployment_id" : "3c0c9f7b-b8a2-4fd6-87a9-8502c920df61", + "critter_id" : "4c646192-1338-42fd-95f6-32e1cd5e6316", + "attachment_start" : "2021-02-14T08:00:00.000Z" + }, + { + "deployment_id" : "54fe7542-d12e-42c7-b82e-d8f9e4edfebd", + "critter_id" : "5dfe92df-4f61-4b99-b2d4-5780b889f7f1", + "attachment_start" : "2021-02-14T08:00:00.000Z" + }, + { + "deployment_id" : "25f82c40-27dd-4004-b19c-70a033ec2a81", + "critter_id" : "d8545861-3c66-4788-9145-365e4f6b4731", + "attachment_start" : "2021-02-14T08:00:00.000Z" + }, + { + "deployment_id" : "8d636201-a6de-4638-8109-3e9ee1d50264", + "critter_id" : "f0419f13-f2f0-462f-9805-f207eb39394d", + "attachment_start" : "2021-02-09T08:00:00.000Z" + }, + { + "deployment_id" : "a723bb8b-3e93-45df-b4be-e209a976a7f0", + "critter_id" : "88997438-8477-4265-a4dc-0bb16ab16018", + "attachment_start" : "2020-02-13T08:00:00.000Z" + }, + { + "deployment_id" : "0f6e2948-705b-463d-9f89-6f2f8452164d", + "critter_id" : "e7d771cc-7365-48f5-9dd0-629c1a5a03e1", + "attachment_start" : "2021-02-13T08:00:00.000Z" + }, + { + "deployment_id" : "7bbadf1d-7de4-431e-8620-74740dd43c10", + "critter_id" : "cadeaf04-d21c-47d2-b3bb-2aac6d88aa4e", + "attachment_start" : "2021-01-22T08:00:00.000Z" + }, + { + "deployment_id" : "8eab21df-e883-4457-840b-c5dd22cc5123", + "critter_id" : "b682305a-c264-493b-b536-4ee273e799eb", + "attachment_start" : "2021-02-23T08:00:00.000Z" + }, + { + "deployment_id" : "04675089-20d0-494f-ae2c-cc453fead9f5", + "critter_id" : "f34b6176-d384-4ebd-8464-e9671fb6d729", + "attachment_start" : "2020-02-03T08:00:00.000Z" + }, + { + "deployment_id" : "f627121a-c294-476c-9615-7f77b7e73096", + "critter_id" : "060d8894-a782-4cd2-b11c-de59f4ec1105", + "attachment_start" : "2018-12-08T08:00:00.000Z" + }, + { + "deployment_id" : "c4570e42-1a60-484e-92da-6eb9ceb8d90c", + "critter_id" : "4f976386-0845-44d1-8443-492c6e504afd", + "attachment_start" : "2018-12-07T08:00:00.000Z" + }, + { + "deployment_id" : "97655d98-f67b-433b-86ed-609f9526cd92", + "critter_id" : "8896eed7-17b8-461b-aa6c-e85a18da773a", + "attachment_start" : "2018-12-07T08:00:00.000Z" + }, + { + "deployment_id" : "e78319af-5fb7-4c62-aa0f-68322a01e850", + "critter_id" : "27834dee-9029-4c32-9638-aacc4f877bd1", + "attachment_start" : "2018-12-05T08:00:00.000Z" + }, + { + "deployment_id" : "efca1801-07c6-4ee6-b39b-aa34e2201260", + "critter_id" : "3a4f0256-2ac8-47f9-8f24-490e5f8c6fa4", + "attachment_start" : "2018-12-06T08:00:00.000Z" + }, + { + "deployment_id" : "07510237-0b8b-482f-828b-426c2e5ccd16", + "critter_id" : "32cf6897-341c-4aee-a613-5959892d0e07", + "attachment_start" : "2018-12-04T08:00:00.000Z" + }, + { + "deployment_id" : "2c189dc3-1450-4c16-a684-99e1583d733c", + "critter_id" : "f4dcb829-5978-4846-9879-1399e0626944", + "attachment_start" : "2018-12-08T08:00:00.000Z" + }, + { + "deployment_id" : "6e9c3594-a6db-465e-8049-0de0dbf34786", + "critter_id" : "a5cdf199-eb8d-4c24-b9d9-888d374e44c9", + "attachment_start" : "2018-12-08T08:00:00.000Z" + }, + { + "deployment_id" : "884b27c1-a5a3-430e-8d87-8e654b6d977e", + "critter_id" : "b6bbf2a2-6615-4f15-8751-cd6c73fc4648", + "attachment_start" : "2018-12-05T08:00:00.000Z" + }, + { + "deployment_id" : "980002d1-a115-48e8-93eb-5745b621902e", + "critter_id" : "f276967e-c8e9-408b-86e0-1a117f68aede", + "attachment_start" : "2018-12-08T08:00:00.000Z" + }, + { + "deployment_id" : "e117f3cc-78a2-4bef-b3df-a54976217ca4", + "critter_id" : "dbe83cb6-7858-4805-9f3b-71c1dfdc1708", + "attachment_start" : "2018-12-08T08:00:00.000Z" + }, + { + "deployment_id" : "4bb1ef39-fd73-4b11-b30b-0cf52d91ffb7", + "critter_id" : "41fa17e5-77c8-4dee-a3b1-ae22dcbc34d8", + "attachment_start" : "2018-12-06T08:00:00.000Z" + }, + { + "deployment_id" : "b3c8e510-59de-4a52-a53b-a57702a50b84", + "critter_id" : "baaca254-a37d-45eb-aa47-fc5eabb5bc07", + "attachment_start" : "2018-12-06T08:00:00.000Z" + }, + { + "deployment_id" : "f736d1ec-cfba-4924-8cf6-fc884221d000", + "critter_id" : "e4bdc227-44d6-4bfe-a568-a6b8aa648388", + "attachment_start" : "2018-12-08T08:00:00.000Z" + }, + { + "deployment_id" : "3dbb7960-39fa-41c8-8773-71a1941c2013", + "critter_id" : "97b44d11-425c-4690-b129-7b8b7e07c39a", + "attachment_start" : "2018-12-07T08:00:00.000Z" + }, + { + "deployment_id" : "bb5a9443-15b9-4943-b83d-6516fa95da43", + "critter_id" : "890ba532-cbfa-4c82-a455-81b99d60fb63", + "attachment_start" : "2018-12-05T08:00:00.000Z" + }, + { + "deployment_id" : "603859f8-9881-46b0-82d4-f504db50c1c8", + "critter_id" : "ca6428c0-113e-441e-a63a-fc11175319bd", + "attachment_start" : "2018-12-06T08:00:00.000Z" + }, + { + "deployment_id" : "87dc12df-f66d-4806-a30e-447c9e93df07", + "critter_id" : "5b8a925f-8c11-486b-bbc5-420dde03481f", + "attachment_start" : "2018-12-08T08:00:00.000Z" + }, + { + "deployment_id" : "907f6b30-6630-4988-944f-9605e2f6affa", + "critter_id" : "4c679afd-9ac2-4bfb-9acd-8ca393041d8d", + "attachment_start" : "2018-12-06T08:00:00.000Z" + }, + { + "deployment_id" : "05bc44d9-b697-46ea-9f71-08131d6f5c64", + "critter_id" : "5edca49f-1aa9-4a01-8b79-9ae2f22df51d", + "attachment_start" : "2021-02-19T08:00:00.000Z" + }, + { + "deployment_id" : "7af059c2-9e60-4586-9af1-543f239af1a5", + "critter_id" : "5ffdb42b-68a8-42aa-960c-26bd6cf19a28", + "attachment_start" : "2018-01-10T08:00:00.000Z" + }, + { + "deployment_id" : "2830902c-1c07-461b-ad39-ec2dfb2f26b1", + "critter_id" : "c2eb7a88-e101-479a-b19d-b54ae6a18f78", + "attachment_start" : "2018-01-10T08:00:00.000Z" + }, + { + "deployment_id" : "7ab0c85e-9d42-4dfb-adeb-7d78383f55a4", + "critter_id" : "618fed82-9101-4cd3-bbb7-42a72775eab8", + "attachment_start" : "2018-01-10T08:00:00.000Z" + }, + { + "deployment_id" : "b0313b13-f92b-42c8-aeee-6303d5cc9816", + "critter_id" : "3b7d5074-563f-48dc-b782-a24ddfbeff2a", + "attachment_start" : "2015-02-03T08:00:00.000Z" + }, + { + "deployment_id" : "7e5960ea-1689-4127-804a-87a3e1b13c56", + "critter_id" : "33692524-fb73-4ca6-854e-72d567ada238", + "attachment_start" : "2015-02-02T08:00:00.000Z" + }, + { + "deployment_id" : "60af1e2c-72b8-4586-ac5e-c6c4577ec811", + "critter_id" : "0a5626bb-0702-48f6-a166-9d6d1cdd1e56", + "attachment_start" : "2015-04-03T07:00:00.000Z" + }, + { + "deployment_id" : "4a5766a4-2eb4-4011-a200-09557b30c69c", + "critter_id" : "40785c89-607b-45bb-8680-3d9592495d9a", + "attachment_start" : "2015-02-03T08:00:00.000Z" + }, + { + "deployment_id" : "3225b302-333b-4f8a-8c53-8993eb993abd", + "critter_id" : "80bc2d58-346c-4d4e-977c-37b76bf0658f", + "attachment_start" : "2015-04-03T07:00:00.000Z" + }, + { + "deployment_id" : "700bc428-1d1d-4caf-b1a9-07da6afaf22f", + "critter_id" : "836c936c-4902-4601-b46e-ca55fffb0dcd", + "attachment_start" : "2015-02-28T08:00:00.000Z" + }, + { + "deployment_id" : "773ffaa5-cc06-479e-89c3-a994b394dc22", + "critter_id" : "6a99ee25-4d03-438a-85e2-365a47b6b40d", + "attachment_start" : "2015-04-03T07:00:00.000Z" + }, + { + "deployment_id" : "874662ca-eba7-4fcd-91e9-f1c8452eb52a", + "critter_id" : "ecc4b1c0-212a-42ac-9c01-3a1769e321e9", + "attachment_start" : "2015-04-03T07:00:00.000Z" + }, + { + "deployment_id" : "dad3833a-9517-416a-81cc-0a57c02dece1", + "critter_id" : "958b3b3a-fe56-4d5f-a764-57fcf2e90fca", + "attachment_start" : "2015-04-03T07:00:00.000Z" + }, + { + "deployment_id" : "b487111d-b422-4578-ada6-ad0c4dd211c9", + "critter_id" : "f71b8c93-8cf3-4111-affa-6832421b1ab8", + "attachment_start" : "2021-02-23T08:00:00.000Z" + }, + { + "deployment_id" : "c12791a5-aef3-43ff-80cd-3e70dc173ce6", + "critter_id" : "21116953-e550-4b7b-8b48-9ac968884c7f", + "attachment_start" : "2021-02-18T08:00:00.000Z" + }, + { + "deployment_id" : "fba0d593-26ab-4c72-b04d-6b38ff4f1b93", + "critter_id" : "913229f9-c79e-43d2-903c-64552632093d", + "attachment_start" : "2021-02-23T08:00:00.000Z" + }, + { + "deployment_id" : "d4475fbd-0420-4ffd-9d5c-f0a46fb498d6", + "critter_id" : "660b0be7-0cf5-4e6e-9fcd-44e9e133f9e1", + "attachment_start" : "2021-02-19T08:00:00.000Z" + }, + { + "deployment_id" : "83ef3d6a-9e9a-4d58-aec0-74cbe94b9f59", + "critter_id" : "cca2f210-1950-4837-b46d-b2a55c20ec4d", + "attachment_start" : "2021-02-18T08:00:00.000Z" + }, + { + "deployment_id" : "7e7720f3-9eab-47f7-9074-2571d9430245", + "critter_id" : "196aa083-13a9-4485-80be-4cac2e93a2c0", + "attachment_start" : "2021-02-18T08:00:00.000Z" + }, + { + "deployment_id" : "c9a5639d-e8ee-4a38-874a-2e9b34ca49bf", + "critter_id" : "d55fa25a-46d8-4284-9091-3ec311d9cd11", + "attachment_start" : "2021-02-18T08:00:00.000Z" + }, + { + "deployment_id" : "b8669250-7d72-4501-964d-b95b11d78efd", + "critter_id" : "552781f3-2628-492a-86f0-3e881b406356", + "attachment_start" : "2021-01-20T08:00:00.000Z" + }, + { + "deployment_id" : "ec41c900-cbd2-472e-9964-cd77011a5009", + "critter_id" : "b62c9c7b-1986-4968-a5aa-85e8d0b9e7fb", + "attachment_start" : "2021-01-20T08:00:00.000Z" + }, + { + "deployment_id" : "2541c842-0872-4da4-babc-f71535b43c79", + "critter_id" : "60bd985a-fd43-4b8a-804e-2f9abd822752", + "attachment_start" : "2021-01-20T08:00:00.000Z" + }, + { + "deployment_id" : "7a171fe9-5086-4c44-9962-37e30f39938a", + "critter_id" : "a93b3d34-2c85-4a8e-a52f-f9fcabd94ff4", + "attachment_start" : "2021-01-20T08:00:00.000Z" + }, + { + "deployment_id" : "b14587be-6e26-41ca-ab46-d15ec868ccc9", + "critter_id" : "07c9ef9a-7471-4250-9303-1415d2a33bb6", + "attachment_start" : "2020-02-07T08:00:00.000Z" + }, + { + "deployment_id" : "f1b8ef85-8699-4448-9d9d-9c2287f78372", + "critter_id" : "6b74cb19-0dd3-4a24-9abc-7a1667a1da39", + "attachment_start" : "2019-03-26T07:00:00.000Z" + }, + { + "deployment_id" : "167e7c4d-bcc9-40f1-b70b-bdecd3f86ee5", + "critter_id" : "5883f58f-bcfb-492d-b044-24f0e6359df1", + "attachment_start" : "2018-03-07T08:00:00.000Z" + }, + { + "deployment_id" : "456374b0-6529-41cf-9d56-eeefd23c38a9", + "critter_id" : "99da695f-b37b-41bf-b34c-6a66f3cbf8b8", + "attachment_start" : "2018-03-07T08:00:00.000Z" + }, + { + "deployment_id" : "9423f0e9-464b-4061-b50e-ee4a34eaee07", + "critter_id" : "68246853-4d0a-4717-a570-b7639b0e0631", + "attachment_start" : "2018-02-18T08:00:00.000Z" + }, + { + "deployment_id" : "0e41d01f-212c-4c81-b66d-198067115779", + "critter_id" : "346105ee-b711-4ea7-9907-c66d6b86d7f5", + "attachment_start" : "2018-03-07T08:00:00.000Z" + }, + { + "deployment_id" : "47877b27-8d04-4b66-b958-1c4cf246c72d", + "critter_id" : "cc621f36-20e2-422e-a6e2-d5355d975cb3", + "attachment_start" : "2019-03-26T07:00:00.000Z" + }, + { + "deployment_id" : "58c93ecd-0dde-48d6-af67-b7e0abbb4424", + "critter_id" : "31840e67-a1d2-40ba-a001-09cf1ce533e0", + "attachment_start" : "2019-03-25T07:00:00.000Z" + }, + { + "deployment_id" : "5307974e-ba9d-4f0b-94d1-b1b6e9d95f8b", + "critter_id" : "4f103e89-dc79-41fd-9991-352e7c0ff7e0", + "attachment_start" : "2019-03-26T07:00:00.000Z" + }, + { + "deployment_id" : "203b8559-9320-4f40-ae9b-60a6c635aed5", + "critter_id" : "fb56f01b-935a-4029-90f6-6d86144f614d", + "attachment_start" : "2019-03-25T07:00:00.000Z" + }, + { + "deployment_id" : "0715ad0d-f850-4fac-9e93-98481f34a9ff", + "critter_id" : "5f8aefad-0e1d-4b83-be7f-2f48e705c17a", + "attachment_start" : "2019-03-25T07:00:00.000Z" + }, + { + "deployment_id" : "0a49c11e-873b-4980-9ea5-9f527c73b9a9", + "critter_id" : "ff304aae-ec9d-40e9-8caf-6916c218caf8", + "attachment_start" : "2019-03-25T07:00:00.000Z" + }, + { + "deployment_id" : "8548f19b-2e07-4fe1-b5d7-a9ce847545de", + "critter_id" : "71b89494-f1dc-461f-b2cb-6a01deaee93e", + "attachment_start" : "2018-03-07T08:00:00.000Z" + }, + { + "deployment_id" : "3cef34b9-f700-4c28-a76e-21074b3c7aa7", + "critter_id" : "1e34ab6e-6b1c-4f85-991a-e7b8c7169896", + "attachment_start" : "2018-03-07T08:00:00.000Z" + }, + { + "deployment_id" : "d3c398bd-8132-4b63-9c0f-1063b78c8c0e", + "critter_id" : "5a9ca959-897f-448c-9056-7c09463d61e7", + "attachment_start" : "2018-03-07T08:00:00.000Z" + }, + { + "deployment_id" : "e1d87446-4a86-4a39-968a-b669f1e577a9", + "critter_id" : "30a52b16-9651-48fe-b7da-797a8e6055f2", + "attachment_start" : "2018-03-28T07:00:00.000Z" + }, + { + "deployment_id" : "8ca1cd77-4cc5-4f38-b5a7-6bc9f139a070", + "critter_id" : "55c4b2ad-979b-46c6-8000-b3a425faf45d", + "attachment_start" : "2021-02-19T08:00:00.000Z" + }, + { + "deployment_id" : "2d09659f-a9f9-4c0f-820c-c918daaa0113", + "critter_id" : "cfd4d868-ca8b-4710-ba8f-816c6b89efaa", + "attachment_start" : "2015-02-03T08:00:00.000Z" + }, + { + "deployment_id" : "c0f05a50-cb3b-49b8-82f6-6ea82820dd08", + "critter_id" : "ea7b8e07-293a-4ef8-bd81-94e9c1261736", + "attachment_start" : "2015-02-02T08:00:00.000Z" + }, + { + "deployment_id" : "bc0a6e5b-bfaf-4697-8baf-3c83895771a7", + "critter_id" : "4e82482c-09e0-467b-884a-dfe26cb6769f", + "attachment_start" : "2015-04-03T07:00:00.000Z" + }, + { + "deployment_id" : "757a0d75-9218-4278-a8d8-4fa71e02aea1", + "critter_id" : "7878c248-d3d4-4df8-9b4a-ac97391af06e", + "attachment_start" : "2015-02-03T08:00:00.000Z" + }, + { + "deployment_id" : "c1a8354a-8dd7-4f7a-92b5-2351e53419b4", + "critter_id" : "416097d9-d960-42dc-808c-6ad4380d3b5c", + "attachment_start" : "2021-02-23T08:00:00.000Z" + }, + { + "deployment_id" : "34517d43-f099-4727-80c3-131076715578", + "critter_id" : "f1d22d83-5076-40b8-a036-80085d94e9d6", + "attachment_start" : "2021-02-18T08:00:00.000Z" + }, + { + "deployment_id" : "a3b4c0ab-ed04-4b22-a70b-e3e9f23e755d", + "critter_id" : "b2d7367b-8d26-490b-b287-a1b68b6ddc5c", + "attachment_start" : "2021-02-23T08:00:00.000Z" + }, + { + "deployment_id" : "809adc28-aa2e-4e6f-9420-49a993d213fd", + "critter_id" : "be6da21b-84a8-4086-8e81-4dc7f0277f13", + "attachment_start" : "2021-02-19T08:00:00.000Z" + }, + { + "deployment_id" : "c091e800-2dbd-4395-beb1-a783f5e1650b", + "critter_id" : "abed4de1-33ca-49d8-98d9-90f9e1269ceb", + "attachment_start" : "2021-02-18T08:00:00.000Z" + }, + { + "deployment_id" : "469a3fce-e79c-483a-9422-ba18316b8954", + "critter_id" : "458567fc-2e05-42ae-821f-0f9fcbf56bbe", + "attachment_start" : "2021-02-18T08:00:00.000Z" + }, + { + "deployment_id" : "dd903d99-0316-4db1-8b93-4caaeb88afd5", + "critter_id" : "3027ea89-8bac-438a-a020-7fca6ac202b9", + "attachment_start" : "2021-02-18T08:00:00.000Z" + }, + { + "deployment_id" : "61fc0bad-6bf2-4230-b010-b9147be61916", + "critter_id" : "35bf2d87-e8ea-4a85-a967-7a93650493ff", + "attachment_start" : "2015-03-16T07:00:00.000Z" + }, + { + "deployment_id" : "0a63927e-23f4-414d-bea2-9d3d575ca8ff", + "critter_id" : "f3510edf-c8f9-47f0-9fb1-151359bfffc7", + "attachment_start" : "2015-03-16T07:00:00.000Z" + }, + { + "deployment_id" : "9ff775f9-9253-4028-bacf-a9badd10764e", + "critter_id" : "4b7e9093-18c2-4c17-abc7-1ec0f2ebf977", + "attachment_start" : "2013-02-03T08:00:00.000Z" + }, + { + "deployment_id" : "c1611cfb-e98f-44fa-a795-4d727f36bcc5", + "critter_id" : "d2f91e50-f742-4f3a-9d58-f9fdf4485c44", + "attachment_start" : "2016-03-01T08:00:00.000Z" + }, + { + "deployment_id" : "0ce317b9-e539-43d0-8368-e22c6db12200", + "critter_id" : "60c205fe-b5b5-47bd-abf4-1637e4b639e7", + "attachment_start" : "2015-04-02T07:00:00.000Z" + }, + { + "deployment_id" : "1843f1ff-708a-4fb8-9c42-2c46d3600fe1", + "critter_id" : "b96c8812-f79d-4192-9f42-de2596ef0a29", + "attachment_start" : "2013-03-02T08:00:00.000Z" + }, + { + "deployment_id" : "ef0b6196-73e0-4fd5-882e-6da6a622535e", + "critter_id" : "6c3b1c20-cec8-4ad2-b149-5e89b802c940", + "attachment_start" : "2016-03-07T08:00:00.000Z" + }, + { + "deployment_id" : "bf03f222-dade-42e1-a835-4ee2479b598d", + "critter_id" : "5df11d72-e042-42e7-bc07-cd06cf287cd7", + "attachment_start" : "2015-04-02T07:00:00.000Z" + }, + { + "deployment_id" : "8f5ebe40-9200-4557-af31-c88409227e3e", + "critter_id" : "e6324dee-c303-4739-9315-84d6bf4a50bd", + "attachment_start" : "2016-03-08T08:00:00.000Z" + }, + { + "deployment_id" : "6cab5b0a-1cd0-4ced-8f67-a8bf274f7aa2", + "critter_id" : "16647308-4db5-4277-b551-d10610153a2a", + "attachment_start" : "2018-03-28T07:00:00.000Z" + }, + { + "deployment_id" : "38050fdf-9a7c-4b67-80b5-bb3453fbeb75", + "critter_id" : "66c0d0ab-7a60-4a43-8603-ea7032e4b8dd", + "attachment_start" : "2015-03-10T07:00:00.000Z" + }, + { + "deployment_id" : "a8bf958c-494b-4e41-ae25-2a125371d3df", + "critter_id" : "39488494-3dd8-487f-8440-187155baca51", + "attachment_start" : "2016-03-07T08:00:00.000Z" + }, + { + "deployment_id" : "dced3a1c-f5c1-43be-ab8a-6b66bf01a017", + "critter_id" : "beafb8cb-471d-485e-8283-c4b7ffab21e2", + "attachment_start" : "2014-12-19T08:00:00.000Z" + }, + { + "deployment_id" : "ac569bef-20c3-464d-9c8c-e6986cac1822", + "critter_id" : "edae855f-836b-4b72-8490-af69acdc2d85", + "attachment_start" : "2016-03-08T08:00:00.000Z" + }, + { + "deployment_id" : "478d707e-0584-4d1b-a6f9-c33f1082bb2b", + "critter_id" : "d23db522-b3eb-4205-819e-2d78d7eff60f", + "attachment_start" : "2017-01-03T08:00:00.000Z" + }, + { + "deployment_id" : "9507820d-7683-4690-a3a5-7e369d3826ca", + "critter_id" : "64d04c13-b7d1-4483-9c1b-517bd60694c0", + "attachment_start" : "2015-03-10T07:00:00.000Z" + }, + { + "deployment_id" : "958120c1-67ec-49d4-8787-7e0073f48b51", + "critter_id" : "9cf463ca-1e01-4071-98af-f632f908fad5", + "attachment_start" : "2013-03-02T08:00:00.000Z" + }, + { + "deployment_id" : "09f53d0f-ee6e-4faf-ad0f-e003280bb737", + "critter_id" : "c37d5917-adfd-435d-a63e-0fce94460ba3", + "attachment_start" : "2013-03-03T08:00:00.000Z" + }, + { + "deployment_id" : "75b26163-434e-4c4b-960b-663b1f087f1a", + "critter_id" : "8ac89cc5-cc65-4fef-804b-b2824deb62e7", + "attachment_start" : "2018-03-10T08:00:00.000Z" + }, + { + "deployment_id" : "447441a4-f6a1-4196-b67c-0c7db37b3912", + "critter_id" : "20c4a5be-6e6d-4100-b3f1-e9714122e9e4", + "attachment_start" : "2013-02-27T08:00:00.000Z" + }, + { + "deployment_id" : "34205db5-a017-4328-b3fb-96b2f0dace18", + "critter_id" : "df29fa0e-a9f7-4079-9e8e-77a6d5dec58c", + "attachment_start" : "2013-02-24T08:00:00.000Z" + }, + { + "deployment_id" : "a1a969ec-0cfc-4954-b753-6eedacf6b50a", + "critter_id" : "2177beb1-3a7a-4d37-8069-5b84054b316d", + "attachment_start" : "2013-02-26T08:00:00.000Z" + }, + { + "deployment_id" : "dec6faa5-2950-4af0-9068-93fb7433ab29", + "critter_id" : "6b74cb19-0dd3-4a24-9abc-7a1667a1da39", + "attachment_start" : "2019-03-26T07:00:00.000Z" + }, + { + "deployment_id" : "6d68bb01-0329-4ee2-a834-c44b761e54c9", + "critter_id" : "5883f58f-bcfb-492d-b044-24f0e6359df1", + "attachment_start" : "2018-03-07T08:00:00.000Z" + }, + { + "deployment_id" : "99efe8bc-5c9c-424e-9969-d6753ff2fead", + "critter_id" : "7a12d602-8854-43fd-a9e7-e9ec53bd548b", + "attachment_start" : "2015-04-02T07:00:00.000Z" + }, + { + "deployment_id" : "ddab44f7-8e93-4782-b91e-e055741ec4ec", + "critter_id" : "4547e995-9a56-41c5-b6f5-fd060384172c", + "attachment_start" : "2016-03-07T08:00:00.000Z" + }, + { + "deployment_id" : "94067217-0429-4483-973e-16ade64df96c", + "critter_id" : "335af5a6-0335-402d-9bba-2670c55e990d", + "attachment_start" : "2013-01-07T08:00:00.000Z" + }, + { + "deployment_id" : "4bc957db-a5fc-4a9a-b72f-6dbeec876fb5", + "critter_id" : "0e8eccf1-61c4-4b95-9d6b-f15f3ca3d4bc", + "attachment_start" : "2013-02-24T08:00:00.000Z" + }, + { + "deployment_id" : "b2afabec-1db6-405d-892f-a54f494a74ae", + "critter_id" : "44cb279e-88c0-45c3-9203-1b035d6f8dc9", + "attachment_start" : "2016-03-08T08:00:00.000Z" + }, + { + "deployment_id" : "7ae41707-8e2f-491b-b906-2465e79646c3", + "critter_id" : "330e499e-2766-452e-9258-9b8513092ce1", + "attachment_start" : "2013-02-06T08:00:00.000Z" + }, + { + "deployment_id" : "ac1fe02f-35ac-474e-a8bb-ac15c3965048", + "critter_id" : "901ca963-0504-4665-82d5-598e3dc6f6a0", + "attachment_start" : "2013-02-28T08:00:00.000Z" + }, + { + "deployment_id" : "fbf6cd38-fe11-4def-a910-d443a1f32de7", + "critter_id" : "f3f9f2e2-047c-42e9-8274-e2462fad16e5", + "attachment_start" : "2013-04-01T07:00:00.000Z" + }, + { + "deployment_id" : "4e897f49-dd3c-4a73-a55d-0a0bb436c90b", + "critter_id" : "99da695f-b37b-41bf-b34c-6a66f3cbf8b8", + "attachment_start" : "2018-03-07T08:00:00.000Z" + }, + { + "deployment_id" : "37a63e37-2a42-42d2-823d-57f596bb03c7", + "critter_id" : "40dd31ab-5eee-4271-9e4b-4915723ee8bf", + "attachment_start" : "2016-03-08T08:00:00.000Z" + }, + { + "deployment_id" : "dd2b74eb-025e-403c-a1b4-59555207dd5c", + "critter_id" : "bd32127b-5b9b-4215-b294-c62e7c6c4459", + "attachment_start" : "2016-03-07T08:00:00.000Z" + }, + { + "deployment_id" : "d26c6223-e835-4ee4-8b80-b185b63e8775", + "critter_id" : "a664dd3c-7e05-4a8c-8359-eed5fe5a20f4", + "attachment_start" : "2013-02-06T08:00:00.000Z" + }, + { + "deployment_id" : "9434f390-dd53-48bc-b8b1-2242fe70eb4c", + "critter_id" : "f1479b02-f11b-43ab-997e-e8ea57e512b0", + "attachment_start" : "2013-01-26T08:00:00.000Z" + }, + { + "deployment_id" : "636e1218-97f4-43d2-a2d7-4cf627c3774a", + "critter_id" : "9483edd1-f98b-4a18-871b-c48a2e912f8c", + "attachment_start" : "2016-03-08T08:00:00.000Z" + }, + { + "deployment_id" : "4a49d15e-db32-499a-b9a7-e62da5960abd", + "critter_id" : "1d84e7b9-1885-432b-a14e-0ef9ba019824", + "attachment_start" : "2016-03-08T08:00:00.000Z" + }, + { + "deployment_id" : "d1a9d404-56f8-4542-9741-81769ece3266", + "critter_id" : "8e6d9c39-276f-408b-854a-ddd0a9ea02c6", + "attachment_start" : "2017-01-03T08:00:00.000Z" + }, + { + "deployment_id" : "e0659a9e-7731-4caf-8093-a68dbf61cda8", + "critter_id" : "342d7691-6c25-4255-bdc9-ec7b96886c4f", + "attachment_start" : "2016-03-07T08:00:00.000Z" + }, + { + "deployment_id" : "b2abb79b-5d00-4b07-b405-c76e4aeacb32", + "critter_id" : "6a973e86-3169-449d-9e3f-8ee693eb48e0", + "attachment_start" : "2014-12-08T08:00:00.000Z" + }, + { + "deployment_id" : "26b81d73-5583-4c81-8cf9-bb449391a7bf", + "critter_id" : "3cdfe161-3962-4016-852e-36227cb734d3", + "attachment_start" : "2017-01-05T08:00:00.000Z" + }, + { + "deployment_id" : "08f98270-d5f3-4309-a481-08d28c9f8a17", + "critter_id" : "68246853-4d0a-4717-a570-b7639b0e0631", + "attachment_start" : "2018-02-18T08:00:00.000Z" + }, + { + "deployment_id" : "fb0eb140-2ff4-4100-8517-3acb1291e02a", + "critter_id" : "184e2268-de1d-4c98-82ae-80c530831b60", + "attachment_start" : "2017-01-08T08:00:00.000Z" + }, + { + "deployment_id" : "0c65d25c-a90d-4f9f-8ec5-cc66cad0f2b6", + "critter_id" : "5fca4555-f2f4-409d-9efb-4577c0fc28be", + "attachment_start" : "2016-03-26T07:00:00.000Z" + }, + { + "deployment_id" : "5fedb02b-f73a-477b-8415-c52eb960ac3e", + "critter_id" : "b92a10b2-5b4d-4ee0-b531-2f2baa775e9a", + "attachment_start" : "2013-02-27T08:00:00.000Z" + }, + { + "deployment_id" : "c9607fac-6492-4e7e-87bb-531da72ecdbc", + "critter_id" : "3e987dca-f474-4c08-a108-a1e04dc4e3c5", + "attachment_start" : "2016-03-07T08:00:00.000Z" + }, + { + "deployment_id" : "73479dc3-13f2-4632-89ff-1e83011a0e38", + "critter_id" : "6cf49bdc-a0c2-4736-acb3-ead8805cf610", + "attachment_start" : "2013-02-06T08:00:00.000Z" + }, + { + "deployment_id" : "a624212d-3acb-4a16-a3f1-644dbd5537b2", + "critter_id" : "b345a6d5-3236-46f2-852c-4cdfe4e59d1d", + "attachment_start" : "2016-03-25T07:00:00.000Z" + }, + { + "deployment_id" : "0533a8c2-a78d-4bed-80fe-cb106f0b96b6", + "critter_id" : "dbce04a5-f35a-4c64-80ae-33504d2d42b4", + "attachment_start" : "2016-02-24T08:00:00.000Z" + }, + { + "deployment_id" : "17e79f5d-c30a-4068-9f60-4aebd697337a", + "critter_id" : "050962b2-2e5e-4180-a460-c39214f2850c", + "attachment_start" : "2016-03-07T08:00:00.000Z" + }, + { + "deployment_id" : "75d24fc4-f4eb-4931-97a2-108d1a930bbe", + "critter_id" : "31d0246e-4022-4c54-ad12-f5986e96371b", + "attachment_start" : "2017-01-08T08:00:00.000Z" + }, + { + "deployment_id" : "836f9acb-4047-4b87-b592-d55ff07841d2", + "critter_id" : "535ac206-81a9-43b1-804b-719d4a02d7bd", + "attachment_start" : "2016-02-25T08:00:00.000Z" + }, + { + "deployment_id" : "03db5e71-35d5-46a3-929c-809a4882056a", + "critter_id" : "bb00b91d-cdef-4cd4-a0c9-e7d8e39d82d1", + "attachment_start" : "2017-03-14T07:00:00.000Z" + }, + { + "deployment_id" : "223255b8-addb-4410-addd-1ce0d577339f", + "critter_id" : "ab32dd85-4125-4baf-9ab7-a2623dbd30af", + "attachment_start" : "2013-02-25T08:00:00.000Z" + }, + { + "deployment_id" : "24f13745-fedb-40c4-95a4-d879f648ff93", + "critter_id" : "fc6df967-5c4c-4c16-b837-f80616358aa7", + "attachment_start" : "2014-12-11T08:00:00.000Z" + }, + { + "deployment_id" : "9e3ceb45-aa95-4e73-8489-c0abac392763", + "critter_id" : "1962a780-a9f9-4a29-b5f7-a39c8950a571", + "attachment_start" : "2014-12-09T08:00:00.000Z" + }, + { + "deployment_id" : "ac6e109e-ecc4-4be2-b47c-629ea7957f60", + "critter_id" : "4d18d754-f3b1-4ad0-898a-4aacd92a9080", + "attachment_start" : "2016-03-27T07:00:00.000Z" + }, + { + "deployment_id" : "afce910e-9487-42c2-9c86-93e50f36aff2", + "critter_id" : "634443a5-d83b-42c7-b5e4-c49e29f43451", + "attachment_start" : "2019-02-17T08:00:00.000Z" + }, + { + "deployment_id" : "7bef5ebd-fd1f-477f-b6d8-f50dd3633613", + "critter_id" : "759d6bc2-4b6f-4a66-8aa1-cc3a04cbe7c3", + "attachment_start" : "2016-03-07T08:00:00.000Z" + }, + { + "deployment_id" : "600ef8f1-7853-4946-b492-f47dad32f63f", + "critter_id" : "d89c0362-763d-4506-ac0c-5716a74435c6", + "attachment_start" : "2017-02-02T08:00:00.000Z" + }, + { + "deployment_id" : "0c8b23d1-9ea5-4615-95aa-0d0052cb14e5", + "critter_id" : "369265d5-7109-4095-aa76-8f9816e567e0", + "attachment_start" : "2018-02-20T08:00:00.000Z" + }, + { + "deployment_id" : "791b3364-2dc0-484e-b9ff-8971a8468488", + "critter_id" : "703bc99e-5ece-4dd4-be51-ebcedfdc3857", + "attachment_start" : "2018-02-20T08:00:00.000Z" + }, + { + "deployment_id" : "30aefb09-df1c-4cb6-adda-995db81572fc", + "critter_id" : "4fdfb8f2-56ce-4c98-b3ad-e2a5d752ff75", + "attachment_start" : "2017-02-02T08:00:00.000Z" + }, + { + "deployment_id" : "ff6f17ca-f254-4159-97af-28c990172ebd", + "critter_id" : "786709ac-34de-4557-b9a3-ebed4b104375", + "attachment_start" : "2018-02-20T08:00:00.000Z" + }, + { + "deployment_id" : "533fbbc5-ad28-4088-84b4-f224f4abba48", + "critter_id" : "50de4285-c7fc-4640-9262-81afb0ccf0d2", + "attachment_start" : "2018-02-19T08:00:00.000Z" + }, + { + "deployment_id" : "d50c055e-e462-4b2d-a597-2a51eeb95c63", + "critter_id" : "ad4456b6-25cc-4b5c-91d5-f363a00c8135", + "attachment_start" : "2018-02-19T08:00:00.000Z" + }, + { + "deployment_id" : "77c85fac-310f-43d8-824c-ce336e25b5b7", + "critter_id" : "1401c5b2-d72c-476f-bd64-f1e8c3bc2c07", + "attachment_start" : "2018-02-19T08:00:00.000Z" + }, + { + "deployment_id" : "57a9f1b5-8340-45d3-868f-fba3b426369b", + "critter_id" : "9c8a9162-99ed-48d3-8c77-b1ab8c080364", + "attachment_start" : "2013-02-02T08:00:00.000Z" + }, + { + "deployment_id" : "02330192-1ac0-44e0-a407-2fa52a9c9ffc", + "critter_id" : "cd597237-b497-4e74-ab14-d87280b42c71", + "attachment_start" : "2018-02-20T08:00:00.000Z" + }, + { + "deployment_id" : "0936e412-fdb1-4feb-9b9c-a57d80a5f46c", + "critter_id" : "6a64b3cb-0698-4ab7-96c3-386a1c4cf57d", + "attachment_start" : "2018-02-19T08:00:00.000Z" + }, + { + "deployment_id" : "4dc17928-401d-4a61-bdf2-559eccba0772", + "critter_id" : "8a274cba-2b2f-4ac3-8d3f-ba53dcd3ca4e", + "attachment_start" : "2014-12-11T08:00:00.000Z" + }, + { + "deployment_id" : "73e98c5e-7ac8-4860-b1ac-9f63650dc135", + "critter_id" : "4f91f39a-58d8-46d2-9195-6a4409908617", + "attachment_start" : "2013-02-25T08:00:00.000Z" + }, + { + "deployment_id" : "31843fbd-6044-42e4-bf63-8819fd47898f", + "critter_id" : "346105ee-b711-4ea7-9907-c66d6b86d7f5", + "attachment_start" : "2018-03-07T08:00:00.000Z" + }, + { + "deployment_id" : "3bd98738-ae34-46ef-b8e3-26f24042406b", + "critter_id" : "169f5636-4000-4910-a557-91f1016934c2", + "attachment_start" : "2013-01-08T08:00:00.000Z" + }, + { + "deployment_id" : "d9048c39-9d36-4617-96b3-9a8520a250f4", + "critter_id" : "d5110b3c-058c-45d6-a88d-57f5d1361a4f", + "attachment_start" : "2017-01-08T08:00:00.000Z" + }, + { + "deployment_id" : "3a5ce6ea-f253-42e6-ba1e-9eaf2accd46c", + "critter_id" : "deda666d-bc0d-49fa-8b27-7ce888147d1c", + "attachment_start" : "2013-02-02T08:00:00.000Z" + }, + { + "deployment_id" : "96e8fc6c-91e1-4bc7-9018-162c7d252c0f", + "critter_id" : "a6b69af0-bb8d-4ea7-a9b6-2aa2e1070b49", + "attachment_start" : "2013-02-26T08:00:00.000Z" + }, + { + "deployment_id" : "b05f6171-b2a3-425f-9d0d-3a4557551be0", + "critter_id" : "af1f203c-ce62-4cba-8079-993107593fa0", + "attachment_start" : "2016-02-24T08:00:00.000Z" + }, + { + "deployment_id" : "ca5ac71d-dfc5-4afe-88ee-191f80844e5c", + "critter_id" : "58bd430b-06dc-4781-a0c7-6dc61890ae7c", + "attachment_start" : "2017-01-04T08:00:00.000Z" + }, + { + "deployment_id" : "b0ce2b58-d16b-4443-8449-4c31b15e0c53", + "critter_id" : "75c8000d-6637-4d22-a51e-90f4d8ee33c2", + "attachment_start" : "2016-02-19T08:00:00.000Z" + }, + { + "deployment_id" : "986ba230-2e6d-4b2d-b0fc-d3655aa40b35", + "critter_id" : "76fa2000-15be-4639-9e72-8d7894f11356", + "attachment_start" : "2016-03-08T08:00:00.000Z" + }, + { + "deployment_id" : "e21d9998-871c-4ba5-8f8a-8d3913baf9ab", + "critter_id" : "57a2063d-d308-46e9-87fc-7f0b040231db", + "attachment_start" : "2013-03-02T08:00:00.000Z" + }, + { + "deployment_id" : "bf29c4a1-c821-474d-a2b7-061a801e74b1", + "critter_id" : "ef6ce50e-b1bc-4148-8722-d2432acc15ab", + "attachment_start" : "2013-02-04T08:00:00.000Z" + }, + { + "deployment_id" : "f9f6952e-c031-417a-9fd7-494eb3df35ea", + "critter_id" : "cc621f36-20e2-422e-a6e2-d5355d975cb3", + "attachment_start" : "2019-03-26T07:00:00.000Z" + }, + { + "deployment_id" : "f1c08847-2f32-417a-8dc5-110d1c117f23", + "critter_id" : "f7256af7-a6c1-4f3e-9e8f-6e48e9e0ca7d", + "attachment_start" : "2015-04-02T07:00:00.000Z" + }, + { + "deployment_id" : "be64c219-3d1e-49c1-b595-af1d14d971e2", + "critter_id" : "2d459ebb-b400-46c3-bf12-f99a30ea056b", + "attachment_start" : "2015-04-02T07:00:00.000Z" + }, + { + "deployment_id" : "11e0990c-e862-4d76-9b94-f5a47c6b26ae", + "critter_id" : "5059de52-f078-4729-ad2b-deeaab2741f8", + "attachment_start" : "2013-02-26T08:00:00.000Z" + }, + { + "deployment_id" : "90de3322-e9e8-4d8c-aa03-cf907982de1d", + "critter_id" : "c08bdd94-c54e-4686-936c-b35a8c1699bd", + "attachment_start" : "2013-02-26T08:00:00.000Z" + }, + { + "deployment_id" : "50fad6a2-4ed9-468c-98b1-b398c9bf06db", + "critter_id" : "4a9552e1-bf6a-46f1-9f46-a80912abb80c", + "attachment_start" : "2015-03-16T07:00:00.000Z" + }, + { + "deployment_id" : "3b7cf3d1-3bef-4a9c-a0f2-53ec5c7aaec3", + "critter_id" : "e941a6bf-e4ae-4c7e-a02f-7640bab2f49e", + "attachment_start" : "2013-02-24T08:00:00.000Z" + }, + { + "deployment_id" : "3da9bd2f-6f23-4562-8ea2-99eea34813a1", + "critter_id" : "7e5d960e-fae4-4776-b197-ba93658187eb", + "attachment_start" : "2016-03-27T07:00:00.000Z" + }, + { + "deployment_id" : "f799a4b6-738c-46e1-89d3-99cd72ea073d", + "critter_id" : "fb5cd3c3-7fb7-4e86-94f5-66308dda1ed9", + "attachment_start" : "2016-03-01T08:00:00.000Z" + }, + { + "deployment_id" : "96cd1938-ee17-4659-8c2a-08d50d96732b", + "critter_id" : "14876cf2-37e8-4ed6-8924-12c8ec7dcbe9", + "attachment_start" : "2014-12-18T08:00:00.000Z" + }, + { + "deployment_id" : "49e60af2-5dc1-4580-939a-6546c5f0a2be", + "critter_id" : "d1758ceb-14bb-44be-a5f0-c6b8e9d2dfcd", + "attachment_start" : "2013-03-01T08:00:00.000Z" + }, + { + "deployment_id" : "7a269c4d-fcd9-4a0c-9d5e-6241d5880d02", + "critter_id" : "91f758e2-e693-40c5-a57a-583b48e9deb6", + "attachment_start" : "2013-02-27T08:00:00.000Z" + }, + { + "deployment_id" : "cf571d72-f5f6-4849-98b3-1f368d44be13", + "critter_id" : "50840645-bb2e-4d56-a98f-814890d41fca", + "attachment_start" : "2016-03-01T08:00:00.000Z" + }, + { + "deployment_id" : "d9741a09-29be-42e6-8285-df09131ce1ca", + "critter_id" : "4ce7bce1-1cd1-4600-af02-ceb64dd8215f", + "attachment_start" : "2017-03-14T07:00:00.000Z" + }, + { + "deployment_id" : "fb14d12c-a2da-4f8d-9e56-ad1ed16c5ad4", + "critter_id" : "d2166014-3949-40d8-8327-27810787d5df", + "attachment_start" : "2017-03-14T07:00:00.000Z" + }, + { + "deployment_id" : "390995d3-ce60-4dda-ab9d-2ce615cc199e", + "critter_id" : "c6bdaa2c-e61d-4a0b-952f-53898f852e8f", + "attachment_start" : "2017-03-14T07:00:00.000Z" + }, + { + "deployment_id" : "ab9bb366-abe8-4b30-a8b2-3ec69f1fee09", + "critter_id" : "338d57b4-2aed-4a36-af5b-ba5fee0446e8", + "attachment_start" : "2017-03-14T07:00:00.000Z" + }, + { + "deployment_id" : "a31c0f25-d82e-42d8-a33d-b45f85200ec4", + "critter_id" : "293f5caa-9366-4875-837f-57a3ac0d42a6", + "attachment_start" : "2017-03-14T07:00:00.000Z" + }, + { + "deployment_id" : "18ee41a1-0672-44d3-8a4b-654fbe4ec5e5", + "critter_id" : "4a67c462-dd08-47b0-91f6-afb7b6f10689", + "attachment_start" : "2018-02-20T08:00:00.000Z" + }, + { + "deployment_id" : "d4d55a52-2fd0-4df5-930f-33cb403145c6", + "critter_id" : "87d68a29-0784-4335-9267-a3b94f62951a", + "attachment_start" : "2017-03-14T07:00:00.000Z" + }, + { + "deployment_id" : "d89e66ad-f82e-487a-804e-8e7ec1ecfe0f", + "critter_id" : "4e9e9be1-85bd-4eee-a7d0-f2f468c6ed91", + "attachment_start" : "2013-02-28T08:00:00.000Z" + }, + { + "deployment_id" : "070b7663-53b0-4b56-b588-259f09936926", + "critter_id" : "33cefcda-3ef8-4743-b6d8-1a728a2e28c7", + "attachment_start" : "2018-03-07T08:00:00.000Z" + }, + { + "deployment_id" : "aafe24ee-3be6-441c-be33-30c96f3d9f16", + "critter_id" : "0a330644-f1cd-4cb2-9260-7ef65cbf4622", + "attachment_start" : "2013-02-06T08:00:00.000Z" + }, + { + "deployment_id" : "17393f6f-be55-47bb-b636-af8acf25af94", + "critter_id" : "1ca3d3f6-3057-42a7-8d90-8b17f28c2c69", + "attachment_start" : "2013-01-21T08:00:00.000Z" + }, + { + "deployment_id" : "c61735cc-bdea-4bb7-9786-9888520432a8", + "critter_id" : "90cab07e-5938-4c63-86f7-d2691c8162b1", + "attachment_start" : "2017-01-03T08:00:00.000Z" + }, + { + "deployment_id" : "2ff1b44d-e7b1-4e63-adac-05fa7be2ed7e", + "critter_id" : "94190f31-446b-466d-bcae-f8c6c65d9a63", + "attachment_start" : "2013-01-26T08:00:00.000Z" + }, + { + "deployment_id" : "4a5a22bd-f5b6-476e-ab46-316224eb97d7", + "critter_id" : "566844df-de6b-46d9-874f-42b7f8d0d221", + "attachment_start" : "2013-01-08T08:00:00.000Z" + }, + { + "deployment_id" : "fb8c4a46-baaa-4754-a4a9-338eb3c8945c", + "critter_id" : "a34990c1-f1d9-4615-9c24-99579027688d", + "attachment_start" : "2017-01-03T08:00:00.000Z" + }, + { + "deployment_id" : "ed7d805c-42af-462d-93a8-8ef8c0adfbaa", + "critter_id" : "13b145ee-2255-4cd1-9c3c-a059d9bf3ea9", + "attachment_start" : "2018-03-13T07:00:00.000Z" + }, + { + "deployment_id" : "2753030b-3545-41ba-b95d-9a594cc94548", + "critter_id" : "48e1328d-a614-4bb7-a387-49547d996f98", + "attachment_start" : "2018-03-06T08:00:00.000Z" + }, + { + "deployment_id" : "a9e231e7-605b-4b61-b14a-c30b510fdb20", + "critter_id" : "a636081d-207a-49b1-8ab7-d965098f9b1b", + "attachment_start" : "2015-02-26T08:00:00.000Z" + }, + { + "deployment_id" : "cb16aae0-db01-4934-af8e-d22cd0fedd7f", + "critter_id" : "7a99840a-f2f1-4fbd-a2e0-4dd50dda6a1d", + "attachment_start" : "2019-03-15T07:00:00.000Z" + }, + { + "deployment_id" : "105f325c-7563-4f6d-8eb8-1756907b0284", + "critter_id" : "3ac11676-e5b4-4956-85af-ec49c4dfd9df", + "attachment_start" : "2018-03-13T07:00:00.000Z" + }, + { + "deployment_id" : "ca3c8cd9-362e-4ee4-84af-9a2face77a68", + "critter_id" : "9f46b51a-a5eb-4404-8e63-c910e9bbfcaa", + "attachment_start" : "2018-03-14T07:00:00.000Z" + }, + { + "deployment_id" : "5d6acf3e-b10d-4f2d-a6ee-8f6998dd971d", + "critter_id" : "8c4db98b-9d84-411c-b37c-840745658372", + "attachment_start" : "2018-03-14T07:00:00.000Z" + }, + { + "deployment_id" : "b3f62643-bf2e-47a3-8bbf-b63ff49b842b", + "critter_id" : "02193710-335e-4777-9199-28a19f20d129", + "attachment_start" : "2013-02-28T08:00:00.000Z" + }, + { + "deployment_id" : "d091d107-64ac-4a9c-8895-0edaba9510d7", + "critter_id" : "14e1b0f8-3f4d-422b-8301-ee5736afdab2", + "attachment_start" : "2016-03-07T08:00:00.000Z" + }, + { + "deployment_id" : "2a3c996a-8d0b-4a69-bcbf-3e15d7f95713", + "critter_id" : "5e79565a-d626-41fa-a8fb-064cc3885e93", + "attachment_start" : "2017-01-03T08:00:00.000Z" + }, + { + "deployment_id" : "ae675ffb-03cd-4894-a463-74ca62db212c", + "critter_id" : "46401cc1-c58d-4ffd-b8c6-6d7c84972a25", + "attachment_start" : "2013-02-23T08:00:00.000Z" + }, + { + "deployment_id" : "11f321f5-8d45-4e03-9994-b7291ad7311f", + "critter_id" : "dd83f120-a986-4b4e-b42c-5bbb527f41b3", + "attachment_start" : "2013-01-20T08:00:00.000Z" + }, + { + "deployment_id" : "5c7e050d-1f5a-441e-a859-724a09515589", + "critter_id" : "cd585882-a9dc-438b-8c29-098e8d8adf02", + "attachment_start" : "2016-03-07T08:00:00.000Z" + }, + { + "deployment_id" : "afb5dbd0-5acc-414a-8221-5c7c0c6dd25a", + "critter_id" : "e7729315-241a-4ad5-97da-8501e38a036f", + "attachment_start" : "2013-01-08T08:00:00.000Z" + }, + { + "deployment_id" : "e5afc3be-b3f9-4b1c-813f-d67111c38d3d", + "critter_id" : "6d1efe9c-4ff4-4315-b174-1ecc6c418e05", + "attachment_start" : "2014-12-11T08:00:00.000Z" + }, + { + "deployment_id" : "0d2e54e7-5451-47d7-8313-d2c334c690b2", + "critter_id" : "25d1e3eb-7a6c-4b76-aee7-413870bebc30", + "attachment_start" : "2013-02-05T08:00:00.000Z" + }, + { + "deployment_id" : "33a08c91-7140-4a99-841b-17f6d5ffcc2c", + "critter_id" : "f7fb3fd6-e487-4007-9a36-621ecb996f38", + "attachment_start" : "2013-02-27T08:00:00.000Z" + }, + { + "deployment_id" : "4a8e0847-a52b-41d1-96e3-1fae18e344ba", + "critter_id" : "90ffd7d4-dab0-4d6b-963f-85175d3eb3bd", + "attachment_start" : "2016-03-08T08:00:00.000Z" + }, + { + "deployment_id" : "866d8acb-5e70-404e-afcb-eb2769094ce3", + "critter_id" : "30086609-a7e1-4b30-a5b3-de9a1e4ce9fa", + "attachment_start" : "2013-02-06T08:00:00.000Z" + }, + { + "deployment_id" : "62c36e8a-193e-4fe0-98c7-8517dd306cc6", + "critter_id" : "37f763d8-6f70-4fea-8d43-7c8054830e28", + "attachment_start" : "2013-03-03T08:00:00.000Z" + }, + { + "deployment_id" : "af105994-c1a1-4219-a06b-a5470b84ec7d", + "critter_id" : "48857417-aeb1-4a55-ba46-4832f6bce5f4", + "attachment_start" : "2016-03-08T08:00:00.000Z" + }, + { + "deployment_id" : "18ae12d7-57e3-47d8-8d53-881f3850d169", + "critter_id" : "ea21f2f5-e330-4e32-9ed2-80fc23f338b7", + "attachment_start" : "2014-12-08T08:00:00.000Z" + }, + { + "deployment_id" : "c8027a01-768c-4663-9f63-6a81a128ed72", + "critter_id" : "b71adb24-d03c-4412-8b5b-37816066d6c6", + "attachment_start" : "2013-02-03T08:00:00.000Z" + }, + { + "deployment_id" : "cbad9c05-6367-4f29-a52d-87c96705c543", + "critter_id" : "93a3f48b-0ecd-44d5-8711-5b2a06efdc14", + "attachment_start" : "2013-01-30T08:00:00.000Z" + }, + { + "deployment_id" : "406e864d-b39f-447c-8d91-37694bb8af92", + "critter_id" : "4ac38c9a-01d3-4581-a493-cb74349af389", + "attachment_start" : "2013-01-31T08:00:00.000Z" + }, + { + "deployment_id" : "7a332545-c239-44f3-aa14-a12e37258c3b", + "critter_id" : "40cbc3de-0e22-407c-94ed-4fa7b8148b8c", + "attachment_start" : "2013-01-31T08:00:00.000Z" + }, + { + "deployment_id" : "f096c7fa-0665-4aeb-a80a-3b62b8282e83", + "critter_id" : "7a031147-4256-4ef7-b0ee-76d01314eb8d", + "attachment_start" : "2018-03-28T07:00:00.000Z" + }, + { + "deployment_id" : "3c67f6d4-db32-4bea-a103-03a60b6848e7", + "critter_id" : "c33752f7-dac5-4449-85c4-485a4dffb2bb", + "attachment_start" : "2013-01-31T08:00:00.000Z" + }, + { + "deployment_id" : "fd0b4e6a-a9bb-401e-a00c-3079a4c4754e", + "critter_id" : "22478744-2936-48a9-b0bd-4450ac9d29a1", + "attachment_start" : "2013-01-31T08:00:00.000Z" + }, + { + "deployment_id" : "4aac0fce-1eec-4aae-9298-c4b9288cb13a", + "critter_id" : "c49ab4f0-5a9a-42d0-ba73-089be2f2e2b5", + "attachment_start" : "2019-03-16T07:00:00.000Z" + }, + { + "deployment_id" : "2473239c-f2d9-48df-8127-fd7ff73e84ac", + "critter_id" : "31840e67-a1d2-40ba-a001-09cf1ce533e0", + "attachment_start" : "2019-03-25T07:00:00.000Z" + }, + { + "deployment_id" : "c86f5f69-0d09-41a3-97f6-730778ec5844", + "critter_id" : "e892ec9a-eb79-479e-9dd6-28be4163060f", + "attachment_start" : "2018-03-06T08:00:00.000Z" + }, + { + "deployment_id" : "abe0c899-97f5-47c2-b8ee-7e38fb05ba05", + "critter_id" : "7fb77a18-ac2c-4a83-852b-3bea0f53dc7b", + "attachment_start" : "2018-03-04T08:00:00.000Z" + }, + { + "deployment_id" : "3bc5d4d0-226d-4286-a56a-0275ecf0bcef", + "critter_id" : "03892b84-a71f-4886-b3cf-8eafbaba03c7", + "attachment_start" : "2018-03-04T08:00:00.000Z" + }, + { + "deployment_id" : "78e1016c-7d51-4f7b-ab1f-e847bb2e1797", + "critter_id" : "30d988ea-9765-4dcc-9eb3-88c8b8f65b30", + "attachment_start" : "2018-03-06T08:00:00.000Z" + }, + { + "deployment_id" : "4e55fe42-65d4-459b-b0e3-77c98eec70af", + "critter_id" : "bd7f743c-ecb4-48d5-ad9f-ae17d28bc78b", + "attachment_start" : "2019-03-16T07:00:00.000Z" + }, + { + "deployment_id" : "178b952b-089c-49a6-87d8-adfb9595bbff", + "critter_id" : "05f175a0-a887-4d27-be8c-b81278c6bc8f", + "attachment_start" : "2018-03-06T08:00:00.000Z" + }, + { + "deployment_id" : "0eff52fc-01e4-46c5-beba-dcf42a6b3d51", + "critter_id" : "042a24c0-1363-4435-acdb-dcf144d83a7c", + "attachment_start" : "2018-03-07T08:00:00.000Z" + }, + { + "deployment_id" : "25ef402a-be3d-4821-b964-74ea69a84e19", + "critter_id" : "e4a5fc14-31f1-4e66-a372-80777846ccb3", + "attachment_start" : "2019-02-11T08:00:00.000Z" + }, + { + "deployment_id" : "abfcee43-e4f0-4b20-8f86-89c3596da0e6", + "critter_id" : "9de40aa2-f940-45df-a8d0-7b223ca7d6bd", + "attachment_start" : "2018-03-07T08:00:00.000Z" + }, + { + "deployment_id" : "9dc322fd-badc-4c62-9762-b79e02c18312", + "critter_id" : "433fff41-03d5-4cef-b6b6-ae51cd0792ec", + "attachment_start" : "2015-02-25T08:00:00.000Z" + }, + { + "deployment_id" : "40785f78-680b-4fb4-9dfd-f49b04dbf18f", + "critter_id" : "efd3f247-9896-4dc2-8006-f61f3288fa1b", + "attachment_start" : "2018-03-06T08:00:00.000Z" + }, + { + "deployment_id" : "2f925a82-afee-4714-ad61-40b70a0aa3ce", + "critter_id" : "087d6821-ec1f-4e9d-a6ff-24400f676d2e", + "attachment_start" : "2015-03-28T07:00:00.000Z" + }, + { + "deployment_id" : "291cbd87-e95b-4d34-b791-a82d591e694f", + "critter_id" : "cee1cd01-46b4-4e05-817a-56bd5ec5a9ba", + "attachment_start" : "2016-02-24T08:00:00.000Z" + }, + { + "deployment_id" : "eee0ab7d-a930-4a9e-8b9d-0fcc90cccdff", + "critter_id" : "349b1231-cfe7-4fdc-8d47-daa398d98fd1", + "attachment_start" : "2018-03-04T08:00:00.000Z" + }, + { + "deployment_id" : "646a82af-6c57-4d0c-9360-298cee17290d", + "critter_id" : "0ef204fe-ecfe-4ed4-b4e7-b10cfc78a7e2", + "attachment_start" : "2015-03-31T07:00:00.000Z" + }, + { + "deployment_id" : "90418e6f-79aa-43f0-a635-742a2f2b0aba", + "critter_id" : "f9a214c8-f8ec-42e0-8fb2-96d0991c22e6", + "attachment_start" : "2015-04-02T07:00:00.000Z" + }, + { + "deployment_id" : "71e25916-d8d6-4fd2-896e-3469752c61f9", + "critter_id" : "37287b4a-0ba5-4d81-9694-a5f6a1e37ec4", + "attachment_start" : "2018-03-06T08:00:00.000Z" + }, + { + "deployment_id" : "ad1f01c4-75fd-4f34-9803-13dcc50cb0d1", + "critter_id" : "d7d3b994-6b7c-44c6-a52c-4c4820e2ecfc", + "attachment_start" : "2018-03-06T08:00:00.000Z" + }, + { + "deployment_id" : "04f0a7b2-0ce8-42f2-8f3b-7b0f646ac060", + "critter_id" : "e5d2a9f5-b45d-4a57-8c64-2a5a37234882", + "attachment_start" : "2015-02-27T08:00:00.000Z" + }, + { + "deployment_id" : "e4dfcca0-0647-4b14-9764-9bca0b0710f2", + "critter_id" : "9eeb497e-02e4-4078-9afc-37ede167a565", + "attachment_start" : "2017-03-14T07:00:00.000Z" + }, + { + "deployment_id" : "b418a14a-901b-4c29-a685-ce24b985a217", + "critter_id" : "9526e188-e831-4ee2-91f6-93826bd33b0a", + "attachment_start" : "2015-02-26T08:00:00.000Z" + }, + { + "deployment_id" : "c9086de5-8c1a-47c8-94fe-d521fc3192a4", + "critter_id" : "897c99a4-67f0-4c8b-9219-8279b690cf1f", + "attachment_start" : "2015-02-24T08:00:00.000Z" + }, + { + "deployment_id" : "19b20687-0b4c-4565-8216-a681f98764f9", + "critter_id" : "a47c5f75-138c-48ee-b683-d70b719bcf33", + "attachment_start" : "2019-03-16T07:00:00.000Z" + }, + { + "deployment_id" : "d9a2e71a-202b-45ce-8593-9e3b8887987c", + "critter_id" : "245f8a7c-8e68-45cb-a5ea-103f43e38efc", + "attachment_start" : "2015-02-24T08:00:00.000Z" + }, + { + "deployment_id" : "060a6522-fc3c-4881-962c-055cdf54bafb", + "critter_id" : "988581d8-8b23-4e16-aa2f-adb76dc69df8", + "attachment_start" : "2015-02-25T08:00:00.000Z" + }, + { + "deployment_id" : "e1c3047e-2a2a-4ca8-9c3c-0379ff9dd5ab", + "critter_id" : "48b618d0-b6d0-49ce-b5f5-56d59e177d3f", + "attachment_start" : "2015-02-25T08:00:00.000Z" + }, + { + "deployment_id" : "2f5a9489-ff92-4891-a041-d707222a8f51", + "critter_id" : "d325a970-108b-412f-b370-d916727e5f0b", + "attachment_start" : "2015-04-01T07:00:00.000Z" + }, + { + "deployment_id" : "3b528e41-d7a9-433f-b3c3-51beb27c11f9", + "critter_id" : "c58969cd-b5d0-419a-85b4-f1fc71ce80b1", + "attachment_start" : "2015-03-31T07:00:00.000Z" + }, + { + "deployment_id" : "d7d5962b-67b6-4d3f-b963-80dfdb46f490", + "critter_id" : "0a966c91-d248-4ee6-9fe7-588b5e400d7a", + "attachment_start" : "2015-02-27T08:00:00.000Z" + }, + { + "deployment_id" : "886ac95c-d7a0-4d59-a318-0120c041f3f9", + "critter_id" : "627b7d64-a42b-41b1-9f79-d1212cc1da34", + "attachment_start" : "2019-02-14T08:00:00.000Z" + }, + { + "deployment_id" : "69a77e45-5f20-45c0-82d0-ad85ad4054c8", + "critter_id" : "b25d1d5c-711c-41ee-8c5b-a244f5686bd1", + "attachment_start" : "2018-03-29T07:00:00.000Z" + }, + { + "deployment_id" : "b9ae0e42-1572-4565-9926-c8603e48754d", + "critter_id" : "55771a7e-2353-4106-847d-837e971b97ad", + "attachment_start" : "2019-02-12T08:00:00.000Z" + }, + { + "deployment_id" : "3f8a5772-9a86-45bb-8b52-ace08d2ed854", + "critter_id" : "56f1607c-d0e4-4405-b345-65cd6cf78ab7", + "attachment_start" : "2019-02-13T08:00:00.000Z" + }, + { + "deployment_id" : "8127c0d5-3959-4e24-9641-e6aec00db488", + "critter_id" : "dcf1f600-79d9-4a7a-bb53-9af83ad31d55", + "attachment_start" : "2019-02-23T08:00:00.000Z" + }, + { + "deployment_id" : "acdfb7ba-ae69-4660-bd1a-4f48e4c9e6a7", + "critter_id" : "3032a254-08b6-4a45-a88b-47b3b96875f9", + "attachment_start" : "2019-02-13T08:00:00.000Z" + }, + { + "deployment_id" : "72da859e-487b-432d-9d75-f61304cc5c8e", + "critter_id" : "02497850-d7f8-44d1-b7f9-186371ffde4e", + "attachment_start" : "2019-02-13T08:00:00.000Z" + }, + { + "deployment_id" : "db2ed270-692e-42b4-9113-d86eaea20e3a", + "critter_id" : "04c9d4e3-9cdd-408c-9680-ada1f9d6abb2", + "attachment_start" : "2018-03-28T07:00:00.000Z" + }, + { + "deployment_id" : "13c894a4-39d8-4286-8e37-8bd834634db4", + "critter_id" : "a4f7749a-bf55-43b3-8888-da6a21ffc4ee", + "attachment_start" : "2019-02-12T08:00:00.000Z" + }, + { + "deployment_id" : "7cd88067-c4b0-4d9e-9076-9b2d088bf800", + "critter_id" : "afa042e0-c886-4359-aec2-f77f7f099693", + "attachment_start" : "2019-02-11T08:00:00.000Z" + }, + { + "deployment_id" : "3daf661f-f2f0-47cf-bd99-9010b56fe0da", + "critter_id" : "4f103e89-dc79-41fd-9991-352e7c0ff7e0", + "attachment_start" : "2019-03-26T07:00:00.000Z" + }, + { + "deployment_id" : "a28bbb4a-7086-4faa-88f9-e7898eb4c415", + "critter_id" : "413b41ec-facd-4dea-9ec5-4404c4e34af0", + "attachment_start" : "2019-02-16T08:00:00.000Z" + }, + { + "deployment_id" : "6206b8ba-7946-4b1b-8500-14a78006f72c", + "critter_id" : "57741227-4476-4ad8-92b8-ae55c79cabd1", + "attachment_start" : "2018-03-29T07:00:00.000Z" + }, + { + "deployment_id" : "39a6a275-5f54-4834-94c1-46257a8605e8", + "critter_id" : "9204dfe5-5235-4de3-857c-a593c66f08ab", + "attachment_start" : "2019-02-07T08:00:00.000Z" + }, + { + "deployment_id" : "ee4a65c9-3ea2-4ca6-9804-b249d0ec79d5", + "critter_id" : "fb56f01b-935a-4029-90f6-6d86144f614d", + "attachment_start" : "2019-03-25T07:00:00.000Z" + }, + { + "deployment_id" : "c90cc462-b040-4724-b814-e4fc3a081034", + "critter_id" : "52f96268-decf-4adf-bf75-36e096cfb45d", + "attachment_start" : "2019-02-08T08:00:00.000Z" + }, + { + "deployment_id" : "fded86c2-2e17-4624-b487-bd80fa4f7a26", + "critter_id" : "5f8aefad-0e1d-4b83-be7f-2f48e705c17a", + "attachment_start" : "2019-03-25T07:00:00.000Z" + }, + { + "deployment_id" : "cdbd90e5-b6b1-4e2b-b021-7c33d036a36a", + "critter_id" : "bab632be-13a4-4b53-9fcc-eddfa652235c", + "attachment_start" : "2019-01-11T08:00:00.000Z" + }, + { + "deployment_id" : "71d438c0-34a2-4285-9177-dd552b755e7d", + "critter_id" : "76a1b3cf-009f-4cbc-9d5a-1e7b5fef714c", + "attachment_start" : "2019-02-08T08:00:00.000Z" + }, + { + "deployment_id" : "ffd2f8ab-03d4-49be-aa7a-9723dcf9c5ac", + "critter_id" : "ff304aae-ec9d-40e9-8caf-6916c218caf8", + "attachment_start" : "2019-03-25T07:00:00.000Z" + }, + { + "deployment_id" : "77d662d7-8061-4c0d-b926-e0d930e218b2", + "critter_id" : "113ff924-8531-40ff-8ac4-0ba31b3e77cc", + "attachment_start" : "2018-03-29T07:00:00.000Z" + }, + { + "deployment_id" : "8ccd3886-4549-48ec-88a0-0fb40d6ea419", + "critter_id" : "6466c6ac-344c-44af-afdf-327201992a1f", + "attachment_start" : "2018-03-28T07:00:00.000Z" + }, + { + "deployment_id" : "ad11fc50-9119-4d6b-b9a7-1ed824740ee6", + "critter_id" : "b319cdd0-9e8a-4bd6-85f3-0f43f115a217", + "attachment_start" : "2018-03-13T07:00:00.000Z" + }, + { + "deployment_id" : "c4ce488e-8e33-44c8-94c3-5a2734a7946a", + "critter_id" : "71b89494-f1dc-461f-b2cb-6a01deaee93e", + "attachment_start" : "2018-03-07T08:00:00.000Z" + }, + { + "deployment_id" : "7f6083be-29e7-44db-9ca7-3424e20d9b72", + "critter_id" : "77392498-7e7b-4905-833f-c6d30dab4440", + "attachment_start" : "2019-03-15T07:00:00.000Z" + }, + { + "deployment_id" : "884e0236-2586-487c-893b-f73dcfe67bcc", + "critter_id" : "fe77d4ea-f764-40e6-b871-01b2a82cb926", + "attachment_start" : "2018-03-14T07:00:00.000Z" + }, + { + "deployment_id" : "807d5364-5b09-484c-a98a-6126c6c86a0e", + "critter_id" : "ad58b935-b28e-421f-8773-5b78b3cbba42", + "attachment_start" : "2018-03-04T08:00:00.000Z" + }, + { + "deployment_id" : "a1bbe6f1-0165-42fe-98db-c1e74ad0002f", + "critter_id" : "771f92f6-20dd-4222-8c5c-8276164429e1", + "attachment_start" : "2016-03-25T07:00:00.000Z" + }, + { + "deployment_id" : "029869ad-13ba-4046-878f-615d7ca75fb4", + "critter_id" : "394e39e5-c70f-45e9-8cf7-5fb5d3c2dc6c", + "attachment_start" : "2019-02-13T08:00:00.000Z" + }, + { + "deployment_id" : "67504678-1b3c-47a3-8da7-eda65c7925f3", + "critter_id" : "61f277ed-3b64-45ee-ac63-831cfc942f54", + "attachment_start" : "2018-03-03T08:00:00.000Z" + }, + { + "deployment_id" : "3be6fb8a-6e21-4e60-af64-7ecd4189a2eb", + "critter_id" : "1e34ab6e-6b1c-4f85-991a-e7b8c7169896", + "attachment_start" : "2018-03-07T08:00:00.000Z" + }, + { + "deployment_id" : "809c2e16-7a54-4640-b393-68f6eee0d69c", + "critter_id" : "e6988151-4506-4449-af18-2d2d065064e3", + "attachment_start" : "2016-03-07T08:00:00.000Z" + }, + { + "deployment_id" : "4596df16-c8ef-4b0c-a564-781953031c3e", + "critter_id" : "84423db9-85e4-4192-8fae-13d38951fbdf", + "attachment_start" : "2016-03-26T07:00:00.000Z" + }, + { + "deployment_id" : "24ee9cb0-ba13-43bc-b4e7-12f2863ea53e", + "critter_id" : "2bbaf794-a7c3-42a7-9637-922cb254c2f6", + "attachment_start" : "2014-12-08T08:00:00.000Z" + }, + { + "deployment_id" : "f6ef6512-6900-436b-a5d3-fe592c6515dd", + "critter_id" : "84a443de-9352-4747-a461-55e7df03fa0d", + "attachment_start" : "2014-12-11T08:00:00.000Z" + }, + { + "deployment_id" : "22b1a81b-6be0-4704-8ced-6473852aa74d", + "critter_id" : "1720076c-b1df-4dba-99f0-f35f80e37efb", + "attachment_start" : "2015-02-27T08:00:00.000Z" + }, + { + "deployment_id" : "5f22f661-593f-4435-9cae-6f7eaea4d2a7", + "critter_id" : "e7b2c01c-2e18-4203-89ac-aa2226703c1a", + "attachment_start" : "2019-02-17T08:00:00.000Z" + }, + { + "deployment_id" : "802a6018-5236-453e-8dd0-a793fc3bae7a", + "critter_id" : "a04daefb-720e-409a-93ad-bf4529642a12", + "attachment_start" : "2016-03-27T07:00:00.000Z" + }, + { + "deployment_id" : "c94e8930-6d77-4422-8c60-795efb38d00d", + "critter_id" : "6153847f-3149-4e19-a612-fbe2622fbb59", + "attachment_start" : "2017-02-02T08:00:00.000Z" + }, + { + "deployment_id" : "004c4dc1-c770-4ae8-8eb3-48422f386e52", + "critter_id" : "7bbf000b-040a-4cff-b277-204693742e25", + "attachment_start" : "2014-12-12T08:00:00.000Z" + }, + { + "deployment_id" : "f24b9596-d2e8-4379-a358-15854e982592", + "critter_id" : "439f78f7-beee-455c-ae1a-705fdd49ae42", + "attachment_start" : "2016-03-26T07:00:00.000Z" + }, + { + "deployment_id" : "7e7f447f-45a5-4611-8ce6-4964542a582c", + "critter_id" : "5a9ca959-897f-448c-9056-7c09463d61e7", + "attachment_start" : "2018-03-07T08:00:00.000Z" + }, + { + "deployment_id" : "284e9ddc-4dfb-4c30-a342-40af8dbcbba0", + "critter_id" : "d080a47d-b283-4e2f-8f8c-46341de3b5bd", + "attachment_start" : "2016-03-26T07:00:00.000Z" + }, + { + "deployment_id" : "85993e1e-d1b5-40cb-890b-7664b4373dbd", + "critter_id" : "e499cf9f-0bb6-41cf-9798-7bd12753d2f9", + "attachment_start" : "2019-03-15T07:00:00.000Z" + }, + { + "deployment_id" : "975a3990-c752-4eae-b234-ba49357a22b5", + "critter_id" : "dc05f0fc-8969-42e3-ba55-5f5122ca2b87", + "attachment_start" : "2019-03-15T07:00:00.000Z" + }, + { + "deployment_id" : "cb550cde-288a-403c-a2a7-497bd4825cbc", + "critter_id" : "4a898e61-f1e5-403c-ac06-ccd09387737c", + "attachment_start" : "2019-02-17T08:00:00.000Z" + }, + { + "deployment_id" : "92deb2c3-660d-4d49-9b62-bbd21d884886", + "critter_id" : "b3d42b58-f46e-4ec3-93e3-fa361d996a61", + "attachment_start" : "2019-02-17T08:00:00.000Z" + }, + { + "deployment_id" : "60f4c2e9-83f2-4193-ad47-de2a176cfda0", + "critter_id" : "eaec491d-04cd-436d-8281-64d80d56af6a", + "attachment_start" : "2019-02-11T08:00:00.000Z" + }, + { + "deployment_id" : "6ad78ed4-eb56-4e55-9902-eae123fb8f5d", + "critter_id" : "508cdb6f-0337-43ae-86ca-d6b99eac1ffe", + "attachment_start" : "2019-02-11T08:00:00.000Z" + }, + { + "deployment_id" : "ae5d6d61-7229-4d5a-b199-9c888bb22354", + "critter_id" : "846a83fe-c303-4ba9-a8cb-a31789b0385a", + "attachment_start" : "2019-02-16T08:00:00.000Z" + }, + { + "deployment_id" : "e3d4a466-e7e1-4013-9c67-23dba53ded90", + "critter_id" : "01512abf-9bde-4bb8-8555-8c6c239ae26c", + "attachment_start" : "2019-02-09T08:00:00.000Z" + }, + { + "deployment_id" : "a887f994-a72c-47cf-9c47-62e2c790a3cb", + "critter_id" : "66939a5d-452d-47c4-9dc2-408b057c36ae", + "attachment_start" : "2019-02-09T08:00:00.000Z" + }, + { + "deployment_id" : "518dbeb6-4dd5-428e-a8a3-d99c4f051ee3", + "critter_id" : "912381cf-14db-44eb-82e7-14244988662c", + "attachment_start" : "2019-02-10T08:00:00.000Z" + }, + { + "deployment_id" : "8cd8c805-7e1c-423c-adcb-0759a9979695", + "critter_id" : "0f20803d-1e9e-4b0a-ae81-1b24c8b62da0", + "attachment_start" : "2019-02-10T08:00:00.000Z" + }, + { + "deployment_id" : "e1dffcc6-c73d-438d-8e89-930cba6b5328", + "critter_id" : "c5f83fe2-b318-4755-9f83-aaf2a972363c", + "attachment_start" : "2019-02-07T08:00:00.000Z" + }, + { + "deployment_id" : "ced9b413-339c-4366-8dab-942d32114bb0", + "critter_id" : "9a859175-626e-4e82-b6cc-85107f7de5f8", + "attachment_start" : "2019-02-13T08:00:00.000Z" + }, + { + "deployment_id" : "2a389cef-feca-4ead-b734-9cecd619b288", + "critter_id" : "af5f78be-06f5-486f-afb8-534aab33dd91", + "attachment_start" : "2019-02-13T08:00:00.000Z" + }, + { + "deployment_id" : "aa9cc419-95f0-4d11-be43-c0cc0e5f4c14", + "critter_id" : "22e485c3-538a-41db-9cdf-468f9dbe6087", + "attachment_start" : "2019-02-14T08:00:00.000Z" + }, + { + "deployment_id" : "17a9e6f7-c5af-412f-8e2d-26d289e7dfa8", + "critter_id" : "16c44e15-225d-419a-980f-c300d3c733ca", + "attachment_start" : "2019-02-14T08:00:00.000Z" + }, + { + "deployment_id" : "fb28ea53-fe2d-48fc-b953-f1be5aa3171d", + "critter_id" : "2ad31b07-ce56-4ddf-87f1-da389a74987c", + "attachment_start" : "2019-02-16T08:00:00.000Z" + }, + { + "deployment_id" : "745a32d4-8e97-4771-8d6b-ee95d6d8107d", + "critter_id" : "fb3b7d3a-b05b-4c24-8be1-758a985ede90", + "attachment_start" : "2019-02-10T08:00:00.000Z" + }, + { + "deployment_id" : "63ae3020-16fd-497a-aa4d-c7ae2133922c", + "critter_id" : "3a1fe496-5abd-46af-8f72-5f70ebe10ef0", + "attachment_start" : "2019-02-12T08:00:00.000Z" + }, + { + "deployment_id" : "09681f9d-438b-45aa-8462-94224c916464", + "critter_id" : "a10c12cc-1d36-4620-93aa-0576240463b5", + "attachment_start" : "2019-02-12T08:00:00.000Z" + }, + { + "deployment_id" : "5b9cb20b-eb31-4a99-a857-2578a4dc81e0", + "critter_id" : "584ed64f-0f7d-474e-ba10-173c5a2307d5", + "attachment_start" : "2019-02-11T08:00:00.000Z" + }, + { + "deployment_id" : "b020a4dc-10d7-45a8-83ed-2bb211291aab", + "critter_id" : "4a7710ba-0030-4ead-8f8b-5c116090645b", + "attachment_start" : "2019-03-15T07:00:00.000Z" + }, + { + "deployment_id" : "c12b9e0e-58f5-4cb6-bcb6-502ca66b6cde", + "critter_id" : "175385ee-3a15-4d3f-9c73-b7402747cf43", + "attachment_start" : "2019-03-15T07:00:00.000Z" + }, + { + "deployment_id" : "e1cf723d-9e1b-451c-beed-87f496065377", + "critter_id" : "5a3bc37c-e17b-46e1-8b99-ba789304855a", + "attachment_start" : "2019-03-15T07:00:00.000Z" + }, + { + "deployment_id" : "094382c4-16d0-4dd3-b1d0-3fa223a6a78d", + "critter_id" : "9181539d-86b0-4725-9111-b2f5dd6eabc6", + "attachment_start" : "2019-03-14T07:00:00.000Z" + }, + { + "deployment_id" : "d2235e1c-72ac-418e-8db7-d89bb0190512", + "critter_id" : "29b62f63-64c6-41e2-80b2-d0c0e37ffcb5", + "attachment_start" : "2019-03-15T07:00:00.000Z" + }, + { + "deployment_id" : "707df196-933c-49d1-8009-0b0389d524de", + "critter_id" : "d7766409-310c-4aee-a7cf-5b53d6aef1cb", + "attachment_start" : "2019-03-14T07:00:00.000Z" + }, + { + "deployment_id" : "da82f63b-0540-4c8c-bd0c-4a1bf74465c4", + "critter_id" : "21dd6709-42b9-49f9-b3d3-cd30e7f0af97", + "attachment_start" : "2019-03-15T07:00:00.000Z" + }, + { + "deployment_id" : "27ab320f-9483-45ea-9550-c068b619400c", + "critter_id" : "ee868979-d24a-44b4-b46b-776a0095dcbc", + "attachment_start" : "2019-03-15T07:00:00.000Z" + }, + { + "deployment_id" : "35910ac8-3b71-4fa8-bc0d-208c0c792af4", + "critter_id" : "e5e66845-60b6-4465-8f2d-4b326089fda1", + "attachment_start" : "2019-03-15T07:00:00.000Z" + }, + { + "deployment_id" : "e47c9951-0e4d-400a-bcf6-0eb204298df1", + "critter_id" : "ffa5d033-19df-4ac9-a0a4-2ce5c58d4cb7", + "attachment_start" : "2019-03-14T07:00:00.000Z" + }, + { + "deployment_id" : "cd23304b-a6a9-46ef-9e4e-a7376492eeef", + "critter_id" : "74cac65f-6aea-42e6-9572-ad6b9bc6c7e1", + "attachment_start" : "2019-03-17T07:00:00.000Z" + }, + { + "deployment_id" : "31074be5-865e-431c-aebb-5b8990d5055d", + "critter_id" : "192a6a5e-57ce-4e31-8bd2-b2c0e8fe4e9d", + "attachment_start" : "2019-03-26T07:00:00.000Z" + }, + { + "deployment_id" : "8ee1d076-9961-427a-ade0-50051f26c134", + "critter_id" : "e87ca2c3-cc84-44c4-84ec-840282e1e1e5", + "attachment_start" : "2019-03-26T07:00:00.000Z" + }, + { + "deployment_id" : "ad082c3e-3344-4362-bdb6-454d4faf8806", + "critter_id" : "237ae994-9847-4aed-bb56-7586df647cdc", + "attachment_start" : "2019-03-17T07:00:00.000Z" + }, + { + "deployment_id" : "b8d5747d-344d-4b5e-9edc-6f384df7243b", + "critter_id" : "5973fd0c-53bf-4009-b30c-1693a57a4c12", + "attachment_start" : "2019-03-25T07:00:00.000Z" + }, + { + "deployment_id" : "7eb7a2fb-cd3f-4cec-93ba-4957fef9a4da", + "critter_id" : "eb70045c-1f45-4689-9de6-a8d1b3555775", + "attachment_start" : "2019-03-17T07:00:00.000Z" + }, + { + "deployment_id" : "7061255c-bb2d-40c0-8af9-d62c8ff0068b", + "critter_id" : "80b953b2-27b9-484b-9470-99c40ba79854", + "attachment_start" : "2019-03-17T07:00:00.000Z" + }, + { + "deployment_id" : "80c5bc6b-69df-42b2-8251-b9a3464ac57c", + "critter_id" : "c800a5df-28ce-4b18-9e89-d0ddf06b9f36", + "attachment_start" : "2019-03-26T07:00:00.000Z" + }, + { + "deployment_id" : "3ae91b66-6cf7-4deb-9ac9-ceaadab4831f", + "critter_id" : "8138b3e4-520a-4da0-b19c-fda637964ed9", + "attachment_start" : "2019-03-26T07:00:00.000Z" + }, + { + "deployment_id" : "21ccd12c-e622-4ad1-8c2c-463c5bb049f0", + "critter_id" : "74668be4-adbc-4c2a-af2f-2715ee9229f3", + "attachment_start" : "2019-03-27T07:00:00.000Z" + }, + { + "deployment_id" : "e7a66be0-4ca3-4d6b-a183-2f5b7f5c5f8f", + "critter_id" : "a973e643-3784-4d79-9e49-e0f06bb10025", + "attachment_start" : "2019-03-26T07:00:00.000Z" + }, + { + "deployment_id" : "8d24cc22-c6ba-4a54-8f8f-3ce58590be27", + "critter_id" : "c0f627ac-32f9-4728-be5b-665bc2ae7933", + "attachment_start" : "2020-01-26T08:00:00.000Z" + }, + { + "deployment_id" : "76a693ed-89b6-4c2a-b85a-3b60e5665a4a", + "critter_id" : "b30952d0-414f-4883-88a6-97d772551915", + "attachment_start" : "2019-03-17T07:00:00.000Z" + }, + { + "deployment_id" : "a4336949-34c4-4870-a5cd-0847b857e6e7", + "critter_id" : "2f98a110-1037-4af8-a403-d8141da7fa10", + "attachment_start" : "2019-03-17T07:00:00.000Z" + }, + { + "deployment_id" : "008c5fba-524a-4c4a-9534-ab1875dc922b", + "critter_id" : "9eb8cb81-5ce9-47e0-a168-adb8e96d3427", + "attachment_start" : "2019-03-26T07:00:00.000Z" + }, + { + "deployment_id" : "0168b1fb-160c-4056-b0ae-cc15a3de7e18", + "critter_id" : "e565048a-d57f-4d56-b106-27d8a7507db0", + "attachment_start" : "2020-01-25T08:00:00.000Z" + }, + { + "deployment_id" : "b2624f89-3892-473e-bc32-2c9aee840efc", + "critter_id" : "edefa918-ef04-408d-8067-eddd7a8f155d", + "attachment_start" : "2019-03-12T07:00:00.000Z" + }, + { + "deployment_id" : "97e763c0-c854-40e3-a44c-f24d564b8b7d", + "critter_id" : "c876f18f-b9b2-4ae9-91f3-ba348e67e394", + "attachment_start" : "2019-03-12T07:00:00.000Z" + }, + { + "deployment_id" : "bcd6a2e0-dce4-4767-87ac-f2d86c99d216", + "critter_id" : "919ac7b4-f54f-4f10-b355-b5bc939f900e", + "attachment_start" : "2019-03-12T07:00:00.000Z" + }, + { + "deployment_id" : "68cfdd82-fed3-4f1a-911a-8cc95def15bd", + "critter_id" : "e4877c55-0017-4f94-8d19-bb2dd8a24fe4", + "attachment_start" : "2019-03-12T07:00:00.000Z" + }, + { + "deployment_id" : "fc3ee874-1742-4680-a184-5b883920d52e", + "critter_id" : "9f44b127-36d2-4202-a8dc-6a83d947026e", + "attachment_start" : "2019-03-25T07:00:00.000Z" + }, + { + "deployment_id" : "0c9e1301-46d7-4a1f-bae5-f0aa1e3909ca", + "critter_id" : "1b0a6884-f29a-44b4-9778-2ad119bcd62f", + "attachment_start" : "2019-03-24T07:00:00.000Z" + }, + { + "deployment_id" : "6b4dc3f6-9703-43b9-8d7c-46d77782eb4b", + "critter_id" : "ddc4ffff-e67b-4972-b9fa-3ea8ca5a56af", + "attachment_start" : "2019-03-12T07:00:00.000Z" + }, + { + "deployment_id" : "226b0129-9fcc-4422-be31-978080167124", + "critter_id" : "11b78296-8cec-44fb-a45f-83d886239108", + "attachment_start" : "2019-03-12T07:00:00.000Z" + }, + { + "deployment_id" : "8b622295-96bd-4a57-a64e-a2dc6b75c1ed", + "critter_id" : "52c20aa3-928e-4cac-954b-d270eba0defb", + "attachment_start" : "2019-03-12T07:00:00.000Z" + }, + { + "deployment_id" : "7d82547a-5a39-47a3-a695-976b4d45fa75", + "critter_id" : "879fdb4d-c5d1-4904-a963-e76402ee2ebe", + "attachment_start" : "2019-03-12T07:00:00.000Z" + }, + { + "deployment_id" : "d1112f59-07e2-4c9f-89eb-494f7fbfd8fc", + "critter_id" : "180e6def-15b4-4b6c-b44a-c435b1d0df65", + "attachment_start" : "2019-03-12T07:00:00.000Z" + }, + { + "deployment_id" : "9c121170-3906-4aa0-aa59-9a685046a696", + "critter_id" : "21ee8995-8c81-4407-baa7-19e4ec845776", + "attachment_start" : "2019-03-13T07:00:00.000Z" + }, + { + "deployment_id" : "ffe66898-f26f-423a-9734-362a6f5d00ca", + "critter_id" : "f8135742-c6dc-4298-b43d-7bdb46b320de", + "attachment_start" : "2019-03-13T07:00:00.000Z" + }, + { + "deployment_id" : "3cb80424-f069-4718-9f0a-92bcdf7ad74c", + "critter_id" : "42a4050c-377f-4353-ae80-a6a9d580a650", + "attachment_start" : "2019-03-28T07:00:00.000Z" + }, + { + "deployment_id" : "8f177e37-9d8c-4b46-a330-00ff72ccf990", + "critter_id" : "802cd06d-2cf6-441c-9814-52753ec47b64", + "attachment_start" : "2019-03-28T07:00:00.000Z" + }, + { + "deployment_id" : "77707f24-7cdf-4c0b-a6dd-b7c484bdb949", + "critter_id" : "8341bb22-0fd3-4416-a4f9-067fb98ce642", + "attachment_start" : "2019-03-28T07:00:00.000Z" + }, + { + "deployment_id" : "accf31fb-bd74-4f31-bd10-572363e54b86", + "critter_id" : "a7484747-ed09-4503-bc7c-aab9c460699d", + "attachment_start" : "2019-02-17T08:00:00.000Z" + }, + { + "deployment_id" : "ec975c05-bd16-4f15-84ea-8814cf525827", + "critter_id" : "1ce4417d-23d4-4fa8-9a92-8d01f94654e9", + "attachment_start" : "2020-03-07T08:00:00.000Z" + }, + { + "deployment_id" : "357ea2e1-b542-4ec6-b549-c2f5699b80a5", + "critter_id" : "77e87ae6-4ff7-407c-8799-8630ff2c5744", + "attachment_start" : "2020-01-09T08:00:00.000Z" + }, + { + "deployment_id" : "9d3acdd0-41a0-4240-a233-e0ac6f80b925", + "critter_id" : "93774173-6561-4227-a6b5-9c63a7f19fa7", + "attachment_start" : "2020-01-23T08:00:00.000Z" + }, + { + "deployment_id" : "0d0ec4fd-9d81-4d2f-b14e-61226976401b", + "critter_id" : "d6ceb03f-8d42-46c0-9157-214347cc8d5a", + "attachment_start" : "2020-01-12T08:00:00.000Z" + }, + { + "deployment_id" : "5166789a-dfe3-4b71-98f5-8bb7abf0ef2f", + "critter_id" : "c7ce97a3-6653-45e1-b1bb-1773c29c6563", + "attachment_start" : "2020-03-07T08:00:00.000Z" + }, + { + "deployment_id" : "87c00d6f-cacf-43d0-989d-64177604eabd", + "critter_id" : "271db0dd-9ba5-4f1c-9f72-5c8478255300", + "attachment_start" : "2020-01-11T08:00:00.000Z" + }, + { + "deployment_id" : "e47c8f62-2a4d-4e62-ae9e-7dc29c0c5cd4", + "critter_id" : "4364ebce-2685-4cd6-a874-4a648d3113ad", + "attachment_start" : "2020-01-11T08:00:00.000Z" + }, + { + "deployment_id" : "79f2a103-4ac0-462d-aa8a-5e4e88796dea", + "critter_id" : "13f70a98-b634-47fe-8555-63bb9456fe55", + "attachment_start" : "2020-01-22T08:00:00.000Z" + }, + { + "deployment_id" : "d4dc2640-9f9b-456f-a689-316c310cd585", + "critter_id" : "84350c18-f56b-4d06-a32e-11f0303f4174", + "attachment_start" : "2020-03-07T08:00:00.000Z" + }, + { + "deployment_id" : "476bab55-c89f-4349-89da-3a657f1f9612", + "critter_id" : "0700428a-bb65-4228-9cf1-11c6947ab192", + "attachment_start" : "2020-01-24T08:00:00.000Z" + }, + { + "deployment_id" : "ace9b519-5beb-46ab-a01a-7a2c2982d37c", + "critter_id" : "0ecd8e8e-ce58-42eb-b789-2438c1ba6583", + "attachment_start" : "2020-03-08T08:00:00.000Z" + }, + { + "deployment_id" : "dcb23a26-cffc-4f09-af52-e7584edce6c6", + "critter_id" : "0cdd1d13-f1d8-4dcc-99bf-03e524abbde2", + "attachment_start" : "2020-03-07T08:00:00.000Z" + }, + { + "deployment_id" : "c6d0d3ae-7748-4e40-a98d-32589f147323", + "critter_id" : "f66c1c5c-a80b-430e-98f3-3a617aa86e81", + "attachment_start" : "2020-01-12T08:00:00.000Z" + }, + { + "deployment_id" : "5425b42e-7573-4efd-bc87-fff47fdfab17", + "critter_id" : "1e9d2e66-6971-403a-b73d-1ce7411c4a5c", + "attachment_start" : "2020-01-13T08:00:00.000Z" + }, + { + "deployment_id" : "4cbb9812-280d-4dda-9402-582d00a7b130", + "critter_id" : "49fdd16e-04b6-47d1-943c-c8a442010220", + "attachment_start" : "2020-01-10T08:00:00.000Z" + }, + { + "deployment_id" : "7fd4a986-1f81-46b7-851e-bf6bb370085c", + "critter_id" : "57346679-bc84-4acc-a166-fb9900163b0b", + "attachment_start" : "2020-03-08T08:00:00.000Z" + }, + { + "deployment_id" : "f4747c7e-a974-477f-b5ad-31b9de9730d8", + "critter_id" : "f0767485-9e49-49b6-9347-b9d063308374", + "attachment_start" : "2020-01-09T08:00:00.000Z" + }, + { + "deployment_id" : "13d180d5-ec6b-458c-8041-7671b5a6c2ee", + "critter_id" : "bd5a4b24-42b0-4bcb-bfc9-5ff5c3bfbbb7", + "attachment_start" : "2020-01-12T08:00:00.000Z" + }, + { + "deployment_id" : "c355c34f-c29c-4f19-9042-baf237192b1e", + "critter_id" : "6cbaf159-36df-4533-a961-e23526fb68a6", + "attachment_start" : "2020-01-10T08:00:00.000Z" + }, + { + "deployment_id" : "72c90603-e335-49c1-915f-26d3ea7566fb", + "critter_id" : "92b64a65-f3a6-4af6-9c8c-284bf7d9613f", + "attachment_start" : "2020-01-25T08:00:00.000Z" + }, + { + "deployment_id" : "c6b5386e-e7d1-4719-a1c7-ddbe0a121a6a", + "critter_id" : "74835dae-fcd1-483c-82e1-4a01f385c7a0", + "attachment_start" : "2020-01-10T08:00:00.000Z" + }, + { + "deployment_id" : "91918753-8d9a-482f-883e-3218798e7268", + "critter_id" : "e4947a85-61c4-4b3e-9aed-b78f48931bd1", + "attachment_start" : "2020-01-25T08:00:00.000Z" + }, + { + "deployment_id" : "68c53a32-21ae-4377-a855-88d2f262d07e", + "critter_id" : "1c612b46-7475-4ed1-92ab-7df45c533659", + "attachment_start" : "2020-01-11T08:00:00.000Z" + }, + { + "deployment_id" : "15dd62a4-e2fe-4a7a-a36d-9eb7a43f092c", + "critter_id" : "c17bbaf4-e828-48f7-8e6b-98a9ed73f955", + "attachment_start" : "2020-03-07T08:00:00.000Z" + }, + { + "deployment_id" : "ac607e3c-67b7-4174-a19b-09c0cf73e494", + "critter_id" : "9031d904-45f2-4a0c-b8ee-693577b12b8c", + "attachment_start" : "2020-01-10T08:00:00.000Z" + }, + { + "deployment_id" : "8a10200e-950b-402c-af2d-e5974b33d527", + "critter_id" : "3b974d05-87d2-4300-9596-1d41de5a5b25", + "attachment_start" : "2020-01-24T08:00:00.000Z" + }, + { + "deployment_id" : "46ea842b-f2e1-4fdc-b00d-e2e5bbc37665", + "critter_id" : "50187ddf-49fb-4357-9bd6-f4f2a00d94f0", + "attachment_start" : "2020-01-21T08:00:00.000Z" + }, + { + "deployment_id" : "10ad053b-7c4c-4013-a9b8-891608208e8e", + "critter_id" : "c47ba7f1-e216-43b0-8827-5579917b740c", + "attachment_start" : "2020-01-21T08:00:00.000Z" + }, + { + "deployment_id" : "28bcedf4-bbaf-4758-a199-79a43076990b", + "critter_id" : "031871d5-a89d-429d-b317-ad77e045cd2a", + "attachment_start" : "2020-01-21T08:00:00.000Z" + }, + { + "deployment_id" : "27512b8b-733f-4961-9d57-b5081bced5c5", + "critter_id" : "2dbe1467-1a85-4457-981b-6c092f9a69c5", + "attachment_start" : "2020-01-21T08:00:00.000Z" + }, + { + "deployment_id" : "b337de66-d10f-4b82-b00e-6cc190e633f9", + "critter_id" : "b0a120d1-592d-4715-bfd2-c05e39c02f43", + "attachment_start" : "2020-03-06T08:00:00.000Z" + }, + { + "deployment_id" : "db8858fd-07d0-4d8a-a634-2dbe3ae6ff68", + "critter_id" : "f27f2fd6-2aee-4658-ac50-1a30f8c4550f", + "attachment_start" : "2020-03-06T08:00:00.000Z" + }, + { + "deployment_id" : "f110b8f3-18d2-4ec2-bde6-2b07408c6399", + "critter_id" : "959edf67-4bc9-4fd8-b919-bc16fa8aab95", + "attachment_start" : "2020-03-05T08:00:00.000Z" + }, + { + "deployment_id" : "74139aad-e237-4e8e-b576-eada198fb207", + "critter_id" : "b298fa77-ba02-4699-9443-14d65efa69e6", + "attachment_start" : "2020-03-06T08:00:00.000Z" + }, + { + "deployment_id" : "9c6b3730-f635-49f4-8c3c-1d5be2043c13", + "critter_id" : "2411c9ed-7912-48e1-9132-35e02fab9a90", + "attachment_start" : "2020-03-05T08:00:00.000Z" + }, + { + "deployment_id" : "cc7d8c0b-89bd-4ed7-939c-53d94db4211c", + "critter_id" : "557c16d1-d328-43e9-90a7-22cd3efb4483", + "attachment_start" : "2020-03-06T08:00:00.000Z" + }, + { + "deployment_id" : "e719af0c-f572-4a62-b8cd-10bd9443c4cb", + "critter_id" : "015c9e15-efda-498a-ad7f-78f653001b15", + "attachment_start" : "2020-01-28T08:00:00.000Z" + }, + { + "deployment_id" : "9f801a4f-36db-4e51-bb58-fbff68b18dfd", + "critter_id" : "27255051-4005-4c5a-a873-36f652d37d3a", + "attachment_start" : "2020-01-25T08:00:00.000Z" + }, + { + "deployment_id" : "51d9b873-4911-4a04-a260-8f84860f10cf", + "critter_id" : "950508d2-a63b-4289-a3bc-84bed3e9803c", + "attachment_start" : "2020-01-25T08:00:00.000Z" + }, + { + "deployment_id" : "52e782ca-696d-433d-b67f-6fd89364be8f", + "critter_id" : "aa3b18c9-6a86-4861-9392-41e64e7f9b7a", + "attachment_start" : "2020-01-25T08:00:00.000Z" + }, + { + "deployment_id" : "ed2570f3-60e7-42f9-9a77-3d9abc402979", + "critter_id" : "2045525b-d037-4ffb-9eca-f2249a301f87", + "attachment_start" : "2020-01-26T08:00:00.000Z" + }, + { + "deployment_id" : "b87e1f70-0f34-42e1-b7c6-da5cdfbf31c7", + "critter_id" : "53b844c8-cc56-40ff-9cf5-0afe6cd4d2c9", + "attachment_start" : "2020-01-26T08:00:00.000Z" + }, + { + "deployment_id" : "5c5ac7e2-35cf-42d6-ad64-e8360c9e86ef", + "critter_id" : "72c51c33-1570-4062-91de-301704daab9b", + "attachment_start" : "2020-01-25T08:00:00.000Z" + }, + { + "deployment_id" : "579f2888-c8c4-4bd7-9109-d957675f384b", + "critter_id" : "e847e035-70c9-4031-bcab-7a84e8a05f01", + "attachment_start" : "2020-01-26T08:00:00.000Z" + }, + { + "deployment_id" : "36553773-2ca7-4bcc-97f0-3bd5d3901916", + "critter_id" : "0fc474ed-017d-49da-ac15-683728f9815b", + "attachment_start" : "2020-01-24T08:00:00.000Z" + }, + { + "deployment_id" : "e7827fbe-7ff9-4efe-b979-25ee6737611f", + "critter_id" : "59b02d56-8aeb-4cf2-b809-9c7bc979b8fc", + "attachment_start" : "2020-01-22T08:00:00.000Z" + }, + { + "deployment_id" : "7ed35a0d-4485-4b15-9a96-0a3b07ff77db", + "critter_id" : "fb916524-3d8d-4fcd-8c00-a32862fd7b8f", + "attachment_start" : "2020-01-23T08:00:00.000Z" + }, + { + "deployment_id" : "377807f9-2712-49c1-bed3-b97f6bb9d329", + "critter_id" : "3bb8ef0d-a325-4cb9-87b6-0ac41a50a75c", + "attachment_start" : "2020-01-22T08:00:00.000Z" + }, + { + "deployment_id" : "a4b6ee38-2136-4a1f-9012-e8e8a02d6c70", + "critter_id" : "f9553855-5117-4653-be30-d8bec86c8f2d", + "attachment_start" : "2020-01-24T08:00:00.000Z" + }, + { + "deployment_id" : "3a9cb873-249b-4c28-99ac-0463e4218865", + "critter_id" : "a1c9267b-2277-4a13-8723-e6a6f9d6894d", + "attachment_start" : "2020-01-24T08:00:00.000Z" + }, + { + "deployment_id" : "50f6578a-5a72-4fce-8dda-ebfa20be13db", + "critter_id" : "45bed7bc-cdd1-4325-9eb8-899af1d8db32", + "attachment_start" : "2020-01-23T08:00:00.000Z" + }, + { + "deployment_id" : "3c38c4bc-778b-4d5e-817a-0fe612df97ee", + "critter_id" : "be896f3d-8366-4fe5-a1f1-34c38f142d76", + "attachment_start" : "2020-01-23T08:00:00.000Z" + }, + { + "deployment_id" : "e03faf01-4b21-4212-a6a2-38102ba8cf93", + "critter_id" : "809acdc8-7360-4c96-bbde-6c87b55c9eec", + "attachment_start" : "2020-01-23T08:00:00.000Z" + }, + { + "deployment_id" : "13bfd1b0-bd55-41b8-a53d-9effc955c851", + "critter_id" : "f09708d8-fca8-4776-b236-b234ea998327", + "attachment_start" : "2020-01-22T08:00:00.000Z" + }, + { + "deployment_id" : "155abdfe-0e9b-4a25-bebe-11fb6aa6b9a4", + "critter_id" : "3bae4e90-8da2-4c35-823a-52ae3dc90696", + "attachment_start" : "2020-01-23T08:00:00.000Z" + }, + { + "deployment_id" : "504753d0-a9fb-4295-a611-561c6b49a179", + "critter_id" : "382e24f1-fa5e-484c-a9b3-d3ef76878ca2", + "attachment_start" : "2020-01-22T08:00:00.000Z" + }, + { + "deployment_id" : "78a27723-cc44-4a08-8923-afc79b375054", + "critter_id" : "eba12957-be44-4a3f-bdef-80a8555eb936", + "attachment_start" : "2021-02-14T08:00:00.000Z" + }, + { + "deployment_id" : "7f7d5441-b74e-4d1a-b846-39e5fd0e2529", + "critter_id" : "55c4b2ad-979b-46c6-8000-b3a425faf45d", + "attachment_start" : "2021-02-19T08:00:00.000Z" + }, + { + "deployment_id" : "61b42369-cbc8-4879-8f84-80f7a8c47b6f", + "critter_id" : "21f0c42a-cc9a-431a-8843-69f068d1a843", + "attachment_start" : "2021-02-15T08:00:00.000Z" + }, + { + "deployment_id" : "43087e8e-1f94-4fe4-b104-5da8d0a6586d", + "critter_id" : "2ec6573b-2fe5-45dc-a9e5-68fca2c9de86", + "attachment_start" : "2021-02-16T08:00:00.000Z" + }, + { + "deployment_id" : "6cab7e9f-9dc3-4e4e-8eef-f502843ff4df", + "critter_id" : "f6aee215-cff4-4b8b-8c04-1fa917d280a9", + "attachment_start" : "2021-02-16T08:00:00.000Z" + }, + { + "deployment_id" : "d9d965c1-ded5-4fea-9be3-5fc51b5a1f09", + "critter_id" : "35e72170-04e8-409e-9f29-a9e7dec356bb", + "attachment_start" : "2021-02-16T08:00:00.000Z" + }, + { + "deployment_id" : "7b124f73-a43b-4425-9336-25ffb484fa43", + "critter_id" : "af80e527-0f76-4521-9d94-78c7be24ecc2", + "attachment_start" : "2021-02-15T08:00:00.000Z" + }, + { + "deployment_id" : "99901e43-c521-404b-93f3-0a5d248c8907", + "critter_id" : "953816ee-6550-41d0-85c5-7b2a1d208eac", + "attachment_start" : "2021-02-13T08:00:00.000Z" + }, + { + "deployment_id" : "04d9d019-16ba-42dd-a8c3-a542f438007d", + "critter_id" : "046eb603-0605-468c-ab27-2c99a1dd8d36", + "attachment_start" : "2021-02-12T08:00:00.000Z" + }, + { + "deployment_id" : "895cc7af-0d62-4ab7-b82b-8c4de6648df9", + "critter_id" : "7a286780-fa9c-4425-9b75-66ed7c3336a2", + "attachment_start" : "2021-02-11T08:00:00.000Z" + }, + { + "deployment_id" : "ceda6941-e117-44ca-9ad0-863425038183", + "critter_id" : "29a08d5e-169d-4683-a985-00a7a1d0a3b7", + "attachment_start" : "2021-02-15T08:00:00.000Z" + }, + { + "deployment_id" : "c6c3b15b-ec8b-4bb8-ae09-fbad56562fb2", + "critter_id" : "87431e06-881a-4bf6-9a67-c205d6c697b6", + "attachment_start" : "2021-02-15T08:00:00.000Z" + }, + { + "deployment_id" : "ae86d2eb-6fe5-48f0-9eae-eda033d8383a", + "critter_id" : "2777a1a0-b881-494c-a024-d9b0b3a36d5f", + "attachment_start" : "2021-02-15T08:00:00.000Z" + }, + { + "deployment_id" : "2ac10d69-db7c-463b-b6e0-3e668eea3cd0", + "critter_id" : "1e814578-5090-422f-bec3-0024be75a463", + "attachment_start" : "2012-02-27T08:00:00.000Z" + }, + { + "deployment_id" : "ee4de144-dcd6-417a-8d3a-b73bf7c8c4c1", + "critter_id" : "cfd4d868-ca8b-4710-ba8f-816c6b89efaa", + "attachment_start" : "2015-02-03T08:00:00.000Z" + }, + { + "deployment_id" : "466c4932-7afa-43be-8a9c-e7cb8a9c3d03", + "critter_id" : "ea7b8e07-293a-4ef8-bd81-94e9c1261736", + "attachment_start" : "2015-02-02T08:00:00.000Z" + }, + { + "deployment_id" : "990b5f7e-2a27-4cba-9f92-be1d4acca2fe", + "critter_id" : "4e82482c-09e0-467b-884a-dfe26cb6769f", + "attachment_start" : "2015-04-03T07:00:00.000Z" + }, + { + "deployment_id" : "02b15a06-c1e8-4373-bde3-9114dad8adf9", + "critter_id" : "7878c248-d3d4-4df8-9b4a-ac97391af06e", + "attachment_start" : "2015-02-03T08:00:00.000Z" + }, + { + "deployment_id" : "fc912176-f0c8-4184-bca1-952e68b9b4ea", + "critter_id" : "fd0fb3b7-d27d-4979-bf4e-b00a1e2860bc", + "attachment_start" : "2021-01-26T08:00:00.000Z" + }, + { + "deployment_id" : "b1bba8d7-f151-4ed5-83ab-83d240a33ad5", + "critter_id" : "416097d9-d960-42dc-808c-6ad4380d3b5c", + "attachment_start" : "2021-02-23T08:00:00.000Z" + }, + { + "deployment_id" : "b168d293-b71c-491f-94b9-23f23ee81d89", + "critter_id" : "fa6d3fdd-5810-4ce4-ae94-5adda5a23ea5", + "attachment_start" : "2021-02-15T08:00:00.000Z" + }, + { + "deployment_id" : "7fd99352-a40b-4dc1-96b1-0df973f833c5", + "critter_id" : "f685cb33-b850-456a-a213-6559929c74b9", + "attachment_start" : "2021-02-11T08:00:00.000Z" + }, + { + "deployment_id" : "b8ac3aaf-51ce-466a-a606-fe684e0bc609", + "critter_id" : "4c4f8499-c539-4ce4-8b29-817e22be8bc5", + "attachment_start" : "2021-02-16T08:00:00.000Z" + }, + { + "deployment_id" : "0356ca39-58f4-47c6-b9ad-b33d826b63c0", + "critter_id" : "ab086d9b-341f-485c-8841-ad3e6c5f1102", + "attachment_start" : "2021-02-16T08:00:00.000Z" + }, + { + "deployment_id" : "97e3a140-ce52-4514-9014-5207c5705664", + "critter_id" : "9d0b9d55-6eb2-4edf-8206-237c15632cbe", + "attachment_start" : "2021-02-11T08:00:00.000Z" + }, + { + "deployment_id" : "5c1bab85-30cf-4345-804a-eb54df418cf7", + "critter_id" : "0b098de4-4487-436d-b554-7a889ff984af", + "attachment_start" : "2021-02-16T08:00:00.000Z" + }, + { + "deployment_id" : "00c70747-99b1-4ee0-9fee-1b415239b5e9", + "critter_id" : "ee314f0c-496d-4547-b9a6-2c8c4b6aa5de", + "attachment_start" : "2021-02-12T08:00:00.000Z" + }, + { + "deployment_id" : "347dfb1d-bacb-4bb8-8752-537c052019ce", + "critter_id" : "f1d22d83-5076-40b8-a036-80085d94e9d6", + "attachment_start" : "2021-02-18T08:00:00.000Z" + }, + { + "deployment_id" : "c3855352-35e0-4530-8a4f-2ae04727af80", + "critter_id" : "9c15cbd2-f560-48d3-adc2-99ea96fbac58", + "attachment_start" : "2021-02-13T08:00:00.000Z" + }, + { + "deployment_id" : "a1aec651-3886-419a-bd14-f85f20fe8c36", + "critter_id" : "b2d7367b-8d26-490b-b287-a1b68b6ddc5c", + "attachment_start" : "2021-02-23T08:00:00.000Z" + }, + { + "deployment_id" : "b58add67-700e-4a07-83be-be6a435f41b6", + "critter_id" : "663e175e-d2eb-43d1-9fd7-5673b64d1e1e", + "attachment_start" : "2021-02-13T08:00:00.000Z" + }, + { + "deployment_id" : "7d283f1a-1eb6-43a7-859a-ec517eefa5e1", + "critter_id" : "d8b824f4-71f0-4948-8b7f-b81c3d49e0d3", + "attachment_start" : "2021-02-12T08:00:00.000Z" + }, + { + "deployment_id" : "8cf4af55-81fb-453f-ac2d-50cb8767ee15", + "critter_id" : "83295fe2-21d0-4e76-9ff7-58a843363df3", + "attachment_start" : "2021-02-15T08:00:00.000Z" + }, + { + "deployment_id" : "edf7fcab-8e53-4114-997d-0e150bd5f658", + "critter_id" : "8b4d1678-70aa-43fd-b7f3-11ac235dd80f", + "attachment_start" : "2021-02-13T08:00:00.000Z" + }, + { + "deployment_id" : "f98d4db8-c5ab-4aaa-8bb0-79a43608fd9f", + "critter_id" : "1bf3ae51-1c39-4b79-98c6-6c86cde08ce5", + "attachment_start" : "2021-02-15T08:00:00.000Z" + }, + { + "deployment_id" : "98994857-1fe8-4df8-b085-b57d6b7b8d6c", + "critter_id" : "be6da21b-84a8-4086-8e81-4dc7f0277f13", + "attachment_start" : "2021-02-19T08:00:00.000Z" + }, + { + "deployment_id" : "33ab9629-e183-489b-a7a0-6701b39dc0cd", + "critter_id" : "abed4de1-33ca-49d8-98d9-90f9e1269ceb", + "attachment_start" : "2021-02-18T08:00:00.000Z" + }, + { + "deployment_id" : "f343b297-1775-46e5-8124-de9ebce089bc", + "critter_id" : "2ab9c8d1-4e0d-45ce-a406-57199a1add84", + "attachment_start" : "2021-02-13T08:00:00.000Z" + }, + { + "deployment_id" : "44ce196c-edcb-4b05-aa3d-493eb579820a", + "critter_id" : "d495faf1-82dd-4210-9209-3b0e6e48e357", + "attachment_start" : "2021-02-15T08:00:00.000Z" + }, + { + "deployment_id" : "a34dfb5e-83a0-453f-97c4-72226266f8a8", + "critter_id" : "e0d08fbf-79fb-4d4b-a8e7-6a92167d44bf", + "attachment_start" : "2021-02-16T08:00:00.000Z" + }, + { + "deployment_id" : "16f21da2-d085-4baf-8761-e6a6c8f8fe84", + "critter_id" : "ec28cfe7-6a95-4684-95a9-40d5f19a0590", + "attachment_start" : "2021-02-13T08:00:00.000Z" + }, + { + "deployment_id" : "3e54920f-b76f-4c21-b295-df5e8e8bfaa6", + "critter_id" : "1621223c-0e72-4958-820b-8c04e334b248", + "attachment_start" : "2021-02-13T08:00:00.000Z" + }, + { + "deployment_id" : "ef449e80-ec5e-43fb-b084-ef0570b03311", + "critter_id" : "c0e812b8-edcf-4dce-8323-611ae68d849f", + "attachment_start" : "2021-02-14T08:00:00.000Z" + }, + { + "deployment_id" : "a2782109-1174-4686-8eb6-db8608a6110d", + "critter_id" : "4a5892b6-d618-4f78-8aa6-f6efb852f459", + "attachment_start" : "2021-02-13T08:00:00.000Z" + }, + { + "deployment_id" : "70802885-e746-4f8a-b498-75df0a220da0", + "critter_id" : "6bb35559-e904-492a-b8b1-bbfda2b2e427", + "attachment_start" : "2021-02-16T08:00:00.000Z" + }, + { + "deployment_id" : "619f8081-508b-4051-9be3-3a6b22c29636", + "critter_id" : "458567fc-2e05-42ae-821f-0f9fcbf56bbe", + "attachment_start" : "2021-02-18T08:00:00.000Z" + }, + { + "deployment_id" : "c9d97aa5-86a4-4d56-ae9d-665acf631a83", + "critter_id" : "55eedec3-b4a0-4c5b-9ab7-439fe4268eae", + "attachment_start" : "2021-02-14T08:00:00.000Z" + }, + { + "deployment_id" : "d83c7b10-959e-4193-887d-b4b857ac0257", + "critter_id" : "f10b6e6d-cb10-456b-b746-998528a2d397", + "attachment_start" : "2021-02-14T08:00:00.000Z" + }, + { + "deployment_id" : "6290ef76-de29-475e-8ac8-eeb28bd30aab", + "critter_id" : "86a79bf7-4e6d-42cd-839a-a396047d3197", + "attachment_start" : "2021-02-13T08:00:00.000Z" + }, + { + "deployment_id" : "6454e459-aece-4490-8a10-76dffcdaf2b1", + "critter_id" : "7ec9d63c-34ea-4dee-9ff5-ac13a2eb5769", + "attachment_start" : "2021-02-14T08:00:00.000Z" + }, + { + "deployment_id" : "4a09cfc3-1bb0-4ec2-a3e1-2320042c31eb", + "critter_id" : "95927e6a-9d2c-41c0-adb1-327433a087a2", + "attachment_start" : "2021-02-11T08:00:00.000Z" + }, + { + "deployment_id" : "f1a64584-8f1b-4d4e-aa2d-e960306799a3", + "critter_id" : "88d406da-ef74-44d1-a5e7-f0f179aaf8d4", + "attachment_start" : "2021-03-21T07:00:00.000Z" + }, + { + "deployment_id" : "908255e8-4c34-4caf-98c2-460cd49b6a50", + "critter_id" : "547315e0-fdda-4e6e-9f57-ce59902710ca", + "attachment_start" : "2021-02-12T08:00:00.000Z" + }, + { + "deployment_id" : "8626b0e0-bc52-486d-8a16-e602a4900de4", + "critter_id" : "82aae134-0b99-4f3d-a2e8-66b3cb95cff8", + "attachment_start" : "2021-03-21T07:00:00.000Z" + }, + { + "deployment_id" : "8b2956bf-dd3d-487d-b5fa-96522422512f", + "critter_id" : "1a4a4dca-7072-4ee9-89dc-7913a4161352", + "attachment_start" : "2021-02-11T08:00:00.000Z" + }, + { + "deployment_id" : "ecf05348-90b9-45a4-96ef-6291cc2cfbc3", + "critter_id" : "4f989b20-e9da-4451-b1b9-a6fb85d2c97d", + "attachment_start" : "2021-02-12T08:00:00.000Z" + }, + { + "deployment_id" : "d2de6a0d-e756-4e02-9007-2ea9fafca81a", + "critter_id" : "95572e6b-877f-404a-a4f6-523fa78fd14d", + "attachment_start" : "2021-02-14T08:00:00.000Z" + }, + { + "deployment_id" : "2616a675-ce4f-4064-97cf-a05585dea327", + "critter_id" : "009fa39d-e1f1-4391-836b-e068c2385108", + "attachment_start" : "2021-02-11T08:00:00.000Z" + }, + { + "deployment_id" : "c8eae482-e2fa-4700-912b-b28d1556326f", + "critter_id" : "06a7bbec-12d2-41d7-80fe-694a21f3ce00", + "attachment_start" : "2021-02-12T08:00:00.000Z" + }, + { + "deployment_id" : "b333c00e-ffa2-4abb-8547-a87f8b68a8e6", + "critter_id" : "27dc110d-dd55-4e9c-bbe6-a66ae4678864", + "attachment_start" : "2021-02-12T08:00:00.000Z" + }, + { + "deployment_id" : "312a9c0a-1cae-476b-9039-d9333fdcafa3", + "critter_id" : "3027ea89-8bac-438a-a020-7fca6ac202b9", + "attachment_start" : "2021-02-18T08:00:00.000Z" + }, + { + "deployment_id" : "28eedf8f-1334-4937-b642-8a66cef776e5", + "critter_id" : "3f492e99-593a-4289-b324-8f6ff8c15fbc", + "attachment_start" : "2021-02-15T08:00:00.000Z" + }, + { + "deployment_id" : "1a240e6f-ae02-4710-b668-cf4ed4600278", + "critter_id" : "2650c9a3-0236-472b-b108-245eaea6db54", + "attachment_start" : "2021-02-11T08:00:00.000Z" + }, + { + "deployment_id" : "ae6a9f39-519e-49b0-a6b8-79f86a5e3902", + "critter_id" : "d26701b8-f4a5-484b-8d2f-318ab913ca10", + "attachment_start" : "2021-02-20T08:00:00.000Z" + }, + { + "deployment_id" : "09556ad3-36cd-4ad3-b08b-deab3dd43d53", + "critter_id" : "fb86eabb-981b-43c7-82aa-7c43840141db", + "attachment_start" : "2021-02-21T08:00:00.000Z" + }, + { + "deployment_id" : "d4af23cb-b6c3-4d5e-8d3d-eb26a3fd1cf5", + "critter_id" : "a4f4cc92-0a30-4bd1-9ce2-609f413b5e02", + "attachment_start" : "2021-02-21T08:00:00.000Z" + }, + { + "deployment_id" : "e8cf2c1e-4d7b-412d-a33b-1bd962402834", + "critter_id" : "e876db85-0797-436e-a572-e9e06b93e94e", + "attachment_start" : "2021-03-05T08:00:00.000Z" + }, + { + "deployment_id" : "345eff6e-de62-4491-85da-c379697caa2c", + "critter_id" : "165efcdb-99b6-4057-8da1-b32a2f50f00e", + "attachment_start" : "2021-03-06T08:00:00.000Z" + }, + { + "deployment_id" : "78506be5-112a-49b4-a378-db33bd9a0e53", + "critter_id" : "d61119f3-8ee3-40e7-9bf9-e21548c23f3e", + "attachment_start" : "2021-03-05T08:00:00.000Z" + }, + { + "deployment_id" : "fc16d18c-f565-4c9a-bb3c-c28fcc3c1866", + "critter_id" : "63836935-3764-4e4d-a002-abb29e2d6c50", + "attachment_start" : "2021-03-04T08:00:00.000Z" + }, + { + "deployment_id" : "9264b670-13c9-4abd-9128-033f61839a18", + "critter_id" : "0609672c-c587-43f0-8a36-51d115f77ad0", + "attachment_start" : "2021-02-21T08:00:00.000Z" + }, + { + "deployment_id" : "ed81a9b1-fe07-4159-8975-405cda79ef6b", + "critter_id" : "cdefa53a-aef3-401e-b4d9-a85ce6a79a9c", + "attachment_start" : "2021-03-05T08:00:00.000Z" + }, + { + "deployment_id" : "aa6e0fdc-de14-43db-9231-aa76c03843b9", + "critter_id" : "9e3420fa-daad-4f55-941e-7fb2ec242258", + "attachment_start" : "2021-03-04T08:00:00.000Z" + }, + { + "deployment_id" : "efe94291-ac2b-4ce4-bd3b-eef1529a8ce6", + "critter_id" : "b1c01621-33f6-4b01-873a-b07463efaca0", + "attachment_start" : "2021-03-05T08:00:00.000Z" + }, + { + "deployment_id" : "d7a143ad-de4b-4dd6-b82f-dfa8a9959a89", + "critter_id" : "55f68c79-d3bb-4ddc-8722-103b5f325578", + "attachment_start" : "2021-03-04T08:00:00.000Z" + }, + { + "deployment_id" : "0b0bb964-371f-4e28-9abb-285f8fc70e43", + "critter_id" : "0fe2820f-3f16-4fbf-93c1-4a534b63b5ed", + "attachment_start" : "2021-03-04T08:00:00.000Z" + }, + { + "deployment_id" : "bef11300-94b8-4a16-a9df-d8bfb95c4426", + "critter_id" : "897fc0f9-0667-4eb9-b02a-d6def642aa87", + "attachment_start" : "2021-02-21T08:00:00.000Z" + }, + { + "deployment_id" : "3bb479ab-3695-48f8-bdca-4fb38973ab89", + "critter_id" : "aaf7a822-65bc-4bdc-b318-050fcd9fb9df", + "attachment_start" : "2021-02-20T08:00:00.000Z" + }, + { + "deployment_id" : "f10cae51-7278-4a67-bba9-c16a131bcc68", + "critter_id" : "c60e60a5-017e-4485-b17f-4d3c34ffbea8", + "attachment_start" : "2021-03-04T08:00:00.000Z" + }, + { + "deployment_id" : "af08b0d6-e8af-436e-a651-34e243113bc9", + "critter_id" : "738d1504-53fd-4912-94a0-d2e6d2475c8d", + "attachment_start" : "2021-03-04T08:00:00.000Z" + }, + { + "deployment_id" : "2dabeb76-457f-4be3-87a1-b648463df807", + "critter_id" : "13c4b07b-00db-45dc-9089-9b48bba61e93", + "attachment_start" : "2021-02-20T08:00:00.000Z" + }, + { + "deployment_id" : "9191e6cb-3387-4250-9e49-bdb5dd201497", + "critter_id" : "ee814641-73ca-493b-9df5-b77aaaa6a3d4", + "attachment_start" : "2021-03-06T08:00:00.000Z" + }, + { + "deployment_id" : "84ba21b2-9fd5-491f-8180-a80295643922", + "critter_id" : "e8be3f4d-8ad5-4aef-977a-4a1881af01e6", + "attachment_start" : "2021-03-08T08:00:00.000Z" + }, + { + "deployment_id" : "309e9b34-5312-4a60-8d77-13b149b32bb2", + "critter_id" : "27e00c13-ead1-4530-82cb-e674f1f46a07", + "attachment_start" : "2021-02-22T08:00:00.000Z" + }, + { + "deployment_id" : "cac0baf2-a91b-4620-ac1d-6b463f991094", + "critter_id" : "35112f19-2dfc-43bf-a48f-c07b4103a510", + "attachment_start" : "2021-03-06T08:00:00.000Z" + }, + { + "deployment_id" : "dc0e3a27-d3e0-4d4d-8f37-930f29a82ce5", + "critter_id" : "9aa264ea-1396-407a-a249-735f20195d00", + "attachment_start" : "2021-02-20T08:00:00.000Z" + }, + { + "deployment_id" : "fe10a9a6-f5b5-4426-8574-c4dd86e796d5", + "critter_id" : "8b6cd9ce-8e42-42e6-8945-9c3fb58fad31", + "attachment_start" : "2021-03-05T08:00:00.000Z" + }, + { + "deployment_id" : "3659e08d-eacd-4d46-a593-e912cf5b6cdf", + "critter_id" : "9158d249-3a1d-476b-98d2-583229254319", + "attachment_start" : "2021-03-06T08:00:00.000Z" + }, + { + "deployment_id" : "215b76d3-29da-4eca-a9dd-33d0a219134f", + "critter_id" : "38bb437b-3436-4b26-a069-41b2a78b95bb", + "attachment_start" : "2021-03-07T08:00:00.000Z" + }, + { + "deployment_id" : "acfaaac8-f705-4bb1-922b-fe69b40a22d2", + "critter_id" : "bfbde55f-f080-4bd0-9938-6247bf2070b6", + "attachment_start" : "2021-02-19T08:00:00.000Z" + }, + { + "deployment_id" : "8d7e262f-ac65-4273-b5ce-568196a4e764", + "critter_id" : "cdfad667-1d5c-4a65-8e37-2bd4a35297a3", + "attachment_start" : "2021-03-07T08:00:00.000Z" + }, + { + "deployment_id" : "7553c5c1-b286-45ca-9007-1282efe57879", + "critter_id" : "a4993b73-aa52-45ad-a862-cbdaa474ec35", + "attachment_start" : "2021-03-07T08:00:00.000Z" + }, + { + "deployment_id" : "4b744b43-d3ce-474c-8328-8b42b15ded04", + "critter_id" : "0c6f39f0-b969-431b-b530-b8ca07ba677d", + "attachment_start" : "2021-02-20T08:00:00.000Z" + }, + { + "deployment_id" : "e50905e2-12cf-4de6-803c-83854c6b4d46", + "critter_id" : "0f9fae74-0248-4573-bd70-7538629d260a", + "attachment_start" : "2021-03-05T08:00:00.000Z" + }, + { + "deployment_id" : "921fb8ab-529c-427d-8917-126867f9c9e2", + "critter_id" : "b46940e4-7e2e-4fa1-9206-113456223905", + "attachment_start" : "2021-03-08T08:00:00.000Z" + }, + { + "deployment_id" : "27a73b51-cff3-4873-8168-db28d8341b89", + "critter_id" : "352b6c7e-b989-4fde-92cd-daaa7ed740e9", + "attachment_start" : "2021-05-07T07:00:00.000Z" + }, + { + "deployment_id" : "3d32674a-828f-4560-86d0-946970f1b569", + "critter_id" : "d23a1993-5fd5-4dac-b97c-6241eb5f2ee3", + "attachment_start" : "2021-03-05T08:00:00.000Z" + }, + { + "deployment_id" : "cd701780-1f61-40c2-b647-2db59e534076", + "critter_id" : "855fe541-2f2d-46f7-a2b3-f97219c635aa", + "attachment_start" : "2021-03-04T08:00:00.000Z" + }, + { + "deployment_id" : "e86f5355-4fc0-493c-8d1e-55e93c16b90d", + "critter_id" : "ee370226-c745-4749-8488-83fa07f3a076", + "attachment_start" : "2021-02-20T08:00:00.000Z" + }, + { + "deployment_id" : "3981c686-d46c-4556-b76d-621aa1b74492", + "critter_id" : "62d0b426-ea37-475f-9a91-394458bef345", + "attachment_start" : "2021-03-06T08:00:00.000Z" + }, + { + "deployment_id" : "b96136b5-0a39-43fe-91a0-15d9e031973c", + "critter_id" : "b138dd11-2dda-4cbe-9216-7c4d43f607ec", + "attachment_start" : "2021-03-06T08:00:00.000Z" + }, + { + "deployment_id" : "5bb1be33-95f2-4df2-96a2-956fad12d659", + "critter_id" : "4c410f8f-9ed5-4566-ab6c-98b0e3ffc69f", + "attachment_start" : "2021-02-21T08:00:00.000Z" + }, + { + "deployment_id" : "3b69a6e4-e291-4aa0-a947-fab44dad03a5", + "critter_id" : "56e4e3ec-ab59-4308-af6e-daa33bdf49be", + "attachment_start" : "2021-01-26T08:00:00.000Z" + }, + { + "deployment_id" : "876e283b-e00c-4da7-8d4b-4a9bab3dca1b", + "critter_id" : "cb2432ef-8ec9-4722-bab1-c55e7b464d46", + "attachment_start" : "2021-01-26T08:00:00.000Z" + }, + { + "deployment_id" : "879aa59a-7e87-4bc5-9c8a-6a78b76fd6dd", + "critter_id" : "70a5f7b1-b42b-4dbc-a48c-4a436273e651", + "attachment_start" : "2021-01-27T08:00:00.000Z" + }, + { + "deployment_id" : "c4d6a830-d629-4217-94bc-d6876317801e", + "critter_id" : "3ff71aa5-0259-40e0-b6f1-8fe64445589f", + "attachment_start" : "2021-01-26T08:00:00.000Z" + }, + { + "deployment_id" : "11470f10-358d-4a70-ac49-969b95403284", + "critter_id" : "49c5616d-41e8-44e7-9b69-6d4005ce57a3", + "attachment_start" : "2021-01-26T08:00:00.000Z" + }, + { + "deployment_id" : "395ae2a7-0550-4d47-9c5e-094c2a44b46f", + "critter_id" : "18afb264-f356-4824-812a-9587e30b5394", + "attachment_start" : "2021-01-27T08:00:00.000Z" + }, + { + "deployment_id" : "2ff7b9e3-5c18-4512-931b-e6740508a29e", + "critter_id" : "ec26fccf-8e99-4518-817d-581593857d2e", + "attachment_start" : "2021-02-01T08:00:00.000Z" + }, + { + "deployment_id" : "0f403421-289e-4fe9-9e7c-148826d60986", + "critter_id" : "f76e54ce-a0c1-4233-b7ad-7c4a176f2510", + "attachment_start" : "2021-01-27T08:00:00.000Z" + }, + { + "deployment_id" : "ae3a5cfd-2d5c-49e6-abcd-72ee0d9db8ab", + "critter_id" : "0b487725-7d98-4c8d-987b-abcd7b9855ca", + "attachment_start" : "2021-01-26T08:00:00.000Z" + }, + { + "deployment_id" : "b92658b1-7580-4ca7-80fd-224e27e0922a", + "critter_id" : "1aa369c4-cec5-4ea6-89ae-b4162593ccd2", + "attachment_start" : "2021-01-26T08:00:00.000Z" + }, + { + "deployment_id" : "dbf59f8a-d334-439c-8d4d-db903717b1c1", + "critter_id" : "ce43742b-984b-4968-9879-162179058cdc", + "attachment_start" : "2021-01-26T08:00:00.000Z" + }, + { + "deployment_id" : "30420927-2235-45d5-a658-e2105ce09224", + "critter_id" : "17fc7ff9-b70f-400c-98f7-72a552213dc2", + "attachment_start" : "2021-02-01T08:00:00.000Z" + }, + { + "deployment_id" : "a23cca83-1e1b-4057-a9f5-bb5efe3b5f46", + "critter_id" : "efa60af7-bf39-4320-a4eb-dae09ddbbb53", + "attachment_start" : "2021-03-07T08:00:00.000Z" + }, + { + "deployment_id" : "f6b5dc3a-4e96-4f4e-8570-e145f134b8fe", + "critter_id" : "b03d6b5b-d108-4260-b6c1-353b8ef2e75c", + "attachment_start" : "2016-01-16T03:00:00.000Z" + }, + { + "deployment_id" : "de7e7500-8f3e-413e-b172-27d8917f86f0", + "critter_id" : "81607fbc-59a1-4cf6-8d9c-62d01183202e", + "attachment_start" : "2016-01-16T03:00:00.000Z" + }, + { + "deployment_id" : "3f78fc0e-8c94-4d09-a5bd-6ac02fe10233", + "critter_id" : "8de66b75-ed25-4cd1-8e26-aac581a7b3fd", + "attachment_start" : "2016-01-15T03:01:00.000Z" + }, + { + "deployment_id" : "0c537cb8-e322-491a-a1a6-f420f2bccfee", + "critter_id" : "0c750910-9d26-41eb-a177-a775cbf95318", + "attachment_start" : "2016-01-17T03:01:00.000Z" + }, + { + "deployment_id" : "b0ff75f2-c4b3-4a96-b3cf-5f39163c978d", + "critter_id" : "4e19b864-ab23-478b-97da-46aed970f8fb", + "attachment_start" : "2015-03-07T03:00:00.000Z" + }, + { + "deployment_id" : "e37ae212-8a62-4cfb-9a83-fa4f07d368d8", + "critter_id" : "440efff1-eeaa-48ce-a84a-bf58a0b71f5e", + "attachment_start" : "2015-03-13T03:00:00.000Z" + }, + { + "deployment_id" : "07c86045-fd67-4130-b915-2be015472acb", + "critter_id" : "0d481a06-f044-4d96-8488-05fa5b325c53", + "attachment_start" : "2016-01-05T03:00:00.000Z" + }, + { + "deployment_id" : "ca9308f5-bf6f-47e3-ba01-8ef12ebf9413", + "critter_id" : "f6cc32cb-62f9-4f1f-9a42-6105f7f869cd", + "attachment_start" : "2015-03-08T03:00:00.000Z" + }, + { + "deployment_id" : "872cb66f-0139-4b45-8067-7cfac6207076", + "critter_id" : "d309abed-e3b7-4e85-9278-1ffaf64fa0c5", + "attachment_start" : "2016-01-05T03:00:00.000Z" + }, + { + "deployment_id" : "1fc42636-e8b5-45ec-8064-5fc9be06a420", + "critter_id" : "47afa51f-0c7d-42ba-8bc9-9d9d365f9998", + "attachment_start" : "2015-03-13T03:01:00.000Z" + }, + { + "deployment_id" : "310f7651-d763-4c63-b5da-54cd41a74e97", + "critter_id" : "f157404a-4db6-470c-9c53-bba6af6c61da", + "attachment_start" : "2016-01-14T03:00:00.000Z" + }, + { + "deployment_id" : "63174d4b-f389-4630-bbe8-d01f5300626f", + "critter_id" : "adf199ec-222a-43be-b035-0445e96b744d", + "attachment_start" : "2016-01-15T03:00:00.000Z" + }, + { + "deployment_id" : "4746656b-afa5-4a3d-91b6-cb835c561bf6", + "critter_id" : "eec745c4-3d82-46db-b8c6-2320be19a2e4", + "attachment_start" : "2016-01-15T03:00:00.000Z" + }, + { + "deployment_id" : "d23d6dee-0748-401b-ad4c-0fb86b8412c2", + "critter_id" : "728b81de-e6cb-4cfa-bbff-d0870fd21db1", + "attachment_start" : "2016-01-15T03:00:00.000Z" + }, + { + "deployment_id" : "a024a8eb-894b-441a-8dd7-4e48e63e4d7b", + "critter_id" : "68b4225b-007e-4785-b0c0-895964686918", + "attachment_start" : "2016-01-14T03:00:00.000Z" + }, + { + "deployment_id" : "aceaad6d-496a-4667-bdc8-ce08aa15e535", + "critter_id" : "2e3b553c-3dd7-48c3-a9c1-016bf6151f42", + "attachment_start" : "2015-03-14T03:01:00.000Z" + }, + { + "deployment_id" : "955f6c5a-f2a5-4093-8fb2-1ae2360b0dd5", + "critter_id" : "5a9712f7-bdf8-4143-add8-f0e7145e528b", + "attachment_start" : "2016-01-14T03:01:00.000Z" + }, + { + "deployment_id" : "61565b06-fcd0-4a7b-8b70-d030a9a9a968", + "critter_id" : "4079a7df-8c8e-42da-95af-acb28d507dd9", + "attachment_start" : "2016-01-13T03:00:00.000Z" + }, + { + "deployment_id" : "24d3110d-cc8d-4fc4-ae1e-19e228ea5fe0", + "critter_id" : "36a336d0-e0b1-450a-bc11-ad547b2f4d68", + "attachment_start" : "2015-03-07T03:01:00.000Z" + }, + { + "deployment_id" : "62681007-90a4-4c71-9c0e-e0c150ecef45", + "critter_id" : "2ec6eb9d-121d-4d1a-86a1-006e7a2b10ce", + "attachment_start" : "2015-03-06T03:00:00.000Z" + }, + { + "deployment_id" : "3998ae14-7e07-4ba9-8018-935c058841b8", + "critter_id" : "71d0bc8b-1b67-43d8-9b0c-cc20d110d828", + "attachment_start" : "2015-03-07T03:00:00.000Z" + }, + { + "deployment_id" : "0538af92-12ca-46e1-b0ae-1401b20e7b75", + "critter_id" : "5b302835-829f-4d7b-a583-86d1e9f34478", + "attachment_start" : "2016-01-06T03:00:00.000Z" + }, + { + "deployment_id" : "aaebe1e3-11a5-43a7-955a-201e8aba6fca", + "critter_id" : "a5ef9c43-b989-4398-ad11-6e69a27c67d7", + "attachment_start" : "2016-01-14T03:00:00.000Z" + }, + { + "deployment_id" : "96123810-88e4-4c0b-a594-cdc7eadcdca1", + "critter_id" : "d604dd5e-6302-4780-ad27-46fb6e5a8b26", + "attachment_start" : "2016-01-06T03:02:00.000Z" + }, + { + "deployment_id" : "073d258b-f06b-4fc2-9e68-18ab86fa834e", + "critter_id" : "86a4c255-f20f-492d-ac68-c0385f8f92b6", + "attachment_start" : "2015-03-13T03:01:00.000Z" + }, + { + "deployment_id" : "3f3e48d1-52df-4e91-9abe-97a3e5987cb0", + "critter_id" : "11f45060-b84c-4dfb-8303-20ab8bab724a", + "attachment_start" : "2015-03-06T03:00:00.000Z" + }, + { + "deployment_id" : "3a9c972d-85f2-4664-afb6-486d66367ea1", + "critter_id" : "6b438ab9-860e-459a-8f0d-12123b5e61f7", + "attachment_start" : "2016-01-06T03:00:00.000Z" + }, + { + "deployment_id" : "066c2685-82f0-4e46-b5dc-383718957c93", + "critter_id" : "d538584e-a2da-4c0b-afb3-592d590f93af", + "attachment_start" : "2016-01-13T03:04:00.000Z" + }, + { + "deployment_id" : "9567861b-b819-4939-a98e-7c1ed949aeb2", + "critter_id" : "29953cde-9e70-48a9-a1d7-36a155358a1b", + "attachment_start" : "2015-03-08T03:01:00.000Z" + }, + { + "deployment_id" : "12c9cf65-174b-4347-8d26-988b3381170a", + "critter_id" : "25dd6cda-d187-4622-9bf4-24923777cfd1", + "attachment_start" : "2015-03-13T03:00:00.000Z" + }, + { + "deployment_id" : "e49937ea-5952-40e5-bb72-69fd766cdae7", + "critter_id" : "b9a8ea7f-99c1-48f7-842b-0c1c494dafb3", + "attachment_start" : "2015-03-06T03:01:00.000Z" + }, + { + "deployment_id" : "708951a8-304f-4b9c-89af-aaa64d8e0449", + "critter_id" : "cb1d18a3-25fb-43a5-b136-b7238e601ce7", + "attachment_start" : "2015-03-07T03:00:00.000Z" + }, + { + "deployment_id" : "0e48cc5c-e828-4ea7-8793-dc10b81dac0c", + "critter_id" : "0a30d718-47b1-48cc-8367-ca3b0ce62faf", + "attachment_start" : "2015-03-07T03:00:00.000Z" + }, + { + "deployment_id" : "3ca9e56d-d663-4d9c-895f-d0fa0ed99d32", + "critter_id" : "bdfe6d01-cf08-40cb-aaf4-cbe170672779", + "attachment_start" : "2016-01-06T03:01:00.000Z" + }, + { + "deployment_id" : "dfe1bf59-60b4-49ca-b276-f0227bdcb0ff", + "critter_id" : "674b9a37-1c31-4289-8326-21b0efcfffa5", + "attachment_start" : "2016-01-13T03:00:00.000Z" + }, + { + "deployment_id" : "32cdc125-d86a-4168-8a8c-03835d139151", + "critter_id" : "1947c6d3-cfd8-416c-b94b-6a9b8dc4c65f", + "attachment_start" : "2018-02-18T08:00:00.000Z" + }, + { + "deployment_id" : "3ec14c2c-cfd2-4a94-b31f-889abaff366c", + "critter_id" : "fc5430e6-7512-4c64-9bfb-2f9b47722624", + "attachment_start" : "2018-02-13T08:00:00.000Z" + }, + { + "deployment_id" : "a57f5ed6-071e-4023-ad9c-094a3a9631a5", + "critter_id" : "7cfd0725-2299-4852-9b16-4bea60a19bba", + "attachment_start" : "2018-02-15T08:00:00.000Z" + }, + { + "deployment_id" : "1f305a18-f8ae-4ab9-96ae-bb4aca4b6568", + "critter_id" : "569573f0-2d25-4e3b-8ed3-b958beafbdba", + "attachment_start" : "2018-02-16T08:00:00.000Z" + }, + { + "deployment_id" : "f7a0c888-8d59-4a06-9646-d12d01267633", + "critter_id" : "8002c652-472e-4645-a28c-8e98766f5af0", + "attachment_start" : "2018-02-16T08:00:00.000Z" + }, + { + "deployment_id" : "21ccc05b-fcaf-4b4d-b5a2-a22fd9c2694b", + "critter_id" : "5b537904-c8b9-464e-a989-24a42d0cd100", + "attachment_start" : "2018-02-13T08:00:00.000Z" + }, + { + "deployment_id" : "3b79b5f3-88b6-4480-b98b-13c664335bd6", + "critter_id" : "5f20999f-bad6-46dd-8662-0d3a52d7c0a9", + "attachment_start" : "2018-02-16T08:00:00.000Z" + }, + { + "deployment_id" : "229535b3-311a-491c-8e7d-17fa28a7df8c", + "critter_id" : "ba43696f-9be0-4dd1-86bc-11a567d27bbe", + "attachment_start" : "2017-01-31T08:00:00.000Z" + }, + { + "deployment_id" : "3741141f-2658-4df7-b418-421ac0ca493c", + "critter_id" : "42ab8cb9-cc0d-4b67-a4b2-552153007cb6", + "attachment_start" : "2018-02-14T08:00:00.000Z" + }, + { + "deployment_id" : "996236d0-d1ab-49dc-96f3-51f6c3a540e4", + "critter_id" : "4276c2fa-e300-4d95-96fb-ca67c701cc42", + "attachment_start" : "2018-02-19T08:00:00.000Z" + }, + { + "deployment_id" : "4d90b1ff-5eee-4f9d-968e-07e8f2f456e0", + "critter_id" : "73267de6-e988-428d-bffa-f902bac0851a", + "attachment_start" : "2018-03-30T07:00:00.000Z" + }, + { + "deployment_id" : "d7158554-1bfa-43af-9b2f-400b867c85f1", + "critter_id" : "9eeefe0b-7cf1-4fca-9e83-44bd068c9f07", + "attachment_start" : "2020-02-11T08:00:00.000Z" + }, + { + "deployment_id" : "14a09bd9-e670-4e2e-bfc8-35a91973030f", + "critter_id" : "4c9faf59-196d-45ce-a9ba-971aa0f40dbf", + "attachment_start" : "2017-02-01T08:00:00.000Z" + }, + { + "deployment_id" : "6dbd4b2a-2f9b-413e-90ea-0c5e25f1ac01", + "critter_id" : "bfaf2a1e-90ea-45ae-8f0e-8468c4b4ff51", + "attachment_start" : "2017-01-11T08:00:00.000Z" + }, + { + "deployment_id" : "074a0ae4-f792-4cb0-9973-d262848129ea", + "critter_id" : "e6bce1f3-5db4-4298-a2c1-606464ee409d", + "attachment_start" : "2018-02-18T08:00:00.000Z" + }, + { + "deployment_id" : "6cd59947-4889-4c74-9d2a-b4b8cef3333e", + "critter_id" : "3efecf63-e4f4-4112-a62b-e03dad8b9f45", + "attachment_start" : "2017-01-12T08:00:00.000Z" + }, + { + "deployment_id" : "91318010-4ca7-41af-b565-d275a25b0758", + "critter_id" : "96adfe49-0bc6-4b77-8309-2d86d885b7ad", + "attachment_start" : "2018-02-19T08:00:00.000Z" + }, + { + "deployment_id" : "b893a3ed-438f-4eb0-8310-ec12fa30c8f4", + "critter_id" : "64b1cd4f-01c1-4718-ab74-486cfe880b35", + "attachment_start" : "2017-02-01T08:00:00.000Z" + }, + { + "deployment_id" : "7566af0a-7a4e-472c-9c09-5961f2647727", + "critter_id" : "0722d61c-890c-4b35-b5db-cdb87c34b25c", + "attachment_start" : "2018-02-18T08:00:00.000Z" + }, + { + "deployment_id" : "7dd48e04-4b8e-41bd-af30-9add41ef38b5", + "critter_id" : "bd173717-f8f1-411c-847e-324820fc97d1", + "attachment_start" : "2018-02-18T08:00:00.000Z" + }, + { + "deployment_id" : "3ef84e40-fad2-4dc1-aea9-de156b825a1b", + "critter_id" : "0ba40db3-35c1-4bbd-b191-da8e986e90ed", + "attachment_start" : "2020-01-06T08:00:00.000Z" + }, + { + "deployment_id" : "07fb801b-3594-4c4d-ad6e-f54dfdc36d0d", + "critter_id" : "6d469932-03a9-4009-aec8-2ac5d13ccf5f", + "attachment_start" : "2019-02-23T08:00:00.000Z" + }, + { + "deployment_id" : "4aee5331-a010-4d88-9eb2-1944f1e76f8e", + "critter_id" : "3ccf6d1a-54c0-4e4e-a18f-178fcc54c30b", + "attachment_start" : "2019-02-23T08:00:00.000Z" + }, + { + "deployment_id" : "5d083620-8e47-4ad0-a006-9e0d213198ab", + "critter_id" : "cdadedbd-1e0c-4a86-8d6c-82b82cce6a69", + "attachment_start" : "2019-02-27T08:00:00.000Z" + }, + { + "deployment_id" : "571e831d-f433-4904-997d-6f841705c778", + "critter_id" : "abe0e817-c0f0-4a59-a805-3941242cad9b", + "attachment_start" : "2019-01-24T08:00:00.000Z" + }, + { + "deployment_id" : "52b6f79f-53c7-4b88-a073-772d9cc70475", + "critter_id" : "08443da1-f020-4d56-9fd1-79a0ebca3cc8", + "attachment_start" : "2019-02-25T08:00:00.000Z" + }, + { + "deployment_id" : "db151bbb-58ab-4465-b764-211b6cc0aab8", + "critter_id" : "4a270f9c-b83a-4091-95bd-f25f469dcdca", + "attachment_start" : "2021-03-11T08:00:00.000Z" + }, + { + "deployment_id" : "6986ea16-6a1b-499a-8451-38928592163f", + "critter_id" : "e4f9af9e-0a8d-4527-98b2-f99b2f69b2f6", + "attachment_start" : "2020-01-30T08:00:00.000Z" + }, + { + "deployment_id" : "88d30f9e-c7f4-4b20-b427-1468b802b38c", + "critter_id" : "47ad685e-d196-4daf-95a0-2023bf65d05e", + "attachment_start" : "2020-01-29T08:00:00.000Z" + }, + { + "deployment_id" : "71d42206-1191-4e2d-896d-236d54ed926c", + "critter_id" : "6bce9f20-9255-4018-9e22-950d53a93c64", + "attachment_start" : "2020-01-31T08:00:00.000Z" + }, + { + "deployment_id" : "328a8fb6-07d2-4097-8a94-83bf1bd330fb", + "critter_id" : "8b5601bb-c34c-462f-9135-545784a71d54", + "attachment_start" : "2020-02-07T08:00:00.000Z" + }, + { + "deployment_id" : "405eca92-505f-4124-b21e-c7f76f462a40", + "critter_id" : "b19b942c-15f6-4c84-b4cc-c51898818e00", + "attachment_start" : "2020-03-18T07:00:00.000Z" + }, + { + "deployment_id" : "21e6afad-6a22-400a-a4b4-847874955a68", + "critter_id" : "1f403681-f20b-404f-9630-fc853e962ab4", + "attachment_start" : "2021-03-18T07:00:00.000Z" + }, + { + "deployment_id" : "854083c5-38af-4f76-b369-694056f52815", + "critter_id" : "57ba28e7-6639-4cc4-bd50-e0c4cbc3bb46", + "attachment_start" : "2021-03-18T07:00:00.000Z" + }, + { + "deployment_id" : "e7e56092-2882-49d1-bee7-f51b0c137f82", + "critter_id" : "e2a9d611-cb4d-4f84-9e64-f3908300c893", + "attachment_start" : "2020-02-12T08:00:00.000Z" + }, + { + "deployment_id" : "64aa3bf2-475c-4631-84e9-0fe87f5ea7b3", + "critter_id" : "67fbc3a7-c856-4359-91e1-446849dd29d6", + "attachment_start" : "2019-02-27T08:00:00.000Z" + }, + { + "deployment_id" : "daf73e7d-b24c-41fd-aaf6-110cf5c542b7", + "critter_id" : "77f23a5a-1d17-4a9b-a9d2-f9b07dced09b", + "attachment_start" : "2019-02-15T08:00:00.000Z" + }, + { + "deployment_id" : "cec892e0-95a6-488e-9d92-09556b494a89", + "critter_id" : "8e14f664-e629-4cbd-bbdd-2aee7bc54192", + "attachment_start" : "2019-12-30T08:00:00.000Z" + }, + { + "deployment_id" : "0abfa84a-576d-40a9-b71c-59e4197cc5fc", + "critter_id" : "32defda2-d927-4dbc-8774-fa89a851d438", + "attachment_start" : "2020-02-06T08:00:00.000Z" + }, + { + "deployment_id" : "94ceb4bf-9bbe-4f53-8b80-87c06f9ab0e0", + "critter_id" : "71d2d03b-c1b6-4981-9159-be1bc83de6b4", + "attachment_start" : "2020-01-09T08:00:00.000Z" + }, + { + "deployment_id" : "97e4c4e8-ad4a-486a-9d24-bf92959dc8b6", + "critter_id" : "45426c0f-e244-49e6-b88e-f505698a0468", + "attachment_start" : "2021-02-01T08:00:00.000Z" + }, + { + "deployment_id" : "bb64746d-3048-4a99-b1e8-0b54cb9e998e", + "critter_id" : "ba0cd4bd-5eb9-47b6-b27c-7f1de4207bbb", + "attachment_start" : "2021-03-30T07:00:00.000Z" + }, + { + "deployment_id" : "da32c4b7-74bc-4f76-a9ca-cc744eb91b2d", + "critter_id" : "abe3ece9-a996-4444-a9f6-d36cb623a90d", + "attachment_start" : "2021-02-25T08:00:00.000Z" + }, + { + "deployment_id" : "97e80863-7ef9-4cbf-bf70-ee3b90c41bbf", + "critter_id" : "3d44da6e-0d02-4c8c-aeac-1cfbf81b37eb", + "attachment_start" : "2021-03-17T07:00:00.000Z" + }, + { + "deployment_id" : "7b01442c-b042-4b81-a60a-6aeb544af1c2", + "critter_id" : "ca3b718b-70e5-4ed0-a644-55789177fbf7", + "attachment_start" : "2021-03-10T08:00:00.000Z" + }, + { + "deployment_id" : "8eafb126-6f1e-4f35-b74e-174d38ecc704", + "critter_id" : "4f643083-c890-4e73-bbc2-e184a5870fd4", + "attachment_start" : "2021-03-20T07:00:00.000Z" + }, + { + "deployment_id" : "7795e264-6a37-4165-b341-e5f8c0e91a80", + "critter_id" : "7ee9e3ce-b140-4aa5-8ed4-4fe2bb80158f", + "attachment_start" : "2021-02-28T08:00:00.000Z" + }, + { + "deployment_id" : "5e566f3f-486e-40cc-9a07-d0acd84b20bd", + "critter_id" : "e3e672a1-d7d6-4c0f-981e-80b7b37e0356", + "attachment_start" : "2021-03-31T07:00:00.000Z" + }, + { + "deployment_id" : "9e942479-991a-460f-8b33-ecab8e1ed427", + "critter_id" : "fe09a1ed-a606-4206-a9d4-2144f8ac187c", + "attachment_start" : "2017-03-17T07:00:00.000Z" + }, + { + "deployment_id" : "835a7621-d64c-4ece-bd90-5bdb935ad5c1", + "critter_id" : "7e3d3366-1a16-45ff-9fde-59e26c542977", + "attachment_start" : "2018-04-09T07:00:00.000Z" + }, + { + "deployment_id" : "356e5e84-ae98-4ff5-9272-e0e5a78e1706", + "critter_id" : "4ec64b7f-7483-4d05-90c6-045b88e6103e", + "attachment_start" : "2019-01-29T08:00:00.000Z" + }, + { + "deployment_id" : "c29f855f-7c86-4eaa-88a3-32f2001a4a9d", + "critter_id" : "aa447fbb-338d-4a1a-9577-5e6dd22588b9", + "attachment_start" : "2018-03-24T07:00:00.000Z" + }, + { + "deployment_id" : "a10f255c-2d1b-4772-a929-514b925ccb20", + "critter_id" : "8c3b3227-7ae4-485b-b783-e8bbb25b20fb", + "attachment_start" : "2018-03-03T08:00:00.000Z" + }, + { + "deployment_id" : "15ecaf1b-d639-4c0c-b9f6-d974a24e6501", + "critter_id" : "e34becd5-20d4-4ef0-90b1-35c2299e8c8a", + "attachment_start" : "2018-03-06T08:00:00.000Z" + }, + { + "deployment_id" : "d5ad65aa-fb8f-4d4b-a0d1-712d17bb8c0b", + "critter_id" : "a880b060-ff93-4f00-a1bc-b44d8265a575", + "attachment_start" : "2018-02-27T08:00:00.000Z" + }, + { + "deployment_id" : "c5df6d90-2680-47fe-8f80-c42a564ed88d", + "critter_id" : "ed982de8-e88c-4a4c-816d-4fee12b9653a", + "attachment_start" : "2018-03-03T08:00:00.000Z" + }, + { + "deployment_id" : "b9ced101-13f2-455a-bd12-9762f7805e18", + "critter_id" : "b5986136-16fd-43e4-9a9a-6d1eb6c7df29", + "attachment_start" : "2017-03-17T07:00:00.000Z" + }, + { + "deployment_id" : "af6a60b5-864f-4832-bfdf-72c3b8902ca3", + "critter_id" : "df883378-f9bf-4579-903d-012eb079385d", + "attachment_start" : "2018-03-31T07:00:00.000Z" + }, + { + "deployment_id" : "c8db013c-ce4a-41ba-bb94-fcb977fc0040", + "critter_id" : "70544ae0-56c3-4eb6-b429-c6d52e0d2e18", + "attachment_start" : "2018-03-04T08:00:00.000Z" + }, + { + "deployment_id" : "93d71f43-b1b3-431e-9a22-36551d4f24bc", + "critter_id" : "60487a6e-611e-4a1c-9ebe-478053ee2d10", + "attachment_start" : "2018-03-02T08:00:00.000Z" + }, + { + "deployment_id" : "093a8fc0-2c00-47f7-9cad-c1ab29d7171b", + "critter_id" : "01de93c8-4204-41cf-8e07-76aba32d6f3b", + "attachment_start" : "2017-03-17T07:00:00.000Z" + }, + { + "deployment_id" : "f30c1bd3-69fa-4660-9f8a-076b8e214abc", + "critter_id" : "d9cf6b61-fe4e-491a-898b-a0238620753f", + "attachment_start" : "2018-03-06T08:00:00.000Z" + }, + { + "deployment_id" : "3b647d28-2839-43b7-b41f-3b31694c34e8", + "critter_id" : "04782a6d-7101-492a-add6-6df23a9ee81e", + "attachment_start" : "2018-03-03T08:00:00.000Z" + }, + { + "deployment_id" : "ff812476-29f8-4d1c-8bea-a54b64a1e85e", + "critter_id" : "00387a03-0d46-4a02-81af-cb8ce1c8b4cb", + "attachment_start" : "2018-04-27T07:00:00.000Z" + }, + { + "deployment_id" : "ffb3694c-76b5-4a44-806a-6dfe9bec7449", + "critter_id" : "3b412cbc-a39f-4920-b4fd-b35e4b85c29b", + "attachment_start" : "2019-01-28T08:00:00.000Z" + }, + { + "deployment_id" : "67954751-4a2c-487e-bf1a-4fcb53dba9ad", + "critter_id" : "225d94ff-218b-4420-97f5-d5d05c133518", + "attachment_start" : "2018-02-28T08:00:00.000Z" + }, + { + "deployment_id" : "5be28d86-1120-4f07-8a00-9392b0d7f694", + "critter_id" : "19278a16-9ff9-4555-ad3e-2ea1cc288bd6", + "attachment_start" : "2018-04-27T07:00:00.000Z" + }, + { + "deployment_id" : "a8384df8-1332-478e-bcb1-76e4cdbc0e8c", + "critter_id" : "2ec74ddb-eef5-4d3d-9562-1ac70bb09eb8", + "attachment_start" : "2018-03-05T08:00:00.000Z" + }, + { + "deployment_id" : "b8329a10-c857-4be3-aa13-51a7f4a6e31c", + "critter_id" : "0ce88ad9-a0fa-4636-a146-e7569860e213", + "attachment_start" : "2018-03-02T08:00:00.000Z" + }, + { + "deployment_id" : "50bd0c43-311a-497f-a098-2ac60a5f9cd3", + "critter_id" : "2ad38704-9d2c-455d-88f2-8694ba92a5fb", + "attachment_start" : "2018-04-09T07:00:00.000Z" + }, + { + "deployment_id" : "7ce64518-a8a7-4efe-b7c2-429c334c7cc0", + "critter_id" : "5b72df9c-9787-452f-9057-805219d3336f", + "attachment_start" : "2014-03-18T07:00:00.000Z" + }, + { + "deployment_id" : "29da0465-32a8-405a-bb2a-3a104c7d220b", + "critter_id" : "0984ecb2-aebc-468b-b2d7-ed6915f8e567", + "attachment_start" : "2018-02-28T08:00:00.000Z" + }, + { + "deployment_id" : "a7c370b3-55b5-4bc3-9f51-15fa37e3ec21", + "critter_id" : "b12d1fb9-d8fb-4227-96be-84a1ce8e5b11", + "attachment_start" : "2018-03-02T08:00:00.000Z" + }, + { + "deployment_id" : "b68d25d4-1c7c-4c7c-ada4-7cea3959e9ac", + "critter_id" : "768dc197-b4a1-477b-8d83-d043440e4b00", + "attachment_start" : "2018-03-04T08:00:00.000Z" + }, + { + "deployment_id" : "f172cd6f-3dad-4065-8c61-44a76777e145", + "critter_id" : "3dd1cabf-dce4-43c2-b15d-64df355e3ff8", + "attachment_start" : "2018-03-03T08:00:00.000Z" + }, + { + "deployment_id" : "4d6fa858-3d83-49af-8c38-fa585c9c3e26", + "critter_id" : "24bf4ae4-4162-4bb2-859c-2c00a1a040a5", + "attachment_start" : "2018-02-28T08:00:00.000Z" + }, + { + "deployment_id" : "b849fca2-db70-4eab-9576-332e1f113c89", + "critter_id" : "7827b407-656b-49f7-884f-c69d49022d0a", + "attachment_start" : "2018-03-01T08:00:00.000Z" + }, + { + "deployment_id" : "2fdf7d9a-b530-4fef-bb12-3df14e14db95", + "critter_id" : "cc8dea0d-ca70-4bb4-af3d-54785f3ff403", + "attachment_start" : "2018-03-05T08:00:00.000Z" + }, + { + "deployment_id" : "1571a506-4a17-428d-9e11-96a0e8ffb4fc", + "critter_id" : "22b920db-1305-4cef-bac4-6883a403ca51", + "attachment_start" : "2018-03-02T08:00:00.000Z" + }, + { + "deployment_id" : "facf739b-6adf-4a1e-8efb-7833add449ac", + "critter_id" : "76a64308-1752-43e3-b36e-590af68ef3fc", + "attachment_start" : "2018-03-03T08:00:00.000Z" + }, + { + "deployment_id" : "836bd05c-073d-4692-8fc7-6b177fe5ab39", + "critter_id" : "54c0bb7d-0467-4541-9366-8641e39475da", + "attachment_start" : "2018-02-28T08:00:00.000Z" + }, + { + "deployment_id" : "546a886c-c28a-479c-b889-a51da3a2bb1c", + "critter_id" : "444a8220-e2c7-4371-94e6-8f299db1854a", + "attachment_start" : "2018-03-24T07:00:00.000Z" + }, + { + "deployment_id" : "0987a273-e2e4-46fb-9b26-294573be6666", + "critter_id" : "2f9f691b-9380-4ba1-8554-8b9ecf46c059", + "attachment_start" : "2018-03-05T08:00:00.000Z" + }, + { + "deployment_id" : "16104d6e-4a9d-4bfe-acfc-4dfff800585f", + "critter_id" : "da8fe732-bd64-4911-8e23-079653ae80c9", + "attachment_start" : "2018-03-03T08:00:00.000Z" + }, + { + "deployment_id" : "fe0758f2-436e-41c4-8352-f3840f6adb7e", + "critter_id" : "0314eb13-d4d0-42e6-8885-a693816e6350", + "attachment_start" : "2018-03-03T08:00:00.000Z" + }, + { + "deployment_id" : "889ba823-e026-41fa-871c-3d93c8ee0820", + "critter_id" : "afdcc6a0-35c2-4dc8-8b4c-85af75c42293", + "attachment_start" : "2019-01-29T08:00:00.000Z" + }, + { + "deployment_id" : "325331a9-e9d8-40aa-b794-5ce293ee5f3b", + "critter_id" : "5deb5fa8-b219-4da0-9d82-2eb61665f2ee", + "attachment_start" : "2018-03-04T08:00:00.000Z" + }, + { + "deployment_id" : "7e414e89-ef3c-40c8-a940-22dd4dc5ece2", + "critter_id" : "3ddf1ff2-b364-4c7a-aa29-ec83fb30a2ee", + "attachment_start" : "2019-01-29T08:00:00.000Z" + }, + { + "deployment_id" : "37d83e1a-3f08-49d4-bd2e-62d07d2790a5", + "critter_id" : "ab5b7398-01c9-4509-8b7d-4d5c11d2dea3", + "attachment_start" : "2018-03-05T08:00:00.000Z" + }, + { + "deployment_id" : "091c8214-7826-42eb-9bd1-a4664a616aed", + "critter_id" : "e1695f26-5e0c-4965-a868-66564994505d", + "attachment_start" : "2018-03-04T08:00:00.000Z" + }, + { + "deployment_id" : "920d16c2-4225-4548-849b-ebf2888280c1", + "critter_id" : "e2210bb1-06b5-4449-aa5c-64536a4c70c1", + "attachment_start" : "2019-01-29T08:00:00.000Z" + }, + { + "deployment_id" : "3b5bb17d-0652-44d3-b00b-7967d733176f", + "critter_id" : "2daa7e49-257b-4dcf-a29d-66b4701dc29d", + "attachment_start" : "2018-03-02T08:00:00.000Z" + }, + { + "deployment_id" : "2f4f4aa4-130a-40da-ad71-86bf7793773d", + "critter_id" : "b40afbdc-a22b-4d7c-8237-59951cbb3910", + "attachment_start" : "2018-03-02T08:00:00.000Z" + }, + { + "deployment_id" : "15d10b84-c3cb-4484-b92d-d9c5fb5781a7", + "critter_id" : "d2f7178d-91d3-4fce-93b5-b6c69f66d884", + "attachment_start" : "2018-03-02T08:00:00.000Z" + }, + { + "deployment_id" : "007be266-4692-4f22-bbc9-f071fe9c891e", + "critter_id" : "0777ff73-f97f-4aed-babf-c83d82ef3985", + "attachment_start" : "2018-02-26T08:00:00.000Z" + }, + { + "deployment_id" : "341ad702-683c-4030-b3d4-97389dda27d6", + "critter_id" : "fd70a078-85db-4ba6-a71e-120fc9ab26a5", + "attachment_start" : "2018-03-04T08:00:00.000Z" + }, + { + "deployment_id" : "7a7152be-5e01-4fd7-8522-ceea34af3619", + "critter_id" : "4b31e785-8d24-4328-816f-664ae61308c4", + "attachment_start" : "2018-03-06T08:00:00.000Z" + }, + { + "deployment_id" : "7c59390b-555c-40c0-b7d5-a9c9d79d0005", + "critter_id" : "8e368136-f43a-4711-b7de-03955f039c8c", + "attachment_start" : "2018-03-24T07:00:00.000Z" + }, + { + "deployment_id" : "90fd8a7a-ecf9-4433-99c6-892e6ed8cf03", + "critter_id" : "a5a10054-8b1a-4095-bd22-8ee8f98fe9a3", + "attachment_start" : "2017-03-17T07:00:00.000Z" + }, + { + "deployment_id" : "2a276fea-d04d-4763-a78d-d9709fbf7345", + "critter_id" : "2ec880a9-1d7d-42b2-889e-93c4bd7bdeed", + "attachment_start" : "2018-03-05T08:00:00.000Z" + }, + { + "deployment_id" : "d5c8b5fe-a0fa-474b-ad3d-35d16702d415", + "critter_id" : "67b61b97-8128-44e6-8d04-315c525f0101", + "attachment_start" : "2018-02-26T08:00:00.000Z" + }, + { + "deployment_id" : "852205ff-3a0f-46da-9810-9a7fe41c928a", + "critter_id" : "ade21d68-16f8-473d-8a9b-7e7c55f6b52c", + "attachment_start" : "2018-02-27T08:00:00.000Z" + }, + { + "deployment_id" : "9a4474e0-16a0-4fad-8858-dd2052bbc082", + "critter_id" : "5d12c01a-db79-4ff7-8ce6-97f178290630", + "attachment_start" : "2018-03-04T08:00:00.000Z" + }, + { + "deployment_id" : "ed1dacf5-dc80-44a6-98ce-c7637fb83232", + "critter_id" : "31c836b7-1c3b-4e13-9aec-de16757c56e0", + "attachment_start" : "2018-03-03T08:00:00.000Z" + }, + { + "deployment_id" : "9c763ea9-937e-4b8b-bd93-433a272fd3c4", + "critter_id" : "4d8c2f56-094c-444e-b410-e2e64c7164ec", + "attachment_start" : "2018-03-03T08:00:00.000Z" + }, + { + "deployment_id" : "1474de5d-67d3-425b-b172-de135e020aec", + "critter_id" : "d90de6eb-937e-4c1e-92c9-d0ae0c6f24d4", + "attachment_start" : "2018-03-25T07:00:00.000Z" + }, + { + "deployment_id" : "22015a15-dbcd-4506-b293-a9910a96ae28", + "critter_id" : "e42818a8-97c0-4896-bd00-8f7a0fed54e5", + "attachment_start" : "2018-03-04T08:00:00.000Z" + }, + { + "deployment_id" : "368ae5e0-d419-4299-8bde-8f0db496ec5e", + "critter_id" : "f995deec-c8d0-42fb-a869-2e0a6ff05e71", + "attachment_start" : "2018-03-02T08:00:00.000Z" + }, + { + "deployment_id" : "59266abf-95fd-4aa5-b6bc-863e501e82b3", + "critter_id" : "ce136031-5b26-411a-acc8-a30e80be62cd", + "attachment_start" : "2018-03-04T08:00:00.000Z" + }, + { + "deployment_id" : "816e1eaf-70a1-4dd4-a20b-3f5734a4a3cf", + "critter_id" : "0fdf4aef-6a20-4e82-a070-f438f8c4e40c", + "attachment_start" : "2018-03-03T08:00:00.000Z" + }, + { + "deployment_id" : "fc631865-f5a6-463a-914b-c74f85990151", + "critter_id" : "7901cd0d-814e-4e5a-9abd-ef9531d9fd52", + "attachment_start" : "2018-02-28T08:00:00.000Z" + }, + { + "deployment_id" : "accc9f86-6205-45bc-9156-7a8b9bc8918a", + "critter_id" : "42c2c01a-be75-4d99-a691-ff6441dbe119", + "attachment_start" : "2017-03-17T07:00:00.000Z" + }, + { + "deployment_id" : "06702cce-a9d9-4a58-ad25-a978d818d72e", + "critter_id" : "a9b3cb94-3fd9-4c00-8fb7-db01ac8298ee", + "attachment_start" : "2018-02-27T08:00:00.000Z" + }, + { + "deployment_id" : "4c46b529-ea84-42d2-9ee8-8ac203aa4a2a", + "critter_id" : "7e30e46c-82de-4970-b0b3-952514f029d2", + "attachment_start" : "2018-02-27T08:00:00.000Z" + }, + { + "deployment_id" : "2a7beb4d-3b69-4818-b361-38ca4e643484", + "critter_id" : "a143eba4-c374-47bd-a5bf-1f309f0fd9ec", + "attachment_start" : "2018-03-05T08:00:00.000Z" + }, + { + "deployment_id" : "0510465e-4195-44e4-bc80-8e96f1b95fa0", + "critter_id" : "4f44b5fc-231f-46a5-b9f3-bd7d458d3eca", + "attachment_start" : "2019-01-28T08:00:00.000Z" + }, + { + "deployment_id" : "b0331bfa-a4ba-4973-a39e-1724d933b68d", + "critter_id" : "8684d2e5-64cb-4990-b631-9a3a04b6de78", + "attachment_start" : "2018-03-05T08:00:00.000Z" + }, + { + "deployment_id" : "d4eef4fb-3628-458a-8730-b0cbdf3b4da1", + "critter_id" : "b75e22d8-c0f9-458a-b6fa-940b499019d3", + "attachment_start" : "2018-03-06T08:00:00.000Z" + }, + { + "deployment_id" : "af9442f5-5c90-4754-adbf-ca13dbb84345", + "critter_id" : "176428c3-7ecc-42c2-a81d-8b807aefbbdb", + "attachment_start" : "2018-03-04T08:00:00.000Z" + }, + { + "deployment_id" : "8acf9d99-1f65-4eb8-a15d-c04e703f96af", + "critter_id" : "3c4ddd3e-31bc-48b0-966c-c6a46bb53c06", + "attachment_start" : "2018-03-05T08:00:00.000Z" + }, + { + "deployment_id" : "1085ad2f-852c-4952-ad7b-b7392ef270d0", + "critter_id" : "6dc88ce1-f0fb-4470-b63c-f81163b8b17c", + "attachment_start" : "2018-03-02T08:00:00.000Z" + }, + { + "deployment_id" : "06f5d5d7-7992-4b3b-b3a6-ab44c5ca3ee8", + "critter_id" : "d540e02a-efcf-4075-971c-5a558fa2e4f0", + "attachment_start" : "2018-03-03T08:00:00.000Z" + }, + { + "deployment_id" : "dddb7f40-acbc-4699-b8ef-a82adbad1363", + "critter_id" : "26e0d23d-53a6-4d6f-9079-6a2ed9151947", + "attachment_start" : "2017-03-17T07:00:00.000Z" + }, + { + "deployment_id" : "bb7fcaee-8541-484d-93c3-0abab3a5d624", + "critter_id" : "95731095-8b51-401b-8e59-2dbc75676bdf", + "attachment_start" : "2018-02-26T08:00:00.000Z" + }, + { + "deployment_id" : "74670289-7187-4895-b039-52f135a86d20", + "critter_id" : "32fe2006-c000-4a61-b326-94f416c08a8f", + "attachment_start" : "2018-03-03T08:00:00.000Z" + }, + { + "deployment_id" : "40dca94d-41c5-4db3-8804-bca348ff8469", + "critter_id" : "3e70a16d-abb6-49b3-9ba4-d00d95c824b3", + "attachment_start" : "2020-02-11T08:00:00.000Z" + }, + { + "deployment_id" : "af3624f0-1d21-44a8-8687-370fb22a7ae0", + "critter_id" : "2227044b-b473-4fb5-949e-f176c6ba7567", + "attachment_start" : "2018-03-03T08:00:00.000Z" + }, + { + "deployment_id" : "0f7ea0d7-314a-4631-8ca3-636ad6b62f44", + "critter_id" : "e656dd5d-04cc-46d4-93f4-32eb6314259d", + "attachment_start" : "2018-03-24T07:00:00.000Z" + }, + { + "deployment_id" : "44c62156-f14e-4ede-b445-ec705d241ca1", + "critter_id" : "8a9e6e2a-0da4-4fcc-97bf-2c557b39df51", + "attachment_start" : "2018-03-05T08:00:00.000Z" + }, + { + "deployment_id" : "0e426d0e-ac91-413d-aeb0-4012d1444dfb", + "critter_id" : "4b6cc769-c0eb-4857-82a7-eef0fedcb4ae", + "attachment_start" : "2018-02-26T08:00:00.000Z" + }, + { + "deployment_id" : "1cb20887-629f-4699-bc78-67442d7d8f89", + "critter_id" : "9b2ac00a-7bb0-40c6-9807-0cd73ce2638a", + "attachment_start" : "2018-02-26T08:00:00.000Z" + }, + { + "deployment_id" : "a535efb2-4dc5-4f84-8817-f016b85f19a7", + "critter_id" : "050f36ba-a8cd-45bc-a98f-eae22baecefd", + "attachment_start" : "2018-03-06T08:00:00.000Z" + }, + { + "deployment_id" : "b1202fe8-50a3-4ad9-b3f1-fa78ec1ccea5", + "critter_id" : "bb44c55d-3a99-4534-9dfc-21fd76c95c13", + "attachment_start" : "2018-03-06T08:00:00.000Z" + }, + { + "deployment_id" : "fa22835e-75ac-4d68-a39c-e18daf9313e3", + "critter_id" : "3a8b2847-16d4-428e-8845-fb45b8a72ab4", + "attachment_start" : "2019-01-28T08:00:00.000Z" + }, + { + "deployment_id" : "cff0d856-ed95-43c9-885f-382968fc5d48", + "critter_id" : "7dee9476-9695-4faf-b4b9-0734f0284035", + "attachment_start" : "2018-03-03T08:00:00.000Z" + }, + { + "deployment_id" : "293b3eaa-092d-433d-a987-0c9f7ec1013e", + "critter_id" : "e9b002bb-6bfe-443a-8440-5ce724c1446b", + "attachment_start" : "2018-02-28T08:00:00.000Z" + }, + { + "deployment_id" : "3cf9c394-e7a1-4bd5-8679-7dc14464f989", + "critter_id" : "457d7559-04c5-4069-a979-32186958b819", + "attachment_start" : "2018-02-26T08:00:00.000Z" + }, + { + "deployment_id" : "6b99f9a4-a26b-4510-be31-0624a3159327", + "critter_id" : "32ae44d4-6bde-4ce3-937d-666c76acb673", + "attachment_start" : "2018-03-02T08:00:00.000Z" + }, + { + "deployment_id" : "1bcbe4d2-6554-493c-82a8-7589632e5351", + "critter_id" : "e5102a5a-c88a-4606-aa7f-67575c8b7211", + "attachment_start" : "2018-02-26T08:00:00.000Z" + }, + { + "deployment_id" : "a4c0b239-eb4d-448b-b1f5-b44579b944ed", + "critter_id" : "9defdeec-23e1-42c6-985a-37e5b960b5e5", + "attachment_start" : "2017-03-17T07:00:00.000Z" + }, + { + "deployment_id" : "9997ab99-8c37-4613-97a8-5d30f02c3f2e", + "critter_id" : "bd5f3a5c-27fb-4dd4-ba61-3559d559d94a", + "attachment_start" : "2018-05-05T07:00:00.000Z" + }, + { + "deployment_id" : "463d7c2c-34ce-4e92-8469-f51111732741", + "critter_id" : "e891a6ff-6940-4def-85c6-f6888f3c3bed", + "attachment_start" : "2017-03-17T07:00:00.000Z" + }, + { + "deployment_id" : "2e8339ce-bb0c-4cd1-978e-146a9fc9e41d", + "critter_id" : "fffa523b-657e-4941-bec5-4598cf5d40cc", + "attachment_start" : "2019-01-14T08:00:00.000Z" + }, + { + "deployment_id" : "4094c33c-205b-46d1-bf2b-74acacf5fe42", + "critter_id" : "0b0f31f6-93c2-4197-bb10-d8522ae65e65", + "attachment_start" : "2019-01-14T08:00:00.000Z" + }, + { + "deployment_id" : "0f697d7f-7a68-41c6-8e6d-d35dfc03e961", + "critter_id" : "58c27ea9-845e-4fd6-8afd-642f2b2c46ab", + "attachment_start" : "2019-01-14T08:00:00.000Z" + }, + { + "deployment_id" : "2bab9cb1-06f3-4c42-833b-8a80552db019", + "critter_id" : "2d02425e-d757-4713-8c02-3fe37ab0b6aa", + "attachment_start" : "2018-03-28T07:00:00.000Z" + }, + { + "deployment_id" : "117dc260-c256-46e9-808f-42df18ba9aec", + "critter_id" : "ae9629a6-0ca6-4b96-a9ad-60d68005a91e", + "attachment_start" : "2018-03-31T07:00:00.000Z" + }, + { + "deployment_id" : "4c41b467-bb26-4371-bf0c-6388a9a8a9fd", + "critter_id" : "187a5db2-b3b8-4af3-ae94-93675deee21e", + "attachment_start" : "2018-03-31T07:00:00.000Z" + }, + { + "deployment_id" : "53dde913-431e-4be9-b86e-03680fe77e25", + "critter_id" : "f832a300-3f66-4eff-8bd7-fbbb3073c40d", + "attachment_start" : "2018-03-31T07:00:00.000Z" + }, + { + "deployment_id" : "87f93418-031d-4848-8e2d-008110d3889e", + "critter_id" : "c34f8705-7e87-402d-8d67-c12454d40d28", + "attachment_start" : "2018-03-28T07:00:00.000Z" + }, + { + "deployment_id" : "63bfa2bf-c891-4875-b2bd-7aca310b900c", + "critter_id" : "9796fb2b-fd58-4efb-a54d-d6949d10da60", + "attachment_start" : "2018-03-31T07:00:00.000Z" + }, + { + "deployment_id" : "48445b21-8cf6-415e-8a80-232f22f67ad4", + "critter_id" : "1335efa4-5d88-43c6-bc60-523e12faf971", + "attachment_start" : "2018-03-31T07:00:00.000Z" + }, + { + "deployment_id" : "fafb3e71-f453-4f48-9dd0-bf20718cff1d", + "critter_id" : "c62e58d8-e110-41e6-934e-32be70c1bf15", + "attachment_start" : "2018-03-31T07:00:00.000Z" + }, + { + "deployment_id" : "c4aca7ee-2d5e-4b7d-ad69-6b76348a22d6", + "critter_id" : "e74d3923-91bd-46da-8e86-17ccb284b4d4", + "attachment_start" : "2018-03-28T07:00:00.000Z" + }, + { + "deployment_id" : "5d148899-f070-492c-8c2f-585a268d8564", + "critter_id" : "9be555d7-a9c6-4752-ab6a-388470cf389d", + "attachment_start" : "2019-03-26T07:00:00.000Z" + }, + { + "deployment_id" : "80342e17-ec18-4d4b-b2b1-5f3d702792f4", + "critter_id" : "64c8616f-e2f8-4c0e-8d38-e9fbe8a255ee", + "attachment_start" : "2018-03-24T07:00:00.000Z" + }, + { + "deployment_id" : "543e7917-e526-403a-a210-1a92ee248e8d", + "critter_id" : "76b5072a-4ef6-451b-a4bd-4b7c7601f72e", + "attachment_start" : "2018-03-25T07:00:00.000Z" + }, + { + "deployment_id" : "b0dd23f1-3bcb-4bc4-87b8-5ecd8ede4199", + "critter_id" : "e8241d15-7234-4cec-bce3-44f010275549", + "attachment_start" : "2019-03-04T08:00:00.000Z" + }, + { + "deployment_id" : "9ddf7455-5385-482d-8f39-51f58e5c760b", + "critter_id" : "334ca3cb-e73c-4f1e-8dae-ca1e5d2602ae", + "attachment_start" : "2018-04-01T07:00:00.000Z" + }, + { + "deployment_id" : "3f956900-46b6-4640-b341-046b2c9ed590", + "critter_id" : "c57a46e6-3f13-40ec-a9ff-8430cdd08c7d", + "attachment_start" : "2018-03-25T07:00:00.000Z" + }, + { + "deployment_id" : "93fc75c1-787d-4db0-bf7e-df7421fd00e6", + "critter_id" : "79292d6d-79c3-4cb8-9003-55b9db0b0567", + "attachment_start" : "2018-03-25T07:00:00.000Z" + }, + { + "deployment_id" : "3ab13a22-b7c1-4a7e-91a4-d31f8699331a", + "critter_id" : "322dc417-df9e-4cb0-b907-01c20ef0f97b", + "attachment_start" : "2018-03-25T07:00:00.000Z" + }, + { + "deployment_id" : "626746a2-bae4-4e55-8488-a596030ff214", + "critter_id" : "ed11ced0-1550-44de-974e-de383716e162", + "attachment_start" : "2018-03-24T07:00:00.000Z" + }, + { + "deployment_id" : "56654876-3361-4f6e-8e34-392d1c8b65bb", + "critter_id" : "a6a9c993-58fe-4e78-a8ca-fd284dc287fa", + "attachment_start" : "2016-03-25T07:00:00.000Z" + }, + { + "deployment_id" : "c0216c55-6741-424d-9dc2-3976e6b1b94a", + "critter_id" : "0030eccf-01d5-4ac3-a39c-a465f3a07e96", + "attachment_start" : "2017-04-04T07:00:00.000Z" + }, + { + "deployment_id" : "cdc0a9be-e297-42a0-8060-6028fde3586a", + "critter_id" : "b9172c03-70a6-4ac7-8d6b-79c0c3b7dde3", + "attachment_start" : "2017-04-04T07:00:00.000Z" + }, + { + "deployment_id" : "b91aa7fd-0d39-4a4e-be18-6b98cd161da7", + "critter_id" : "b35de765-d031-4913-8e6a-2bfce51db7be", + "attachment_start" : "2017-04-04T07:00:00.000Z" + }, + { + "deployment_id" : "df51b66e-1951-48ef-a7ab-36559f0aa3bf", + "critter_id" : "5c1bfa8c-9dd1-4523-ad8f-f93fc0fc77d8", + "attachment_start" : "2017-04-04T07:00:00.000Z" + }, + { + "deployment_id" : "8c4db380-5180-4b7b-bfbe-b31262b634d3", + "critter_id" : "18881a55-e2fe-46a1-bc6f-feddb624e93d", + "attachment_start" : "2017-04-04T07:00:00.000Z" + }, + { + "deployment_id" : "954079e5-f6d6-48d2-95ac-ca4411da0d07", + "critter_id" : "22104c1f-6adc-4d9c-b016-49e0004f210a", + "attachment_start" : "2017-04-04T07:00:00.000Z" + }, + { + "deployment_id" : "9c7b4571-dbb4-4c91-95f7-e58acf295a99", + "critter_id" : "9f5ea99b-85b8-4d35-a2d4-f125817e2a49", + "attachment_start" : "2017-04-04T07:00:00.000Z" + }, + { + "deployment_id" : "af568ef8-8528-4b8f-939c-5dad7c257baa", + "critter_id" : "f2611295-54c5-4ed3-ba69-897c9c3e955c", + "attachment_start" : "2019-03-24T07:00:00.000Z" + }, + { + "deployment_id" : "9300ae76-2eeb-44e3-8010-46c94fa07506", + "critter_id" : "8ddaccc7-6704-4ed5-9851-07d67b9e153f", + "attachment_start" : "2019-03-24T07:00:00.000Z" + }, + { + "deployment_id" : "ebff3999-dedb-424e-80f2-3d6806fd3dc5", + "critter_id" : "63f58a0c-b60b-44e9-af15-63eab855d052", + "attachment_start" : "2019-01-29T08:00:00.000Z" + }, + { + "deployment_id" : "97f4fb06-9125-41ec-b73d-3a9c5edbf39d", + "critter_id" : "39f1396d-e272-4b6c-9d0f-2080a4573293", + "attachment_start" : "2019-01-29T08:00:00.000Z" + }, + { + "deployment_id" : "194bbe78-1997-4867-ab0e-c2f1b42dddd0", + "critter_id" : "826aa7b4-31cf-44d5-80cd-fc159e42d7e7", + "attachment_start" : "2023-10-18T07:00:00.000Z" + }, + { + "deployment_id" : "c4a43498-9a00-4ce1-87d0-c309da12d286", + "critter_id" : "c63b0493-f725-4760-8bab-4cbbe388dd5d", + "attachment_start" : "2019-01-28T08:00:00.000Z" + }, + { + "deployment_id" : "3282c6d4-5b71-45bb-ac7e-54e9e9b11591", + "critter_id" : "90e27973-1038-428b-b08d-cbad6a58646a", + "attachment_start" : "2019-01-28T08:00:00.000Z" + }, + { + "deployment_id" : "60c4b41b-b5dd-422e-a79b-1acd8cf056ff", + "critter_id" : "a5bdc427-01e0-419b-9683-0d6efd56d833", + "attachment_start" : "2020-02-22T08:00:00.000Z" + }, + { + "deployment_id" : "51839de4-f284-4214-9107-7b830edf72e7", + "critter_id" : "c6e0ef1f-393d-4afd-b38b-9fe7d341b9d2", + "attachment_start" : "2020-02-22T08:00:00.000Z" + }, + { + "deployment_id" : "87c74c17-c15f-46bb-9665-3561f10c897a", + "critter_id" : "64e891a7-4bfe-42dd-a686-29297b5ca580", + "attachment_start" : "2020-02-22T08:00:00.000Z" + }, + { + "deployment_id" : "175ac3c4-11a8-441b-a080-8ef8f2d1b712", + "critter_id" : "dd9541a4-4079-4e37-a013-6848be673ec2", + "attachment_start" : "2020-02-20T08:00:00.000Z" + }, + { + "deployment_id" : "3796daca-8ab3-47a4-ad8d-9550cf7317c8", + "critter_id" : "be7827a7-95d3-498e-9129-a48cfeb43fe7", + "attachment_start" : "2020-02-22T08:00:00.000Z" + }, + { + "deployment_id" : "13f2c85f-2ca4-47bd-a009-430adc47edb4", + "critter_id" : "6f1fdea5-d995-4dee-9c88-a0a8296b5ccd", + "attachment_start" : "2020-02-11T08:00:00.000Z" + }, + { + "deployment_id" : "b0ead4c9-2f26-40a4-88e1-e4dafc2351af", + "critter_id" : "6de28a29-4378-4600-8d17-c9b41d79c1f1", + "attachment_start" : "2020-02-11T08:00:00.000Z" + }, + { + "deployment_id" : "a22d8c66-d5a1-4281-a1da-96786486a869", + "critter_id" : "379746fd-1b01-40b6-916a-965759cd7293", + "attachment_start" : "2020-02-11T08:00:00.000Z" + }, + { + "deployment_id" : "45e814a0-dd0b-402b-a85f-166b7eaaa39a", + "critter_id" : "3eaacf5e-5d14-41b4-9e91-5ddaa68b7ef0", + "attachment_start" : "2020-02-11T08:00:00.000Z" + }, + { + "deployment_id" : "529518b6-e019-4d4f-891e-fd2fff3543dc", + "critter_id" : "0502b0fd-f081-4827-a920-cb1f0884ef37", + "attachment_start" : "2020-02-11T08:00:00.000Z" + }, + { + "deployment_id" : "d68105df-b07b-44e0-9d80-7ea1b65919d9", + "critter_id" : "1b82eef7-076e-439a-9fd7-1c5895ac3aa3", + "attachment_start" : "2020-04-04T07:00:00.000Z" + }, + { + "deployment_id" : "1a129914-c50d-4975-9807-83aad06ce849", + "critter_id" : "2a9911f2-b0b7-43e8-8baa-09b3412b8cdb", + "attachment_start" : "2020-03-05T08:00:00.000Z" + }, + { + "deployment_id" : "b643ef1b-027f-4a99-9024-ef0453b45e13", + "critter_id" : "32b41020-109c-4348-bdbe-61b182e22572", + "attachment_start" : "2020-01-28T08:00:00.000Z" + }, + { + "deployment_id" : "2ec9830e-8701-4383-bab9-58af64c3eaac", + "critter_id" : "20164e73-37c8-4a04-82e5-2d72f16f510f", + "attachment_start" : "2020-03-18T07:00:00.000Z" + }, + { + "deployment_id" : "cf37a274-420a-4051-bfb1-3e2dfe4a751d", + "critter_id" : "3a59c0c7-a2eb-46c6-8549-15744add7b85", + "attachment_start" : "2020-02-22T08:00:00.000Z" + }, + { + "deployment_id" : "b3c71382-aa91-4d67-a6bf-facaf821aca3", + "critter_id" : "c9776896-81fc-44f4-9a30-19d893873310", + "attachment_start" : "2020-02-19T08:00:00.000Z" + }, + { + "deployment_id" : "b73b0b0d-4063-4572-98c5-0a48514f103e", + "critter_id" : "5326ab66-8661-47d4-a34e-4a01f60242d4", + "attachment_start" : "2020-02-22T08:00:00.000Z" + }, + { + "deployment_id" : "4de99711-be39-48a4-b682-e1cecde9af06", + "critter_id" : "b394ad16-ed6c-4e49-b4a0-6c7499c86606", + "attachment_start" : "2020-03-18T07:00:00.000Z" + }, + { + "deployment_id" : "9551a085-ef19-4cf4-a350-ed29eb4e7b16", + "critter_id" : "fc960ec5-15bf-4fce-806a-6f6429295f43", + "attachment_start" : "2020-02-18T08:00:00.000Z" + }, + { + "deployment_id" : "dcbaadfc-ff41-47b3-bf9b-6f221f3fbc71", + "critter_id" : "9f12cb3e-645b-4795-9402-800b505b1fa4", + "attachment_start" : "2020-03-18T07:00:00.000Z" + }, + { + "deployment_id" : "8704cda1-0922-48b4-b2fe-c4988785e6ca", + "critter_id" : "9c275f31-a276-42c3-bfc7-b08b9f8b8f2a", + "attachment_start" : "2016-03-25T07:00:00.000Z" + }, + { + "deployment_id" : "3cfe62a4-43f7-4ef6-8d22-2dd16eda2484", + "critter_id" : "5329d141-9f86-4f29-be98-82ed69e1cbe6", + "attachment_start" : "2016-03-25T07:00:00.000Z" + }, + { + "deployment_id" : "edad3b1e-0dcb-4bc2-ae1c-4f451648b5e6", + "critter_id" : "789d0491-9400-488e-9a98-8ff0d71eeeea", + "attachment_start" : "2015-03-27T07:00:00.000Z" + }, + { + "deployment_id" : "1d3d4736-d9ee-4eaf-b878-e99154d52730", + "critter_id" : "b9d84bd6-5804-4be4-a93d-ca0b2e8c47b7", + "attachment_start" : "2017-04-04T07:00:00.000Z" + }, + { + "deployment_id" : "0f33dfa2-355f-4ab5-8866-75e457071620", + "critter_id" : "d1b4f650-01a0-4311-90c9-8f6b02f645b6", + "attachment_start" : "2016-04-25T07:00:00.000Z" + }, + { + "deployment_id" : "f9935bdf-eb26-4791-99b1-c97dfee11bed", + "critter_id" : "00c7935a-d8bc-4180-9c2d-34a63361c946", + "attachment_start" : "2016-03-25T07:00:00.000Z" + }, + { + "deployment_id" : "3cf87baa-baca-4a01-8096-a33c1ebdb684", + "critter_id" : "5cb75691-e029-49bb-8a53-f92f7a548c35", + "attachment_start" : "2016-03-25T07:00:00.000Z" + }, + { + "deployment_id" : "69c7e4fc-3a91-461a-940e-891382d97168", + "critter_id" : "b8d1829a-4fc9-4ad9-a6fe-3f8d7af182c2", + "attachment_start" : "2015-03-27T07:00:00.000Z" + }, + { + "deployment_id" : "a44175a9-08d4-4277-93f1-5f2330d22df1", + "critter_id" : "40fa0e69-9402-48da-8ef9-6b2b3c042e82", + "attachment_start" : "2015-03-27T07:00:00.000Z" + }, + { + "deployment_id" : "6bffa03c-0011-4b0b-9d53-e5f11f2e966c", + "critter_id" : "ceff2e86-b0be-4c33-9f70-9a2509f841ce", + "attachment_start" : "2016-03-25T07:00:00.000Z" + }, + { + "deployment_id" : "6b15a2d5-925b-4f41-bb89-649fec3eca2c", + "critter_id" : "040aa02b-6a21-47ed-8f7b-77591f15f305", + "attachment_start" : "2016-03-25T07:00:00.000Z" + }, + { + "deployment_id" : "9eabf8c7-9f8f-48d1-b457-db8930dfeeb8", + "critter_id" : "f3456a90-2078-4413-9f4c-51bc1fd0c09a", + "attachment_start" : "2016-03-25T07:00:00.000Z" + }, + { + "deployment_id" : "5ccf58e0-e833-4be7-b875-7669f74ffff8", + "critter_id" : "f9f3c996-154f-4363-856d-b7d4fbfe7941", + "attachment_start" : "2021-03-16T07:00:00.000Z" + }, + { + "deployment_id" : "8fb8219c-cd5f-4e90-98d8-e856f2361983", + "critter_id" : "3a324f2a-73ba-4c0a-a67c-aa454ea8a74b", + "attachment_start" : "2021-03-06T08:00:00.000Z" + }, + { + "deployment_id" : "9089ae91-dd63-4f65-ae26-e3d77e352eb1", + "critter_id" : "d240a505-8ede-4755-9e1e-67e6cd3ea505", + "attachment_start" : "2021-03-06T08:00:00.000Z" + }, + { + "deployment_id" : "6890d522-4377-4d5d-9852-536c9a6194f8", + "critter_id" : "7b59f606-55ae-485e-859a-2d0af151cb5a", + "attachment_start" : "2021-03-06T08:00:00.000Z" + }, + { + "deployment_id" : "5c68c6e6-6b7c-49dc-b40b-5c456fe84a10", + "critter_id" : "a411bad3-f374-463a-821e-4dc682b80c72", + "attachment_start" : "2021-03-06T08:00:00.000Z" + }, + { + "deployment_id" : "5dcd822f-7f02-4cf1-ad8d-1752f96c79f4", + "critter_id" : "5bf0a58c-2151-4f86-9f5b-61637c60e7d9", + "attachment_start" : "2021-03-16T07:00:00.000Z" + }, + { + "deployment_id" : "03887687-0408-4741-bdd3-14560e6f0527", + "critter_id" : "5be0210a-7cdd-43e8-9299-27a3f25ddbc8", + "attachment_start" : "2021-03-21T07:00:00.000Z" + }, + { + "deployment_id" : "86c3efd8-b932-4cb5-b910-84456b9eff57", + "critter_id" : "352169b2-eba6-411b-b3b0-7e83e1b8e0a8", + "attachment_start" : "2021-03-16T07:00:00.000Z" + }, + { + "deployment_id" : "cc863b54-3726-4736-8c34-0e5166c054db", + "critter_id" : "baaf4775-7f98-48bd-ace5-83996934f0e6", + "attachment_start" : "2021-03-18T07:00:00.000Z" + }, + { + "deployment_id" : "7b73cf75-9a75-45dc-8451-27bb614b7738", + "critter_id" : "6cf59265-ef73-4933-b51b-d7d05a5315ec", + "attachment_start" : "2021-03-21T07:00:00.000Z" + }, + { + "deployment_id" : "54aed448-20fa-4fbe-af90-7dd2b2c91853", + "critter_id" : "6a5cd005-12e2-44ea-9bc9-97802c247564", + "attachment_start" : "2021-03-18T07:00:00.000Z" + }, + { + "deployment_id" : "982a1e74-6bab-4f4c-9fe3-6246ee951542", + "critter_id" : "02522e85-fa46-4ae5-8cf7-40fa50527d6d", + "attachment_start" : "2021-03-15T07:00:00.000Z" + }, + { + "deployment_id" : "7cf617b5-f697-4676-972c-6129f28aaa5a", + "critter_id" : "53b5e98b-d416-450b-9ea5-72b6fe9c4342", + "attachment_start" : "2021-03-22T07:00:00.000Z" + }, + { + "deployment_id" : "dc0cfa34-c11b-4b76-b328-b2377638dbef", + "critter_id" : "d85585ba-6135-4741-8478-a1d8a91837c0", + "attachment_start" : "2021-03-16T07:00:00.000Z" + }, + { + "deployment_id" : "e7632552-f933-4a0e-80b4-9ff1239ebd06", + "critter_id" : "1874e5b5-8798-4d59-8aae-af92cda3935f", + "attachment_start" : "2021-03-20T07:00:00.000Z" + }, + { + "deployment_id" : "7b5a2bca-adb4-442a-ae96-11e0d222c472", + "critter_id" : "f82ddd9d-ce2f-4f10-be30-bb438d3050a1", + "attachment_start" : "2021-03-15T07:00:00.000Z" + }, + { + "deployment_id" : "b4883806-7494-4bcd-a4c0-e08d8adb8613", + "critter_id" : "908f2492-3b4f-4056-88d4-c39c87b0ed80", + "attachment_start" : "2021-03-18T07:00:00.000Z" + }, + { + "deployment_id" : "989f3eb9-3c06-498e-847d-17ea77065d68", + "critter_id" : "5f2287be-1f0d-411b-a55e-554a3e41804f", + "attachment_start" : "2021-03-15T07:00:00.000Z" + }, + { + "deployment_id" : "a71e0529-1646-474c-9896-ee15dfb9935d", + "critter_id" : "762698c9-d751-437c-b7f2-e6bf561bfe61", + "attachment_start" : "2021-03-17T07:00:00.000Z" + }, + { + "deployment_id" : "018ea2ca-85da-460c-91ef-c5d44b994996", + "critter_id" : "e1f1c16f-d841-469c-945e-10eb0fee4bfd", + "attachment_start" : "2021-03-16T07:00:00.000Z" + }, + { + "deployment_id" : "df57a41f-9495-4e10-985c-9495a2afce70", + "critter_id" : "03d73c84-e4a7-44a6-8e83-3fc114e0e654", + "attachment_start" : "2021-03-16T07:00:00.000Z" + }, + { + "deployment_id" : "42ddba10-f594-46d8-aa30-6fdc2d7b8f83", + "critter_id" : "a3d21f24-ed0e-4507-a1c9-88a5b2441726", + "attachment_start" : "2021-03-15T07:00:00.000Z" + }, + { + "deployment_id" : "bc533c72-e567-48d5-a898-76f73e70faa8", + "critter_id" : "b46db913-f314-4768-9c09-8ae115a3a236", + "attachment_start" : "2021-03-19T07:00:00.000Z" + }, + { + "deployment_id" : "a6c02409-531f-481d-8503-e47eda309f68", + "critter_id" : "00d3cea6-dc3c-4913-89a3-7a04277514a3", + "attachment_start" : "2021-03-22T07:00:00.000Z" + }, + { + "deployment_id" : "6cd85313-95d9-41fc-9164-98fb549afe75", + "critter_id" : "420a4a33-becc-4a67-ad29-dad86236f263", + "attachment_start" : "2021-03-15T07:00:00.000Z" + }, + { + "deployment_id" : "63bdf792-7fa3-4e14-9324-412030ffdd09", + "critter_id" : "f6d262e5-850a-4dbe-a5d8-02e9a722030a", + "attachment_start" : "2021-03-18T07:00:00.000Z" + }, + { + "deployment_id" : "5d68ea51-199a-40a8-9570-a840fabe7ea2", + "critter_id" : "6de11c32-41ab-45b3-9d48-2a4ac40b7c74", + "attachment_start" : "2021-03-17T07:00:00.000Z" + }, + { + "deployment_id" : "11e532e8-b2e6-47e1-8ada-e9576cc813cf", + "critter_id" : "ef9aeb9d-2171-4e9a-904f-0dea6e89e8c5", + "attachment_start" : "2021-03-16T07:00:00.000Z" + }, + { + "deployment_id" : "751fc1e8-ab3d-4e89-865a-71c2ebee0361", + "critter_id" : "f9bd6e94-feec-46ac-9ea2-fc17a60413f0", + "attachment_start" : "2021-03-20T07:00:00.000Z" + }, + { + "deployment_id" : "6e8f9328-577b-4b69-bd26-56b9d2995f76", + "critter_id" : "30a7c9b0-f074-453f-aac1-7327136cb48b", + "attachment_start" : "2021-03-15T07:00:00.000Z" + }, + { + "deployment_id" : "ac515c83-4d22-442f-97fc-7df5077a82b3", + "critter_id" : "734448ea-7e12-478a-8c33-43e8cef48987", + "attachment_start" : "2021-03-22T07:00:00.000Z" + }, + { + "deployment_id" : "c0d86669-20c8-47fd-81e1-91c77d02c105", + "critter_id" : "8e865b5f-32b4-40a0-ac77-3717d53e9e71", + "attachment_start" : "2021-01-11T08:00:00.000Z" + }, + { + "deployment_id" : "a0000f5a-b310-45de-aa8e-7e33df0150b0", + "critter_id" : "77324b3f-bed9-4bf1-9e0a-7ca9df3d074d", + "attachment_start" : "2021-01-21T08:00:00.000Z" + }, + { + "deployment_id" : "f67eb8ed-03ec-4f0c-8351-b7f25257bdc5", + "critter_id" : "cd43bc9d-0765-461f-be92-df0a9d7b5912", + "attachment_start" : "2021-01-20T08:00:00.000Z" + }, + { + "deployment_id" : "52a4c99d-51b6-457d-978d-a1063ba53378", + "critter_id" : "e15553f4-8e75-4b80-93a1-34399394b1d1", + "attachment_start" : "2021-01-23T08:00:00.000Z" + }, + { + "deployment_id" : "729e2b82-bc80-4a6e-baea-8672ad59d3b8", + "critter_id" : "6cd1c53d-0f83-4306-be1c-0b67ddac2dc6", + "attachment_start" : "2021-01-24T08:00:00.000Z" + }, + { + "deployment_id" : "74619956-182b-4d67-b817-1187c050d403", + "critter_id" : "2693a84c-a70d-433c-a0ae-126923e9dd6a", + "attachment_start" : "2021-01-22T08:00:00.000Z" + }, + { + "deployment_id" : "1a1df02f-73fe-49e1-b4cd-cd2b78c65f2a", + "critter_id" : "a242436f-a5ac-41a0-a2d6-9e6b7674bc66", + "attachment_start" : "2021-01-20T08:00:00.000Z" + }, + { + "deployment_id" : "0191cdd3-eea3-44a1-9143-3a3e19f523bf", + "critter_id" : "7f5dbf04-7e7c-4ddb-a851-50ca20ceeeff", + "attachment_start" : "2021-01-24T08:00:00.000Z" + }, + { + "deployment_id" : "a84e6916-e43a-42de-8dcd-a58840b9b848", + "critter_id" : "86c909c9-a689-4abb-80f1-083e66c88ca8", + "attachment_start" : "2021-01-20T08:00:00.000Z" + }, + { + "deployment_id" : "57c01675-9605-4972-8e16-4d2e61a64d47", + "critter_id" : "c81f87a3-d133-4e26-9b18-399d0b717b86", + "attachment_start" : "2021-01-24T08:00:00.000Z" + }, + { + "deployment_id" : "d6d9fec1-2e10-41d2-ab94-ccb0cfda69a6", + "critter_id" : "3925a942-ff90-4b4b-ae72-6b6512473a20", + "attachment_start" : "2021-01-22T08:00:00.000Z" + }, + { + "deployment_id" : "dc142529-27f2-45ec-92c4-1fc621666853", + "critter_id" : "859ef4f1-02d5-4b6a-89d6-e107469f1fc7", + "attachment_start" : "2021-01-22T08:00:00.000Z" + }, + { + "deployment_id" : "1d456d81-7b9d-41d7-81ef-eb0b720028dd", + "critter_id" : "6eeb5c46-ab5f-48cc-b3c0-e11c3daa325f", + "attachment_start" : "2021-01-22T08:00:00.000Z" + }, + { + "deployment_id" : "9b411677-ae72-4f00-abca-88c7e4b02109", + "critter_id" : "1801293c-379a-4807-9112-0f3591f049f5", + "attachment_start" : "2021-01-24T08:00:00.000Z" + }, + { + "deployment_id" : "ae1bb68c-350e-4590-8367-f1f984e288f8", + "critter_id" : "5cbe99b7-6f8b-4aba-822e-d391593706c9", + "attachment_start" : "2021-03-18T07:00:00.000Z" + }, + { + "deployment_id" : "3b19a4cc-eedf-4217-91d5-ae28279d9c75", + "critter_id" : "8705d927-5c5f-4433-ba2c-770f3617d291", + "attachment_start" : "2021-01-22T08:00:00.000Z" + }, + { + "deployment_id" : "4d7be069-84eb-4bd0-a729-979aba318ddd", + "critter_id" : "7ab25f9a-0a51-4cb9-80a6-57eea03da108", + "attachment_start" : "2019-03-05T08:00:00.000Z" + }, + { + "deployment_id" : "18ed64e3-036a-48fa-be15-327727cadc30", + "critter_id" : "39ccc157-dcb7-4662-80d5-1e58bc6379c3", + "attachment_start" : "2019-03-07T08:00:00.000Z" + }, + { + "deployment_id" : "0b8c680c-668f-4c68-808a-a6f0fccc04d8", + "critter_id" : "869107ed-b76f-4e20-b4bc-5d310867262f", + "attachment_start" : "2018-03-07T08:00:00.000Z" + }, + { + "deployment_id" : "5242e3fa-2aaa-45af-987b-f4c37c6d6c9c", + "critter_id" : "a66c7488-f5ce-445d-8d6c-884415dde90f", + "attachment_start" : "2019-03-05T08:00:00.000Z" + }, + { + "deployment_id" : "c94b7891-d130-4a1b-bed0-dc95b5f4b99e", + "critter_id" : "1895b743-f799-4a1e-933d-bebaf2b8a6fe", + "attachment_start" : "2019-03-06T08:00:00.000Z" + }, + { + "deployment_id" : "e84df8f6-38be-496d-8958-427e8161ba2c", + "critter_id" : "254c2191-9f3e-491e-ac4b-0cd873c786be", + "attachment_start" : "2019-02-02T08:00:00.000Z" + }, + { + "deployment_id" : "01b1f461-f092-4cb6-b5c7-e94018c5a9ab", + "critter_id" : "bf33b60a-cd15-4b08-8352-ee9e11b23a48", + "attachment_start" : "2019-02-02T08:00:00.000Z" + }, + { + "deployment_id" : "b976b67a-cd56-4f0e-a03a-267dadcc2abe", + "critter_id" : "3a789444-6217-45a9-ad10-5b531cdc2038", + "attachment_start" : "2019-02-19T08:00:00.000Z" + }, + { + "deployment_id" : "33792cbe-7b40-4a21-89b7-c83d0473cf68", + "critter_id" : "662de264-5e5a-4dc1-826e-5034ad447c21", + "attachment_start" : "2019-01-30T08:00:00.000Z" + }, + { + "deployment_id" : "ef4e6803-28b4-402e-b3db-95e9c9673f81", + "critter_id" : "dd7d52a1-5675-499c-86cc-1217102ad853", + "attachment_start" : "2019-02-03T08:00:00.000Z" + }, + { + "deployment_id" : "7213746b-4e31-4dd4-baf2-96326ee04f6b", + "critter_id" : "13a863db-2bbb-44cd-ae56-581d2d31b024", + "attachment_start" : "2019-02-03T08:00:00.000Z" + }, + { + "deployment_id" : "afee4f5a-ba11-4efe-a526-9a076183b65b", + "critter_id" : "bb55a31c-d942-4af5-9b49-c1f9380baefd", + "attachment_start" : "2019-02-03T08:00:00.000Z" + }, + { + "deployment_id" : "60cb6886-6c1e-4374-a828-5962f7c5d1c1", + "critter_id" : "5d5640ea-27fe-4274-a457-72a74121cde6", + "attachment_start" : "2021-01-22T08:00:00.000Z" + }, + { + "deployment_id" : "3850cc90-642c-4b8b-a898-3a904ae4b9f9", + "critter_id" : "2aa64657-e021-42cc-8cdc-9874f01421b2", + "attachment_start" : "2019-03-04T08:00:00.000Z" + }, + { + "deployment_id" : "78a0b84a-d191-4261-b59f-8650ba3548b5", + "critter_id" : "cf8b5c9a-7e45-4109-9192-3fdfd6af1d26", + "attachment_start" : "2020-10-29T07:00:00.000Z" + }, + { + "deployment_id" : "d250e2b1-3eb6-46f6-ac42-9b79371c55be", + "critter_id" : "5f02f338-c466-46a3-ad90-660efbb42259", + "attachment_start" : "2020-10-28T07:00:00.000Z" + }, + { + "deployment_id" : "e7c2e1c7-4c09-41e6-92ea-f74374e9192b", + "critter_id" : "524dbab0-bb07-4b8d-800b-f3babf511f55", + "attachment_start" : "2020-10-27T07:00:00.000Z" + }, + { + "deployment_id" : "5ce5634d-8a49-4b59-9e6e-dc8cf79e80d6", + "critter_id" : "6247dd33-bbd4-4d00-9efc-3d7c7620da14", + "attachment_start" : "2019-03-05T08:00:00.000Z" + }, + { + "deployment_id" : "80feb180-a763-487b-b0cf-1e5ff113ed31", + "critter_id" : "5888829c-8ab7-4e52-9f82-15e2fded4d49", + "attachment_start" : "2019-03-04T08:00:00.000Z" + }, + { + "deployment_id" : "176309b2-9adb-4d60-8104-5554b461e625", + "critter_id" : "e55a19a8-e73b-41ed-b38e-cd627422a144", + "attachment_start" : "2020-10-27T07:00:00.000Z" + }, + { + "deployment_id" : "8788766e-5419-4150-819d-30cd2589bd73", + "critter_id" : "0ca6df37-46be-4651-9a28-0d17eeb6994b", + "attachment_start" : "2018-03-14T07:00:00.000Z" + }, + { + "deployment_id" : "08b0ea1b-02d3-4e5f-bf86-03ce8ba7c500", + "critter_id" : "dedf8192-4b03-44b5-9ea3-2050c8cf545e", + "attachment_start" : "2018-03-10T08:00:00.000Z" + }, + { + "deployment_id" : "46f20ca3-6b01-40d3-8295-cecdcba64700", + "critter_id" : "6c70aed7-6f49-4da1-917b-9d214854faf4", + "attachment_start" : "2018-03-10T08:00:00.000Z" + }, + { + "deployment_id" : "9ffaae82-5961-4b88-ab28-dd7cbc649043", + "critter_id" : "e9765fa4-ed44-4dea-a86a-dbda64e7bc4d", + "attachment_start" : "2018-03-12T07:00:00.000Z" + }, + { + "deployment_id" : "e594cded-7b4e-49fd-ad8d-e3e1bc96144e", + "critter_id" : "41be658f-27f9-446d-8e36-c6f1631594c4", + "attachment_start" : "2018-03-10T08:00:00.000Z" + }, + { + "deployment_id" : "81b5ed9e-d938-41d8-97bd-fa9c9e32deae", + "critter_id" : "b7f682b4-86e7-4f1d-954d-3df171553437", + "attachment_start" : "2018-03-26T07:00:00.000Z" + }, + { + "deployment_id" : "dcc791a5-4c32-4234-83cf-a322f50b96bf", + "critter_id" : "ce80faa5-7b13-4ef5-af70-b01d6cc735cd", + "attachment_start" : "2018-03-23T07:00:00.000Z" + }, + { + "deployment_id" : "f1807b46-73bc-4e18-964d-cb80136e0455", + "critter_id" : "a320781f-f29f-4b22-8a06-7cd7fd0e23f7", + "attachment_start" : "2018-03-13T07:00:00.000Z" + }, + { + "deployment_id" : "f51ad988-c759-4fb5-ad8a-8c0127083e21", + "critter_id" : "8170bd7b-9174-4215-992e-ad38763a829e", + "attachment_start" : "2018-03-13T07:00:00.000Z" + }, + { + "deployment_id" : "493ddc32-2101-4f46-a09e-ee12fb74a730", + "critter_id" : "b7740059-6211-4083-b253-d3a1fd3e819a", + "attachment_start" : "2018-02-12T08:00:00.000Z" + }, + { + "deployment_id" : "4f3a5395-ccde-44de-8904-8218bd33ba8f", + "critter_id" : "0aa141fc-1306-4870-b916-bbf63f803187", + "attachment_start" : "2018-03-10T08:00:00.000Z" + }, + { + "deployment_id" : "e8a59de9-4ba5-4e55-b152-4d3b51eb58aa", + "critter_id" : "f37a5911-d5e4-4482-b331-57cc450666f9", + "attachment_start" : "2018-03-12T07:00:00.000Z" + }, + { + "deployment_id" : "bb248f88-871b-4161-86aa-d67b5dd0e793", + "critter_id" : "d3b3b151-3ea2-46f5-8463-a301318de168", + "attachment_start" : "2018-03-12T07:00:00.000Z" + }, + { + "deployment_id" : "c242b1d5-63d9-49af-8f94-239e4529ad91", + "critter_id" : "dfc97c47-8777-414c-8e47-143d94d2eebd", + "attachment_start" : "2018-03-11T08:00:00.000Z" + }, + { + "deployment_id" : "64306aea-43aa-469f-8809-49c229ae00d0", + "critter_id" : "d89201fc-1f83-441f-9ba1-3c92c7a6b134", + "attachment_start" : "2018-03-12T07:00:00.000Z" + }, + { + "deployment_id" : "0f8195e5-5d86-4bea-aeae-c9fbd664b8c9", + "critter_id" : "49265f0e-06ad-42dc-b7f1-db088958f3bf", + "attachment_start" : "2018-03-11T08:00:00.000Z" + }, + { + "deployment_id" : "7f08c1d7-6320-435c-98b9-f7ba869b7533", + "critter_id" : "ac704da6-a3cf-4cef-8ec5-857481ae871a", + "attachment_start" : "2018-03-11T08:00:00.000Z" + }, + { + "deployment_id" : "84a2aa2c-14ac-4b10-976b-192a7f69803c", + "critter_id" : "32da81d0-f9e8-41be-8cd9-b5af8e9b9ea6", + "attachment_start" : "2018-03-12T07:00:00.000Z" + }, + { + "deployment_id" : "74b87be8-a2ba-479b-887e-0b8ea3a9478e", + "critter_id" : "841db5d4-ad5e-44a1-aa07-b45810d1b514", + "attachment_start" : "2018-03-11T08:00:00.000Z" + }, + { + "deployment_id" : "a81fbec1-0c39-46e1-bb8b-ba901ef1dd66", + "critter_id" : "84d28e15-68cd-4cb8-abde-e23d982532dc", + "attachment_start" : "2018-03-11T08:00:00.000Z" + }, + { + "deployment_id" : "52beb761-57fe-4658-9c55-1ea6dff45dac", + "critter_id" : "86a860e6-763d-4cda-91e0-07e602bf7523", + "attachment_start" : "2018-03-13T07:00:00.000Z" + }, + { + "deployment_id" : "5ae5b494-7272-4c7b-9329-25c252da91f2", + "critter_id" : "52ac211a-1e60-4f21-a647-867af4dfe87d", + "attachment_start" : "2018-03-11T08:00:00.000Z" + }, + { + "deployment_id" : "e92d1b65-a724-4441-afbc-b62475dae2a9", + "critter_id" : "2f5b729a-c816-42e4-971b-cd84ae29ff32", + "attachment_start" : "2018-03-12T07:00:00.000Z" + }, + { + "deployment_id" : "0534c072-f50f-422e-8e0c-505fcdac8b67", + "critter_id" : "e18c0804-bf3f-41b4-989e-a5b6ed0c04e6", + "attachment_start" : "2018-03-10T08:00:00.000Z" + }, + { + "deployment_id" : "080fc479-2f67-4671-abc3-b9604967a091", + "critter_id" : "3d191fa1-78a8-4d8c-9903-a10344202653", + "attachment_start" : "2018-03-10T08:00:00.000Z" + }, + { + "deployment_id" : "4a775676-a9bc-435e-80fe-1c1d48852cdb", + "critter_id" : "b90102a1-a6ad-447a-a2fe-703bdb1c9144", + "attachment_start" : "2018-03-24T07:00:00.000Z" + }, + { + "deployment_id" : "9cb0b199-6dcf-40de-b316-43f93dda9c1a", + "critter_id" : "b1f36b20-13c5-42d5-be94-fc4b63e14210", + "attachment_start" : "2018-03-13T07:00:00.000Z" + }, + { + "deployment_id" : "1f4b4804-4d28-48ca-ba4e-5303030220e4", + "critter_id" : "7179fc43-64fd-4b89-99bc-53e7f70e7db7", + "attachment_start" : "2018-03-13T07:00:00.000Z" + }, + { + "deployment_id" : "235894a7-5aff-4941-b7b2-1d213172feea", + "critter_id" : "b49b39f1-a939-4120-b9fa-ce6e52370be3", + "attachment_start" : "2018-03-13T07:00:00.000Z" + }, + { + "deployment_id" : "2646d4b9-b716-4dd6-9d6e-0e2d6857af82", + "critter_id" : "85dd6a5d-efd4-4372-acbd-12a08d641f8b", + "attachment_start" : "2019-03-05T08:00:00.000Z" + }, + { + "deployment_id" : "30497e67-ec5a-4dec-b28c-cdd85599010f", + "critter_id" : "56bc8563-7572-40fb-935f-9dbce6a5dbe7", + "attachment_start" : "2019-03-04T08:00:00.000Z" + }, + { + "deployment_id" : "583bb40b-37bf-44cf-812e-13d6927d1c9d", + "critter_id" : "cb063250-06af-46d1-9647-762961b95b33", + "attachment_start" : "2017-03-23T07:00:00.000Z" + }, + { + "deployment_id" : "fe2d5717-1563-441d-a054-c8e808894025", + "critter_id" : "0c8ef8ef-110a-4e75-9adc-685f53934c5e", + "attachment_start" : "2017-03-23T07:00:00.000Z" + }, + { + "deployment_id" : "b1d8bcf6-da5e-4695-99b7-5b160583322c", + "critter_id" : "513024b2-abc4-473f-9d41-0260b722bafa", + "attachment_start" : "2017-03-23T07:00:00.000Z" + }, + { + "deployment_id" : "4fecbdbb-7fbf-42f4-8421-5d2b5be4aa0f", + "critter_id" : "85ed2625-fdac-44ef-92b5-a95b19295f17", + "attachment_start" : "2018-03-24T07:00:00.000Z" + }, + { + "deployment_id" : "4ab5af68-7b79-4bb2-aecf-5ae2cc94e1da", + "critter_id" : "c682f2dc-a1da-4e83-a017-8eb05588f2f5", + "attachment_start" : "2018-03-25T07:00:00.000Z" + }, + { + "deployment_id" : "020604dc-f54c-446c-9fc5-21d46cef01ef", + "critter_id" : "0455b26c-a3ac-4600-8456-89dfb863a966", + "attachment_start" : "2018-03-24T07:00:00.000Z" + }, + { + "deployment_id" : "83a9f356-ab73-4ab4-92c3-870c8565689e", + "critter_id" : "50225299-7f4b-4abf-98e1-9f36f5136dfd", + "attachment_start" : "2018-03-13T07:00:00.000Z" + }, + { + "deployment_id" : "dc96a6ec-a862-4476-806d-975a0e8ddae6", + "critter_id" : "64174a6c-c0fe-4e76-b3d7-cd6d1c98b736", + "attachment_start" : "2018-03-24T07:00:00.000Z" + }, + { + "deployment_id" : "e34725de-9d70-442f-bb81-17f4d7530c85", + "critter_id" : "c2cda58e-394c-4198-894e-6255079e3545", + "attachment_start" : "2018-03-13T07:00:00.000Z" + }, + { + "deployment_id" : "64919903-0284-4489-83f9-8aedf618d0d1", + "critter_id" : "4bceceea-5835-4a8c-a246-714eb786338a", + "attachment_start" : "2018-03-23T07:00:00.000Z" + }, + { + "deployment_id" : "64e254d0-c146-416e-967c-fbd3e6b6c18d", + "critter_id" : "d8d3272d-c465-4154-a954-0e7300975a3a", + "attachment_start" : "2018-03-26T07:00:00.000Z" + }, + { + "deployment_id" : "9cc23cda-2ecb-41e5-99c1-6c312c608a2e", + "critter_id" : "63d44cb6-c856-4181-ae97-ccc920ab6a0e", + "attachment_start" : "2018-03-14T07:00:00.000Z" + }, + { + "deployment_id" : "809cef60-9f77-45cd-b4d7-3c9240e16e14", + "critter_id" : "bd431d57-4538-4f8b-a16a-516fa23fa552", + "attachment_start" : "2018-03-13T07:00:00.000Z" + }, + { + "deployment_id" : "a15a377d-184c-411f-9d3d-73b0d5e44d3b", + "critter_id" : "b0b3673a-3103-4ace-b897-3e92a75ae0f7", + "attachment_start" : "2018-03-13T07:00:00.000Z" + }, + { + "deployment_id" : "ce11d576-4593-499a-a847-5083d4889b1b", + "critter_id" : "881ee2c7-689f-4a35-b8ef-74c5cff134fc", + "attachment_start" : "2018-03-23T07:00:00.000Z" + }, + { + "deployment_id" : "78febc50-ce9d-4307-a840-3af1e2948213", + "critter_id" : "419a4efb-d1bf-427f-a799-66b6a0adf4fb", + "attachment_start" : "2018-03-24T07:00:00.000Z" + }, + { + "deployment_id" : "8308be8a-5ec6-4ca4-a8bc-cf1128cf61e1", + "critter_id" : "69cad932-988c-412a-950c-f30b6e13a0e2", + "attachment_start" : "2018-02-13T08:00:00.000Z" + }, + { + "deployment_id" : "a2e938b2-b847-4270-b9e7-962d53db8c1e", + "critter_id" : "927eeedc-d0d7-42d0-a66b-5cd337e134b2", + "attachment_start" : "2018-03-24T07:00:00.000Z" + }, + { + "deployment_id" : "1dd23a91-ffbc-4ab7-bec0-c42a6b94a47b", + "critter_id" : "e78dac84-adab-48ff-b580-f58dfe7ebcc2", + "attachment_start" : "2018-03-24T07:00:00.000Z" + }, + { + "deployment_id" : "40a4f002-cc7a-4363-9fc3-34ab3a494e65", + "critter_id" : "c8a1f0c3-092d-4934-b3a5-aefb59541e21", + "attachment_start" : "2018-03-24T07:00:00.000Z" + }, + { + "deployment_id" : "1420f76f-7a82-4a87-a477-7d565bee9e54", + "critter_id" : "1bccd1e5-68af-44b3-a6af-627858db2546", + "attachment_start" : "2018-03-18T07:00:00.000Z" + }, + { + "deployment_id" : "7832efee-cc98-4101-9874-21ad2b90d170", + "critter_id" : "e33994df-b900-4ce1-b9ae-7da2e99348dd", + "attachment_start" : "2018-03-13T07:00:00.000Z" + }, + { + "deployment_id" : "6a742ccf-7f99-48c6-b2b5-53236d077d02", + "critter_id" : "c9084638-ce8a-4213-943a-89d57dc502d5", + "attachment_start" : "2018-03-24T07:00:00.000Z" + }, + { + "deployment_id" : "a857fc1f-4ed2-424b-a53b-7e8ff2f4ecf9", + "critter_id" : "759953e0-40d8-46c5-9ace-efcb06671653", + "attachment_start" : "2018-03-26T07:00:00.000Z" + }, + { + "deployment_id" : "4fa3042d-85bd-4c66-a8f2-d7300d68e580", + "critter_id" : "400c52a1-91fd-4ef1-981a-9338483b8e13", + "attachment_start" : "2018-03-23T07:00:00.000Z" + }, + { + "deployment_id" : "912ce552-1b01-4cfd-8c34-0caded7525d4", + "critter_id" : "bf546391-5b1c-4410-bd00-4360842fa35c", + "attachment_start" : "2018-03-26T07:00:00.000Z" + }, + { + "deployment_id" : "00528e48-4ba3-4210-874d-cbcbcb5e2ff2", + "critter_id" : "f62eb919-a9af-4c85-b389-d22b69d230ce", + "attachment_start" : "2018-03-23T07:00:00.000Z" + }, + { + "deployment_id" : "7220fcf1-6475-406c-bc7c-fa41c8effe3e", + "critter_id" : "8acfe0ea-3692-42e7-b96d-be68c192fefb", + "attachment_start" : "2018-03-13T07:00:00.000Z" + }, + { + "deployment_id" : "23a600b8-8898-4a37-b2f5-443e33160f44", + "critter_id" : "f5f41636-88b9-4f7a-ba25-420b2b225e8a", + "attachment_start" : "2018-03-13T07:00:00.000Z" + }, + { + "deployment_id" : "ca5d0c45-aa34-4bb9-a913-5f23bcdcf580", + "critter_id" : "cdb3f79d-ba83-47f5-8bd4-b578291565c2", + "attachment_start" : "2023-10-11T07:00:00.000Z" + }, + { + "deployment_id" : "f065e76e-c445-4cc4-8eab-8f9391b16860", + "critter_id" : "26a1d72d-1cf2-486f-8c7e-3fd5838df58a", + "attachment_start" : "2019-03-04T08:00:00.000Z" + }, + { + "deployment_id" : "8bbcf117-82d9-4a49-84ec-cfd23a502d71", + "critter_id" : "d1eff088-b3df-471c-8c8a-d892cf3827fa", + "attachment_start" : "2019-03-05T08:00:00.000Z" + }, + { + "deployment_id" : "5bec0598-1634-4887-bb3f-546af45cb5b5", + "critter_id" : "7249a172-22d0-475b-841b-1a6e91590ea2", + "attachment_start" : "2019-03-07T08:00:00.000Z" + }, + { + "deployment_id" : "8b7397e5-abf5-4597-a74b-8130132e5576", + "critter_id" : "3808de8f-b39f-4177-b84a-171ffd83590e", + "attachment_start" : "2019-04-05T07:00:00.000Z" + }, + { + "deployment_id" : "afda6f70-3587-4acb-8e7e-16e747e2599d", + "critter_id" : "63f40e34-93d2-4814-84eb-569f24a831ba", + "attachment_start" : "2019-03-14T07:00:00.000Z" + }, + { + "deployment_id" : "7bdba909-98f4-4dfc-90b9-b3d4c8d2b2ca", + "critter_id" : "b7609e58-1f12-4d58-be84-092907c2e412", + "attachment_start" : "2019-04-03T07:00:00.000Z" + }, + { + "deployment_id" : "e54eebbd-a444-4a59-831d-bd7430ee9684", + "critter_id" : "5d6938ff-eeac-4fcd-8514-9981457f4a00", + "attachment_start" : "2019-04-03T07:00:00.000Z" + }, + { + "deployment_id" : "f77e9a99-c5b0-4398-a5d0-ec5292b06d3d", + "critter_id" : "b0c4c444-1004-4290-b65c-e2592af241f8", + "attachment_start" : "2019-04-03T07:00:00.000Z" + }, + { + "deployment_id" : "813c69fb-fab0-4bf8-82b7-18e07e95e446", + "critter_id" : "55370fb4-b5e0-4a34-920d-7bdea28bc0bc", + "attachment_start" : "2019-04-03T07:00:00.000Z" + }, + { + "deployment_id" : "44e231bc-ba0c-423a-9093-561e866e5648", + "critter_id" : "49f9d01e-1e9d-4d4e-8ff8-0196814e34f4", + "attachment_start" : "2019-04-03T07:00:00.000Z" + }, + { + "deployment_id" : "27e968d8-1148-46b5-a70d-193628c5553a", + "critter_id" : "9fd3dae9-c021-48fd-b97d-f601ceb7d03d", + "attachment_start" : "2019-04-03T07:00:00.000Z" + }, + { + "deployment_id" : "98535903-f055-47aa-bb77-b31b49de04bd", + "critter_id" : "5a65f778-5ee2-4269-a9c3-9c1475117bd5", + "attachment_start" : "2019-04-03T07:00:00.000Z" + }, + { + "deployment_id" : "7c757a07-d6f6-461a-9648-8ac45c3d50c7", + "critter_id" : "1a36c6a7-60e7-41ba-b7e3-7a381aab1ff2", + "attachment_start" : "2019-04-03T07:00:00.000Z" + }, + { + "deployment_id" : "96565329-8cf6-4804-806e-713b0ac17d3b", + "critter_id" : "5888829c-8ab7-4e52-9f82-15e2fded4d49", + "attachment_start" : "2019-03-04T08:00:00.000Z" + }, + { + "deployment_id" : "4457f53f-4caa-4dec-aa90-2d11b1b056d6", + "critter_id" : "2aa64657-e021-42cc-8cdc-9874f01421b2", + "attachment_start" : "2019-03-04T08:00:00.000Z" + }, + { + "deployment_id" : "63228cec-5cdb-4c4c-badf-ebdbb43ae127", + "critter_id" : "6247dd33-bbd4-4d00-9efc-3d7c7620da14", + "attachment_start" : "2019-03-05T08:00:00.000Z" + }, + { + "deployment_id" : "99a7cedd-d4e0-4105-809d-47c093f54f40", + "critter_id" : "7f78d56e-187a-4508-b1f9-00ed180fb463", + "attachment_start" : "2019-04-03T07:00:00.000Z" + }, + { + "deployment_id" : "106e45af-f813-4bd2-b31c-05644f8bf46e", + "critter_id" : "1c8f6887-fbd1-41fe-92e1-ce646fe779e6", + "attachment_start" : "2019-04-03T07:00:00.000Z" + }, + { + "deployment_id" : "4e1433fd-bc2c-47c2-9f22-cd460d315faa", + "critter_id" : "024e58f7-515d-4f10-81b4-66eeddca3750", + "attachment_start" : "2020-12-15T08:00:00.000Z" + }, + { + "deployment_id" : "e5f3e0d5-0f9a-4515-a4db-525fc2d1d6b1", + "critter_id" : "4b7d4d1a-df2c-4281-a5a0-bd10fb23526f", + "attachment_start" : "2020-10-31T07:00:00.000Z" + }, + { + "deployment_id" : "c45e943b-c535-4c9c-a9a3-fe27fa796b0a", + "critter_id" : "e9c3f951-3241-43fd-bbba-5282959a8495", + "attachment_start" : "2020-10-30T07:00:00.000Z" + }, + { + "deployment_id" : "df7ceb2b-bbc2-4f3d-910e-301031e4150e", + "critter_id" : "fff2c9a0-5822-48be-a726-ecba11ca5031", + "attachment_start" : "2020-10-30T07:00:00.000Z" + }, + { + "deployment_id" : "fe32b25d-445b-4e84-930c-d6554ca33d67", + "critter_id" : "051bad4a-35b6-4d98-aee2-f6656e5094f4", + "attachment_start" : "2020-10-30T07:00:00.000Z" + }, + { + "deployment_id" : "d65bdd6b-aca7-43b5-926c-1972ad785a0d", + "critter_id" : "3b8631e1-e453-47f6-a3c4-b4c6e0f1bd37", + "attachment_start" : "2020-10-30T07:00:00.000Z" + }, + { + "deployment_id" : "5016f3dc-a9fc-4821-bb5d-35f10a609988", + "critter_id" : "c4f72d18-517e-47e6-8799-0345ffce5489", + "attachment_start" : "2020-10-29T07:00:00.000Z" + }, + { + "deployment_id" : "da7d57c4-4107-41c0-a311-e95215a16a2d", + "critter_id" : "dab3dead-5cab-4263-8f5a-2252e326a89e", + "attachment_start" : "2020-10-31T07:00:00.000Z" + }, + { + "deployment_id" : "e90f96ce-995d-4592-8b5e-7da3ae573e15", + "critter_id" : "5b6eaed2-668d-4191-88ab-7292b8c44157", + "attachment_start" : "2020-10-29T07:00:00.000Z" + }, + { + "deployment_id" : "270b6666-53d7-4721-a8a8-204dbe0ec76f", + "critter_id" : "27684a97-4ee9-48dc-be5a-f9fac532e287", + "attachment_start" : "2020-10-30T07:00:00.000Z" + }, + { + "deployment_id" : "4d376249-5570-475d-8732-4ca5a7659482", + "critter_id" : "1dc8b66e-6900-4c93-a6e2-ecef018467d6", + "attachment_start" : "2020-10-31T07:00:00.000Z" + }, + { + "deployment_id" : "eb90f93c-fb23-4a57-aaf5-170f5aa21e01", + "critter_id" : "faec79f6-4a7b-44df-826e-287ff1773029", + "attachment_start" : "2020-10-29T07:00:00.000Z" + }, + { + "deployment_id" : "96e8d796-551f-4ce3-b54f-89d574b376a0", + "critter_id" : "e49bfb01-9f97-477f-8ef5-4abf7e6d7092", + "attachment_start" : "2020-10-30T07:00:00.000Z" + }, + { + "deployment_id" : "a723bb8b-3e93-45df-b4be-e209a976a7f0", + "critter_id" : "88997438-8477-4265-a4dc-0bb16ab16018", + "attachment_start" : "2020-02-13T20:00:00.000Z" + }, + { + "deployment_id" : "645e1be8-b753-4781-94b4-2c31a2dd6b8b", + "critter_id" : "818ceccb-0351-4d38-8d9c-3be61a66c690", + "attachment_start" : "2016-02-08T08:00:00.000Z" + }, + { + "deployment_id" : "08793fe0-71d5-4f62-9c34-196b1b8cc5d9", + "critter_id" : "4012087a-6fa0-4c0e-bcf5-0fd0cdda5b4f", + "attachment_start" : "2016-02-08T08:00:00.000Z" + }, + { + "deployment_id" : "6a83a438-2648-42d8-b86c-dbb5d2372c25", + "critter_id" : "1a9f5cb1-eff2-4947-a288-afe6c973c151", + "attachment_start" : "2019-03-26T07:00:00.000Z" + }, + { + "deployment_id" : "90e9963b-f0fd-4176-af3b-0ded43ce0442", + "critter_id" : "30def5a9-cb65-4a68-b5df-6ac3ba20df76", + "attachment_start" : "2018-03-07T08:00:00.000Z" + }, + { + "deployment_id" : "5fe9f1ef-d501-49fd-9d75-d4e0a7221032", + "critter_id" : "20212525-78a7-4c70-9e15-994b258c27f6", + "attachment_start" : "2018-03-07T08:00:00.000Z" + }, + { + "deployment_id" : "c558e75e-0f00-4ec6-bbd1-abe9bdc83ebf", + "critter_id" : "798cfb20-a7ef-4a2c-ad72-25b5ef63639e", + "attachment_start" : "2018-02-18T08:00:00.000Z" + }, + { + "deployment_id" : "23818ec7-8348-4ecc-9992-b6106024da4d", + "critter_id" : "a0ae699e-88e9-42b3-9334-21e206a81495", + "attachment_start" : "2016-02-08T08:00:00.000Z" + }, + { + "deployment_id" : "f6026e23-da6a-4ce7-ac6f-8bd591a4e442", + "critter_id" : "88be15fa-e771-44fc-9c63-c8198549f060", + "attachment_start" : "2016-02-08T08:00:00.000Z" + }, + { + "deployment_id" : "aef4fe91-575b-421c-a180-b930d0031c48", + "critter_id" : "d84d5192-5cc1-4fad-a619-cd76597b7164", + "attachment_start" : "2016-02-08T08:00:00.000Z" + }, + { + "deployment_id" : "718a378f-ee7f-4897-ae2d-7525b2f9662c", + "critter_id" : "49aaec31-009b-49ae-9d0b-cb0638089a59", + "attachment_start" : "2016-02-09T08:00:00.000Z" + }, + { + "deployment_id" : "d812ad18-f5f3-4ebe-8b6b-fba68240c379", + "critter_id" : "90159364-2296-4a93-b901-7b6d1c2b7c78", + "attachment_start" : "2016-02-08T08:00:00.000Z" + }, + { + "deployment_id" : "8b7df84f-efe1-40f6-b758-39bf129c7ddf", + "critter_id" : "869bbfdb-8d8c-4c81-95d7-f67ed51cbe27", + "attachment_start" : "2016-02-08T08:00:00.000Z" + }, + { + "deployment_id" : "6c22d917-325b-4127-bd0d-2ff7c6a21a8d", + "critter_id" : "d22efa1d-971f-48cf-9643-dc1431c4dcd7", + "attachment_start" : "2017-02-08T08:00:00.000Z" + }, + { + "deployment_id" : "fd7fcd6f-08b4-41e4-a2a1-37a61da806c1", + "critter_id" : "8fa2d657-fd82-4930-a38f-a95374bd9bc4", + "attachment_start" : "2016-02-11T08:00:00.000Z" + }, + { + "deployment_id" : "741465ed-d177-48ed-a9c6-d37476bdea5d", + "critter_id" : "d9a04d94-a21f-4e33-8ae7-a97dcda61262", + "attachment_start" : "2016-02-10T08:00:00.000Z" + }, + { + "deployment_id" : "33b43ac3-dcef-4149-9269-e62e9e8f0322", + "critter_id" : "bdc9d48d-ed5e-4bfe-863c-16dbad8aaa06", + "attachment_start" : "2017-02-08T08:00:00.000Z" + }, + { + "deployment_id" : "93575f4a-e1d8-4d79-936b-5c158f1bd476", + "critter_id" : "75529e7b-bae9-4b99-8f60-dfb4c2f1967e", + "attachment_start" : "2017-02-08T08:00:00.000Z" + }, + { + "deployment_id" : "a8ba7f4f-78f9-41be-89eb-5592f241fe22", + "critter_id" : "798b852d-ee0c-47f9-9747-f035408b2cbf", + "attachment_start" : "2017-02-08T08:00:00.000Z" + }, + { + "deployment_id" : "1ca7a87a-c6ea-45f2-9fc2-adfd2004056a", + "critter_id" : "a45704af-f21b-4bd3-96e8-4a5feaf3e8fa", + "attachment_start" : "2017-02-08T08:00:00.000Z" + }, + { + "deployment_id" : "fdaf51b9-07c6-467c-ad42-f900d2e53082", + "critter_id" : "70c9826f-44dd-4eba-94e7-31fc98189212", + "attachment_start" : "2018-03-28T07:00:00.000Z" + }, + { + "deployment_id" : "8ea7a71a-4ef0-43f6-9134-59497076db10", + "critter_id" : "c95969bc-2949-424c-969f-f00af9209bde", + "attachment_start" : "2018-03-07T08:00:00.000Z" + }, + { + "deployment_id" : "a273637a-29af-48ee-bd02-055a54191d1d", + "critter_id" : "754698b7-2067-44ff-b1e3-4b913b9b6ee8", + "attachment_start" : "2018-03-21T07:00:00.000Z" + }, + { + "deployment_id" : "99b4a9e9-422e-4185-bbaf-96b02991ce67", + "critter_id" : "c5ce259e-df92-42be-9a18-4f23f6346e49", + "attachment_start" : "2019-03-26T07:00:00.000Z" + }, + { + "deployment_id" : "d8edd9ab-57c0-4963-aefc-640342e4e398", + "critter_id" : "5d48da20-43a0-4951-a6cf-f1e3ed5cc51a", + "attachment_start" : "2019-03-25T07:00:00.000Z" + }, + { + "deployment_id" : "da2b8546-ea17-405e-b267-183a9fc39707", + "critter_id" : "0caba68b-bb96-471d-9712-4661d4737ed4", + "attachment_start" : "2019-03-26T07:00:00.000Z" + }, + { + "deployment_id" : "24e784e3-76ea-45c9-a24d-4936d397ad47", + "critter_id" : "4bd8fe08-f0e1-41fd-99b3-494fab00a763", + "attachment_start" : "2023-03-03T08:00:00.000Z" + }, + { + "deployment_id" : "5d851295-82f9-4dce-81a1-6bedbde80a1d", + "critter_id" : "4bc23d12-da38-4ff0-bd0c-3ae3d6a9d7b8", + "attachment_start" : "2023-01-01T08:00:00.000Z" + }, + { + "deployment_id" : "491ad36f-66d0-4d3f-b26d-23c628dff689", + "critter_id" : "3d8b980b-1f9c-4c72-bf6f-a5a29d0f1d07", + "attachment_start" : "2023-01-01T08:00:00.000Z" + }, + { + "deployment_id" : "02c150e8-4702-42f4-98e1-90b3f9ccf684", + "critter_id" : "d3af091d-db6b-4f45-916d-d1896309ceed", + "attachment_start" : "2023-02-02T08:00:00.000Z" + }, + { + "deployment_id" : "4aecd133-5869-400d-ac1a-e8f4a4562fbf", + "critter_id" : "4bd8fe08-f0e1-41fd-99b3-494fab00a763", + "attachment_start" : "2023-03-03T08:00:00.000Z" + }, + { + "deployment_id" : "6099b7af-e232-4106-9988-be06ae39de2f", + "critter_id" : "4bc23d12-da38-4ff0-bd0c-3ae3d6a9d7b8", + "attachment_start" : "2023-01-01T08:00:00.000Z" + }, + { + "deployment_id" : "40c7e0f8-8f82-47cf-af28-6d17142c1e5a", + "critter_id" : "9444be1f-08da-41b8-a462-cbc09aa7d856", + "attachment_start" : "2023-09-06T07:00:00.000Z" + }, + { + "deployment_id" : "87d75edc-ba7f-476e-ad44-ad1360b0e826", + "critter_id" : "87765efd-e65a-4057-bd86-2814044e17e2", + "attachment_start" : "2017-01-02T08:00:00.000Z" + }, + { + "deployment_id" : "5744997c-80b4-4ccc-af8a-4a7a3decd6bc", + "critter_id" : "333821fc-240e-49af-801f-226872cc841d", + "attachment_start" : "2023-09-10T07:00:00.000Z" + }, + { + "deployment_id" : "d5d0540a-8e52-42c7-92e8-6ae030724f5b", + "critter_id" : "61193b47-172d-467d-a6b7-d237b2ff07c4", + "attachment_start" : "2023-09-12T07:00:00.000Z" + }, + { + "deployment_id" : "63f4fb9a-94f3-4998-8f2d-3dc5ddc0f369", + "critter_id" : "e0339891-8e3c-4d79-adf2-b02f88e1c395", + "attachment_start" : "2023-09-14T07:00:00.000Z" + }, + { + "deployment_id" : "5c5d7a25-51a0-4830-a321-2b9335c97abd", + "critter_id" : "e0339891-8e3c-4d79-adf2-b02f88e1c395", + "attachment_start" : "2023-09-12T07:00:00.000Z" + }, + { + "deployment_id" : "46836d82-34d3-48ca-963c-564010354749", + "critter_id" : "1beafad8-e5cd-492d-82a0-ba457f6c3a74", + "attachment_start" : "2023-09-20T07:00:00.000Z" + }, + { + "deployment_id" : "c6820716-b098-40ee-8d91-33424bb53a0d", + "critter_id" : "af6d2b96-d719-4f18-af43-66dab7d80be5", + "attachment_start" : "2022-02-22T08:00:00.000Z" + }, + { + "deployment_id" : "d37ddad8-3d8a-4731-9c4c-9a61e7b8e5cd", + "critter_id" : "af6d2b96-d719-4f18-af43-66dab7d80be5", + "attachment_start" : "2023-09-24T07:00:00.000Z" + }, + { + "deployment_id" : "118b4bf3-a509-414d-a29b-fa5a21d7d2fb", + "critter_id" : "82ee68d3-125f-4cf5-854b-2fe7c4705aab", + "attachment_start" : "2023-09-24T07:00:00.000Z" + }, + { + "deployment_id" : "39e41092-3816-4cd3-baad-ef4c7b307d4b", + "critter_id" : "d4bde303-645f-4872-9255-0f47480a00b3", + "attachment_start" : "2023-09-24T07:00:00.000Z" + }, + { + "deployment_id" : "571df1fd-7c77-410a-afc2-9f3b10c43586", + "critter_id" : "2d708a24-2e87-4f4e-b38c-c0cb17eaf5c5", + "attachment_start" : "2023-10-10T07:00:00.000Z" + }, + { + "deployment_id" : "3912e9c1-47dc-46bc-9343-fed5860af687", + "critter_id" : "c6b0a6c7-71ca-421a-96d6-1878fec07b05", + "attachment_start" : "2016-02-16T08:00:00.000Z" + }, + { + "deployment_id" : "f78b1cc3-fc76-4f6b-8a81-fc331e7f414d", + "critter_id" : "3d8b980b-1f9c-4c72-bf6f-a5a29d0f1d07", + "attachment_start" : "2023-01-01T08:00:00.000Z" + }, + { + "deployment_id" : "a8e2631b-c07a-4a88-bd24-fcb7552a44a1", + "critter_id" : "3d8b980b-1f9c-4c72-bf6f-a5a29d0f1d08", + "attachment_start" : "2023-01-01T08:00:00.000Z" + }, + { + "deployment_id" : "4e814dc9-483b-48b3-b413-f6755f7a9120", + "critter_id" : "64f6b29a-ca38-4758-b53e-a030bdca455f", + "attachment_start" : "2019-01-29T08:00:00.000Z" + }, + { + "deployment_id" : "e9df05fd-a7e4-44c0-bf4f-1a772199d125", + "critter_id" : "d14e84e6-6eed-496b-8c01-d78610ce9939", + "attachment_start" : "2021-01-01T08:00:00.000Z" + }, + { + "deployment_id" : "07da6d7d-ed4d-47ab-8ddf-976adfad7ea9", + "critter_id" : "87765efd-e65a-4057-bd86-2814044e17e2", + "attachment_start" : "2012-01-02T08:00:00.000Z" + }, + { + "deployment_id" : "a6919a81-4088-4e37-bb4e-5658ec64d6af", + "critter_id" : "333821fc-240e-49af-801f-226872cc841d", + "attachment_start" : "2023-09-11T07:00:00.000Z" + }, + { + "deployment_id" : "b69f7867-7aaf-4f76-831a-dfec36cc407f", + "critter_id" : "9444be1f-08da-41b8-a462-cbc09aa7d856", + "attachment_start" : "2023-09-03T07:00:00.000Z" + }, + { + "deployment_id" : "42b70e77-ea1c-4604-bcc0-c947aea8e85b", + "critter_id" : "3bc438fd-9aba-42af-9cd5-0a943296e96b", + "attachment_start" : "2023-09-20T07:00:00.000Z" + }, + { + "deployment_id" : "27fb81ca-dfaf-44a7-982f-6d6eaa766afd", + "critter_id" : "e0339891-8e3c-4d79-adf2-b02f88e1c395", + "attachment_start" : "2023-09-14T07:00:00.000Z" + }, + { + "deployment_id" : "bd665003-0e4b-4e49-83a6-987516d4710b", + "critter_id" : "68434729-d6a1-478e-ba5e-57f2066f7ed6", + "attachment_start" : "2023-09-19T07:00:00.000Z" + }, + { + "deployment_id" : "01a6f8af-7c49-4b9b-9e27-2a31d6cf4095", + "critter_id" : "af6d2b96-d719-4f18-af43-66dab7d80be5", + "attachment_start" : "2023-02-03T08:00:00.000Z" + }, + { + "deployment_id" : "3c044963-40ac-4d7a-86c2-2ffdd322ca5c", + "critter_id" : "af6d2b96-d719-4f18-af43-66dab7d80be5", + "attachment_start" : "2023-09-25T07:00:00.000Z" + }, + { + "deployment_id" : "e3d1e415-c524-4aae-9039-bd940be52a8a", + "critter_id" : "e56daf49-0206-4ad7-ae07-dbd2b2640b38", + "attachment_start" : "2023-09-26T07:00:00.000Z" + }, + { + "deployment_id" : "f019909d-c244-4ff3-8ff5-eaeb74701e4c", + "critter_id" : "d4bde303-645f-4872-9255-0f47480a00b3", + "attachment_start" : "2023-09-26T07:00:00.000Z" + }, + { + "deployment_id" : "2bbfffdf-30de-4ef6-96c4-88692e65deba", + "critter_id" : "fce4ad2e-f575-49a7-8337-cff466b26908", + "attachment_start" : "2023-09-27T07:00:00.000Z" + }, + { + "deployment_id" : "34363f19-2068-4946-9e5f-e083f6977472", + "critter_id" : "55fb92d6-dbce-45c8-8f05-21225045ea69", + "attachment_start" : "2023-10-10T07:00:00.000Z" + }, + { + "deployment_id" : "0bda45af-b8f9-41f7-93f0-5ac32b8d5c6b", + "critter_id" : "4404753c-c2f7-4cb3-b680-189da7ae73c3", + "attachment_start" : "2023-10-10T07:00:00.000Z" + }, + { + "deployment_id" : "adb00db8-0f1b-4cf3-b1b4-fd93a85b468a", + "critter_id" : "0de3b5f5-f09e-46be-a779-5ee4fdcb96fc", + "attachment_start" : "2013-10-01T07:00:00.000Z" + }, + { + "deployment_id" : "d2661cdf-20c0-4453-9a11-a9b2b416cc9b", + "critter_id" : "8cf30231-7d5f-4c89-bd2c-9cbd2e48f10f", + "attachment_start" : "2023-10-16T07:00:00.000Z" + }, + { + "deployment_id" : "d9fc080f-38cc-4b18-80b4-bae00fb1d412", + "critter_id" : "be6af320-e20f-4d2a-8aab-9f447e4f1338", + "attachment_start" : "2023-10-08T07:00:00.000Z" + }, + { + "deployment_id" : "9cf2d99b-9927-4d9c-b24d-eb5318f68e28", + "critter_id" : "d494f354-59ad-45cd-9dc8-f0724188cc89", + "attachment_start" : "2023-10-15T07:00:00.000Z" + }, + { + "deployment_id" : "1f319266-4417-4997-a53b-8e39435577c0", + "critter_id" : "d7bff264-609e-4d48-ac2e-c7f938ee4823", + "attachment_start" : "2022-02-02T08:00:00.000Z" + }, + { + "deployment_id" : "04e1623e-2591-4744-b53d-162e35f44f51", + "critter_id" : "826aa7b4-31cf-44d5-80cd-fc159e42d7e7", + "attachment_start" : "2018-10-24T07:00:00.000Z" + }, + { + "deployment_id" : "74cd58ba-dca3-4140-bc76-cc2ed1e8b5a2", + "critter_id" : "d6520c4a-9882-442d-9ae8-7bc3031a7716", + "attachment_start" : "2023-10-11T07:00:00.000Z" + }, + { + "deployment_id" : "46356972-a312-446a-978d-3d2b84ba0132", + "critter_id" : "b5a9b4c6-5aa8-4a2d-b988-e6a720055427", + "attachment_start" : "2023-10-29T07:00:00.000Z" + }, + { + "deployment_id" : "1b735ed9-2453-4321-afaa-275b8ce9a5d6", + "critter_id" : "222c73f2-a393-49fb-8150-ad7ff26f32ea", + "attachment_start" : "2023-10-30T07:00:00.000Z" + }, + { + "deployment_id" : "ed9e2b38-9447-4a74-bb06-0eb048fc6658", + "critter_id" : "816b3bf6-099a-4cd4-a51f-80dbde57f6b8", + "attachment_start" : "2023-10-29T07:00:00.000Z" + }, + { + "deployment_id" : "9f51aa65-c5c3-4b67-8a5e-8469a9899131", + "critter_id" : "ae844f4f-686b-4031-8393-5f53ec153ec0", + "attachment_start" : "2023-11-22T08:00:00.000Z" + }, + { + "deployment_id" : "71542f46-5d0f-42a2-93ce-708a4e52515c", + "critter_id" : "ce945741-9b88-4f0a-bd60-09ccf65bf4ef", + "attachment_start" : "2023-11-13T08:00:00.000Z" + }, + { + "deployment_id" : "b27045b9-dfcc-4789-8aa6-bb6f3512de7a", + "critter_id" : "5c639cf7-c8fd-48ac-b65b-ad867920c476", + "attachment_start" : "2023-09-13T07:00:00.000Z" + }, + { + "deployment_id" : "e5a59eb2-69e5-49fc-9726-80e5edc203bf", + "critter_id" : "945b3779-f4f4-4f7d-a161-b1ba43035c4a", + "attachment_start" : "2023-09-12T07:00:00.000Z" + }, + { + "deployment_id" : "48211ea2-bc0a-4608-8b2c-439ac6cde8a9", + "critter_id" : "c7f6b018-d912-4ba6-a38a-ea6554339883", + "attachment_start" : "2023-09-06T07:00:00.000Z" + }, + { + "deployment_id" : "8011dca5-fb85-4933-af62-e1e5afced5ab", + "critter_id" : "d25f9a00-6229-422d-8dcf-c60640c9ada8", + "attachment_start" : "2010-01-02T08:00:00.000Z" + }, + { + "deployment_id" : "699e9e63-f984-4f1f-b770-af952cb241ba", + "critter_id" : "7e161d58-bbeb-46d7-8f67-ccb342f8af39", + "attachment_start" : "2023-09-06T07:00:00.000Z" + }, + { + "deployment_id" : "5bad7986-108f-4233-b9e6-8f4f31cb3bb9", + "critter_id" : "1beafad8-e5cd-492d-82a0-ba457f6c3a74", + "attachment_start" : "2023-09-05T07:00:00.000Z" + }, + { + "deployment_id" : "7d11fa0c-91b3-4cac-89d2-b322c75bf574", + "critter_id" : "af6d2b96-d719-4f18-af43-66dab7d80be5", + "attachment_start" : "2020-02-20T08:00:00.000Z" + }, + { + "deployment_id" : "298a7a0e-eac4-42c1-acce-990421c60590", + "critter_id" : "af6d2b96-d719-4f18-af43-66dab7d80be5", + "attachment_start" : "2023-09-25T07:00:00.000Z" + }, + { + "deployment_id" : "f11d80fd-cf4d-4e33-94a8-b3b8d018269f", + "critter_id" : "05d5baee-7c9d-4622-9fa6-fe5cc64716f1", + "attachment_start" : "2023-09-12T07:00:00.000Z" + }, + { + "deployment_id" : "590d7fbd-858b-47d7-9a28-053b476f6429", + "critter_id" : "5163ce5d-c488-42fc-9077-8125d81bbb3e", + "attachment_start" : "2023-09-27T07:00:00.000Z" + }, + { + "deployment_id" : "81e2994d-b920-456f-8538-2a6221815a89", + "critter_id" : "05d5baee-7c9d-4622-9fa6-fe5cc64716f1", + "attachment_start" : "2023-09-26T07:00:00.000Z" + }, + { + "deployment_id" : "7332720b-d582-4382-bb4c-c521c675e19a", + "critter_id" : "03cb54df-97ab-453a-aa52-1e8106929983", + "attachment_start" : "2023-09-01T07:00:00.000Z" + }, + { + "deployment_id" : "3ef92176-765a-4122-9899-7476e4e49e23", + "critter_id" : "55fb92d6-dbce-45c8-8f05-21225045ea69", + "attachment_start" : "2023-10-03T07:00:00.000Z" + }, + { + "deployment_id" : "cdd57c30-952e-4524-bee2-f2b3c7e2a594", + "critter_id" : "e3f83993-ca2e-4522-b672-34f18f3b58fb", + "attachment_start" : "2023-10-01T07:00:00.000Z" + }, + { + "deployment_id" : "40619508-f35b-48cf-80bd-14e76531254f", + "critter_id" : "58e14abe-ed5b-4485-8f86-a7e44d49b517", + "attachment_start" : "2023-10-15T07:00:00.000Z" + }, + { + "deployment_id" : "2fe7a95f-76e9-44ac-89fe-6e7995f766a3", + "critter_id" : "d7bff264-609e-4d48-ac2e-c7f938ee4823", + "attachment_start" : "2023-10-19T07:00:00.000Z" + }, + { + "deployment_id" : "a04554b5-c7a3-432e-a938-89f81b419894", + "critter_id" : "24c729d1-7fff-453f-91d2-e590aca13abc", + "attachment_start" : "2023-10-25T07:00:00.000Z" + }, + { + "deployment_id" : "acffc536-716e-455e-ac2c-b56d041cea43", + "critter_id" : "59a33f28-0cca-44a3-8e40-8d7d1de2f968", + "attachment_start" : "2023-10-24T07:00:00.000Z" + }, + { + "deployment_id" : "e8407858-0196-4ea1-aafb-547dd56f73e6", + "critter_id" : "fc2e5a7c-4233-4a8a-a86a-28992af28ec3", + "attachment_start" : "2023-02-02T08:00:00.000Z" + }, + { + "deployment_id" : "e9d193d0-55d3-42d8-974d-d7398ef2a1ed", + "critter_id" : "850d6803-0dfc-45d8-9278-2ccdb43e5b37", + "attachment_start" : "2023-10-16T07:00:00.000Z" + }, + { + "deployment_id" : "0704ceb6-b306-4466-831c-6db247eadb69", + "critter_id" : "77209712-8986-47d1-9d03-41858fb25f1b", + "attachment_start" : "2023-11-01T07:00:00.000Z" + }, + { + "deployment_id" : "b1da8f20-ad4d-4b7e-952e-96a94622ed3a", + "critter_id" : "34bd2e88-e3de-4301-a382-c7ea32e7aeef", + "attachment_start" : "2023-11-15T08:00:00.000Z" + }, + { + "deployment_id" : "f4272834-4546-4bff-9028-32b0ff68ea0d", + "critter_id" : "93974613-1c4d-451f-b43b-c7e46ac9c928", + "attachment_start" : "2023-11-21T08:00:00.000Z" + }, + { + "deployment_id" : "9f22abfa-ec35-4990-9e00-a89d20e9988a", + "critter_id" : "2c51310c-5e6e-4888-9ef3-c5c9190de87b", + "attachment_start" : "2023-11-22T08:00:00.000Z" + }, + { + "deployment_id" : "c6691580-3f20-4c7a-bc09-c83db3ed1ec5", + "critter_id" : "a97cfecc-e810-4961-8656-e42666167a83", + "attachment_start" : "2023-11-22T08:00:00.000Z" + }, + { + "deployment_id" : "07e2cf20-04e7-4884-9ecc-9b6321b39061", + "critter_id" : "050a29f5-92a1-40a5-a2d2-75317bd00543", + "attachment_start" : "2023-11-15T08:00:00.000Z" + }, + { + "deployment_id" : "0d5246c1-bfcc-4641-8535-b3a91e1b4a41", + "critter_id" : "6f298eef-9fc6-4a0b-9f56-3b1c68f3dc13", + "attachment_start" : "2023-11-14T08:00:00.000Z" + }, + { + "deployment_id" : "44d85815-ec85-4bc8-afbc-517ce0a94af6", + "critter_id" : "a9cbe201-6a56-417e-9be0-ba447012de4e", + "attachment_start" : "2023-11-27T08:00:00.000Z" + }, + { + "deployment_id" : "e27722c1-70c5-411f-afbb-46518adf8488", + "critter_id" : "fb4a817d-d1d3-4aa6-a4b9-8b57484cb168", + "attachment_start" : "2023-11-16T08:00:00.000Z" + }, + { + "deployment_id" : "d563c67b-1a0f-43b8-85e2-1438cbe256ba", + "critter_id" : "f149fc48-29e9-4790-b3af-7a7c0dc727b8", + "attachment_start" : "2023-11-22T08:00:00.000Z" + }, + { + "deployment_id" : "ede0746d-38df-4cf4-9f19-7dc15a92350d", + "critter_id" : "98f9203b-0050-47e4-a0d6-c06d7011d463", + "attachment_start" : "2023-11-23T08:00:00.000Z" + }, + { + "deployment_id" : "a9e39a84-5be1-44ff-9704-cf414c145fed", + "critter_id" : "5e858662-a1ac-4254-8830-e316bce711c2", + "attachment_start" : "2023-11-24T08:00:00.000Z" + }, + { + "deployment_id" : "4ad868f0-45aa-4ad4-9a25-c599e17c27e7", + "critter_id" : "6ab23896-ae59-47b7-ad05-530bc9111cbf", + "attachment_start" : "2023-11-26T08:00:00.000Z" + }, + { + "deployment_id" : "82f75a40-53d3-4c83-b678-7c88135573a7", + "critter_id" : "327b9224-27a0-4301-baed-de0fc60ea4ee", + "attachment_start" : "2023-12-04T08:00:00.000Z" + }, + { + "deployment_id" : "8a4b0b41-1125-4581-99d1-28c5e3384314", + "critter_id" : "945b3779-f4f4-4f7d-a161-b1ba43035c4a", + "attachment_start" : "2023-09-02T07:00:00.000Z" + }, + { + "deployment_id" : "62b5b0d4-d081-4632-bd42-b688f31efe91", + "critter_id" : "b198a64e-2f74-41bb-88c8-356b2d3cd3ab", + "attachment_start" : "2023-09-14T07:00:00.000Z" + }, + { + "deployment_id" : "fb0c71b4-7cd7-478a-ad27-b51dcec8d0e5", + "critter_id" : "c7f6b018-d912-4ba6-a38a-ea6554339883", + "attachment_start" : "2023-09-08T07:00:00.000Z" + }, + { + "deployment_id" : "8ce7ca2e-c61c-4edc-bb60-fc8e5e87689e", + "critter_id" : "7e161d58-bbeb-46d7-8f67-ccb342f8af39", + "attachment_start" : "2023-09-06T07:00:00.000Z" + }, + { + "deployment_id" : "687cddbe-ae0b-4407-8a8b-809cbc647e18", + "critter_id" : "af6d2b96-d719-4f18-af43-66dab7d80be5", + "attachment_start" : "2023-09-21T07:00:00.000Z" + }, + { + "deployment_id" : "ec1cfad2-a18d-491d-b697-76b6df271ca3", + "critter_id" : "e56daf49-0206-4ad7-ae07-dbd2b2640b38", + "attachment_start" : "2023-09-25T07:00:00.000Z" + }, + { + "deployment_id" : "461c52dc-973f-4fcc-a0df-25744543742d", + "critter_id" : "1239ec04-2311-453a-87b6-ff8e29d0bde7", + "attachment_start" : "2023-09-27T07:00:00.000Z" + }, + { + "deployment_id" : "290d63ad-7fb5-4ccb-a0d6-9da22e083a2a", + "critter_id" : "7d6125fb-7459-428b-8d90-a259576c34f6", + "attachment_start" : "2023-10-01T07:00:00.000Z" + }, + { + "deployment_id" : "c9835230-74d4-48f3-81a0-1f370c207762", + "critter_id" : "60249924-5dae-4b23-b4cd-e3e4871eeeb5", + "attachment_start" : "2023-10-03T07:00:00.000Z" + }, + { + "deployment_id" : "907cd745-e9f3-4c04-ade2-d596972c211b", + "critter_id" : "93c0b273-98c2-4506-bac7-b28d3175565f", + "attachment_start" : "2023-10-08T07:00:00.000Z" + }, + { + "deployment_id" : "dd2b30c6-a708-45bb-abb9-a0cb250b6c9b", + "critter_id" : "55fb92d6-dbce-45c8-8f05-21225045ea69", + "attachment_start" : "2023-10-01T07:00:00.000Z" + }, + { + "deployment_id" : "d7ebf902-4bfc-410f-ba81-eb61f0b4728d", + "critter_id" : "9244d0f3-26e0-4837-8616-70f897ce75e8", + "attachment_start" : "2023-10-01T07:00:00.000Z" + }, + { + "deployment_id" : "be3efc5c-d7d9-4914-9359-1e7fe186de4d", + "critter_id" : "cdb3f79d-ba83-47f5-8bd4-b578291565c2", + "attachment_start" : "2023-10-10T07:00:00.000Z" + }, + { + "deployment_id" : "4d05a26a-79ad-4b52-8ac9-bf33964075aa", + "critter_id" : "c100e9d5-ce9b-459b-a5aa-2ca20c3d8067", + "attachment_start" : "2023-10-17T07:00:00.000Z" + }, + { + "deployment_id" : "2b5f4d07-6286-4eac-9a8b-5f1cdabb5903", + "critter_id" : "4943b82f-1db5-4ace-87f9-81af3d8789ee", + "attachment_start" : "2023-10-03T07:00:00.000Z" + }, + { + "deployment_id" : "e2c659b5-6644-4254-a283-21cb157100c9", + "critter_id" : "826aa7b4-31cf-44d5-80cd-fc159e42d7e7", + "attachment_start" : "2019-01-29T08:00:00.000Z" + }, + { + "deployment_id" : "c8ea60b8-3e4e-4050-9c4e-824ae44d2e2e", + "critter_id" : "826aa7b4-31cf-44d5-80cd-fc159e42d7e7", + "attachment_start" : "2018-02-13T08:00:00.000Z" + }, + { + "deployment_id" : "e0642f4e-967c-43e8-8d73-e63c30b6816d", + "critter_id" : "826aa7b4-31cf-44d5-80cd-fc159e42d7e7", + "attachment_start" : "2023-10-01T07:00:00.000Z" + }, + { + "deployment_id" : "9d1f607a-ac7d-4804-9fa3-21d2bc5fa0db", + "critter_id" : "6e6562e4-6237-47a3-8fdf-089ccdd4049c", + "attachment_start" : "2024-02-14T08:00:00.000Z" + }, + { + "deployment_id" : "0899a7b4-2c19-450f-901c-a1235f0e79e2", + "critter_id" : "bda29fa0-2f98-4e6e-89e1-e07b5209e081", + "attachment_start" : "2023-10-17T07:00:00.000Z" + }, + { + "deployment_id" : "63ff282f-fc7e-49e5-be24-dcad913d6b0b", + "critter_id" : "850d6803-0dfc-45d8-9278-2ccdb43e5b37", + "attachment_start" : "2023-10-01T07:00:00.000Z" + }, + { + "deployment_id" : "252214b3-4565-4757-a5df-0fd818977721", + "critter_id" : "77209712-8986-47d1-9d03-41858fb25f1b", + "attachment_start" : "2023-11-01T07:00:00.000Z" + }, + { + "deployment_id" : "6df36ecf-c1da-4e8e-b046-d3489ac17eb8", + "critter_id" : "d31120c1-e6a2-4af4-850f-d69ad6ce674f", + "attachment_start" : "2023-11-14T08:00:00.000Z" + }, + { + "deployment_id" : "2cd6d9ad-e5c2-4d4f-bfe4-3e00a1ff968f", + "critter_id" : "a97cfecc-e810-4961-8656-e42666167a83", + "attachment_start" : "2023-11-21T08:00:00.000Z" + }, + { + "deployment_id" : "e711ead3-3066-4fab-bdf0-f4bfc5c6b355", + "critter_id" : "050a29f5-92a1-40a5-a2d2-75317bd00543", + "attachment_start" : "2023-11-23T08:00:00.000Z" + }, + { + "deployment_id" : "e7b4c4d8-e377-45a1-9f0a-a6c9f747a5d5", + "critter_id" : "74a95455-f300-4746-ad9f-24784fcebe6d", + "attachment_start" : "2023-11-15T08:00:00.000Z" + }, + { + "deployment_id" : "17806079-0674-49f7-bb66-ee108f5d1e56", + "critter_id" : "eac1388d-cff7-434d-8df7-42a43f700365", + "attachment_start" : "2023-11-21T08:00:00.000Z" + }, + { + "deployment_id" : "09491fba-614a-4e70-be9d-f4d135151e1d", + "critter_id" : "ef3915d9-4aca-411a-833a-e3c722db4df9", + "attachment_start" : "2023-11-20T08:00:00.000Z" + }, + { + "deployment_id" : "a54d4c3c-6c4c-4baa-b2cb-ac396d85a52b", + "critter_id" : "f149fc48-29e9-4790-b3af-7a7c0dc727b8", + "attachment_start" : "2023-09-12T07:00:00.000Z" + }, + { + "deployment_id" : "99c9c1d7-6659-4025-8ad3-4c904cd8bb36", + "critter_id" : "e9718d85-1b4d-41fe-88ef-d8e2297c07e2", + "attachment_start" : "2023-11-12T08:00:00.000Z" + }, + { + "deployment_id" : "de2aebca-2d36-4501-aa52-29ccff052622", + "critter_id" : "e9718d85-1b4d-41fe-88ef-d8e2297c07e2", + "attachment_start" : "2023-11-23T08:00:00.000Z" + }, + { + "deployment_id" : "11b31609-5366-4043-aa1c-b0a01776fa12", + "critter_id" : "67b2ca8d-ea71-42ee-9f7e-98bdb7532ed3", + "attachment_start" : "2023-11-28T08:00:00.000Z" + }, + { + "deployment_id" : "00310cf8-d454-4eb1-b131-9a967345f7a8", + "critter_id" : "131f11d7-5f28-4ea5-9fc0-0c7f1e2993d6", + "attachment_start" : "2023-11-26T08:00:00.000Z" + }, + { + "deployment_id" : "5acb9f8a-864d-4d61-9d80-404ed9d430b1", + "critter_id" : "95dc5d41-a672-499a-9ddb-d1e363de33ed", + "attachment_start" : "2023-12-01T08:00:00.000Z" + }, + { + "deployment_id" : "18a1aadc-2519-4eeb-998a-400925758f4d", + "critter_id" : "4b5b3491-e7f3-44cb-ab6e-920c4e6f5f13", + "attachment_start" : "2023-12-01T08:00:00.000Z" + }, + { + "deployment_id" : "6ed1f89b-c530-4167-ae6a-ce7f54760eb3", + "critter_id" : "86ee25ab-c225-4207-be71-8ffc2d708777", + "attachment_start" : "2023-12-13T08:00:00.000Z" + }, + { + "deployment_id" : "0360d5ca-dab2-4b6b-a34e-5d0a01f08387", + "critter_id" : "34a334d8-1521-4d6f-ac47-a06281917bb9", + "attachment_start" : "2023-12-10T08:00:00.000Z" + }, + { + "deployment_id" : "640441f8-4ee6-4510-9106-97670cf5a656", + "critter_id" : "a0c87327-569b-4a81-a37e-77ce17cc9ef6", + "attachment_start" : "2023-12-12T08:00:00.000Z" + }, + { + "deployment_id" : "96200e17-b404-42a6-a73d-12fd036e7a42", + "critter_id" : "c5d80f07-21de-4923-b07b-a13c8cfb2ce8", + "attachment_start" : "2023-11-30T08:00:00.000Z" + }, + { + "deployment_id" : "8f1d4d45-e1d7-448e-9430-c1c706eba86a", + "critter_id" : "fc896072-a525-46e1-bce3-b72c68522316", + "attachment_start" : "2024-01-21T08:00:00.000Z" + }, + { + "deployment_id" : "8a36134f-12b6-403b-922b-ade48c26b995", + "critter_id" : "21f2adf7-7cec-45d7-8a03-bc1c320d4f5b", + "attachment_start" : "2023-09-08T07:00:00.000Z" + }, + { + "deployment_id" : "f55bee6d-0769-42ac-bb31-45cf9bcbfb99", + "critter_id" : "af6d2b96-d719-4f18-af43-66dab7d80be5", + "attachment_start" : "2023-09-25T07:00:00.000Z" + }, + { + "deployment_id" : "89ba1f96-a7f8-48c5-b408-7480b2ba64b9", + "critter_id" : "4b9229d8-cf68-45c1-9945-c6f2cfa2d78e", + "attachment_start" : "2023-09-24T07:00:00.000Z" + }, + { + "deployment_id" : "28c1420e-f717-48b6-be63-72a39050cc4b", + "critter_id" : "5163ce5d-c488-42fc-9077-8125d81bbb3e", + "attachment_start" : "2023-09-27T07:00:00.000Z" + }, + { + "deployment_id" : "80f67f8b-cfa3-49f3-aa5a-c766a24ecc35", + "critter_id" : "1239ec04-2311-453a-87b6-ff8e29d0bde7", + "attachment_start" : "2023-09-20T07:00:00.000Z" + }, + { + "deployment_id" : "78a18125-60b0-4e6f-9b87-8ad8355fce0a", + "critter_id" : "7d6125fb-7459-428b-8d90-a259576c34f6", + "attachment_start" : "2023-10-02T07:00:00.000Z" + }, + { + "deployment_id" : "1d8857a8-5157-4481-aede-ea5e9327ca64", + "critter_id" : "2ec17d8a-8edd-4adc-8cff-622642fd1a54", + "attachment_start" : "2023-10-08T07:00:00.000Z" + }, + { + "deployment_id" : "dc7a78fd-baf0-4735-8e11-bece1b954dbd", + "critter_id" : "8cca8977-10fc-4aae-b05c-9c12fa22e903", + "attachment_start" : "2023-10-06T07:00:00.000Z" + }, + { + "deployment_id" : "9b622966-1dcd-42e7-8fba-ec1ee588a850", + "critter_id" : "8cca8977-10fc-4aae-b05c-9c12fa22e903", + "attachment_start" : "2023-10-23T07:00:00.000Z" + }, + { + "deployment_id" : "9746476a-36b3-45f9-8eaa-f9d9090cbd1d", + "critter_id" : "fdcf3454-935e-4d72-847d-71cd1ff3f2ce", + "attachment_start" : "2023-09-01T07:00:00.000Z" + }, + { + "deployment_id" : "fbef2e1f-ca26-47b9-bd6e-d561376deb3c", + "critter_id" : "cdb3f79d-ba83-47f5-8bd4-b578291565c2", + "attachment_start" : "2023-10-01T07:00:00.000Z" + }, + { + "deployment_id" : "41b38e87-7568-495b-9cdc-d3bdc10d2688", + "critter_id" : "f3de0365-5032-411b-9c01-0705a3271081", + "attachment_start" : "2023-10-09T07:00:00.000Z" + }, + { + "deployment_id" : "8181bc3e-f65a-47a4-ab69-246e249765c5", + "critter_id" : "bda29fa0-2f98-4e6e-89e1-e07b5209e081", + "attachment_start" : "2023-10-02T07:00:00.000Z" + }, + { + "deployment_id" : "e84d5d6f-373e-4637-8dec-56666cbd7fb2", + "critter_id" : "850d6803-0dfc-45d8-9278-2ccdb43e5b37", + "attachment_start" : "2023-10-01T07:00:00.000Z" + }, + { + "deployment_id" : "d530f70a-6f75-4a5d-96e7-c47d941155bc", + "critter_id" : "050a29f5-92a1-40a5-a2d2-75317bd00543", + "attachment_start" : "2023-11-29T08:00:00.000Z" + }, + { + "deployment_id" : "f8d95e7b-853f-4b4b-8b79-2327c7f95cbb", + "critter_id" : "32a32d16-d3c5-44f9-b5ae-e430d98b103a", + "attachment_start" : "2023-11-21T08:00:00.000Z" + }, + { + "deployment_id" : "15c70aa7-2fbf-40cf-9e94-b77a8200dfad", + "critter_id" : "32a32d16-d3c5-44f9-b5ae-e430d98b103a", + "attachment_start" : "2023-11-21T08:00:00.000Z" + }, + { + "deployment_id" : "afe041b2-24fc-4b19-a8bd-d9c44b600d45", + "critter_id" : "69ba9b4d-ad64-41f6-8ccc-6440e563eb96", + "attachment_start" : "2023-11-29T08:00:00.000Z" + }, + { + "deployment_id" : "4961660a-65d8-49ec-a397-b738f4db175c", + "critter_id" : "86ee25ab-c225-4207-be71-8ffc2d708777", + "attachment_start" : "2023-11-01T07:00:00.000Z" + }, + { + "deployment_id" : "3829116e-6784-4d9c-81ae-c5adf98d902f", + "critter_id" : "8180707a-cb08-4cea-a469-ca37d7c7169d", + "attachment_start" : "2023-12-11T08:00:00.000Z" + }, + { + "deployment_id" : "67675d1d-81c2-46a0-bd8b-a99696b60946", + "critter_id" : "2325718a-03c5-4866-98a4-aa8083d64924", + "attachment_start" : "2023-12-19T08:00:00.000Z" + }, + { + "deployment_id" : "81778c1b-0f5c-4c48-8b06-80090c47ff2e", + "critter_id" : "d15aa3e5-84c3-4ec9-a3f5-a8b9b108273c", + "attachment_start" : "2023-12-01T08:00:00.000Z" + }, + { + "deployment_id" : "27592220-03c8-45ec-9c6d-ba06fb195654", + "critter_id" : "410d7417-fe72-47ad-9b45-828feaba12cf", + "attachment_start" : "2023-12-01T08:00:00.000Z" + }, + { + "deployment_id" : "4b24c266-efd8-4a6d-868c-bf34cad67258", + "critter_id" : "b034ab5a-bde2-4884-a52a-ceb0da74ee92", + "attachment_start" : "2024-01-15T08:00:00.000Z" + }, + { + "deployment_id" : "1b0b3242-73ce-40f8-949f-7c7a587c9d18", + "critter_id" : "2e182d13-5e43-46b5-9b71-5ba398d9f7e8", + "attachment_start" : "2024-01-24T08:00:00.000Z" + }, + { + "deployment_id" : "93f94158-6d6d-46de-a793-283699af3c6e", + "critter_id" : "7054aeb6-72f7-4926-a80f-ed10eba9d3db", + "attachment_start" : "2024-01-23T08:00:00.000Z" + }, + { + "deployment_id" : "24369da6-05f9-4d2a-a6cf-262adf64f88a", + "critter_id" : "7e161d58-bbeb-46d7-8f67-ccb342f8af39", + "attachment_start" : "2023-09-14T07:00:00.000Z" + }, + { + "deployment_id" : "b0ed1054-6af6-4f34-9252-ca179e39b5fb", + "critter_id" : "7e161d58-bbeb-46d7-8f67-ccb342f8af39", + "attachment_start" : "2023-09-05T07:00:00.000Z" + }, + { + "deployment_id" : "75d64ff8-8ebe-4dcf-8cf1-a13aeaaa4233", + "critter_id" : "7e161d58-bbeb-46d7-8f67-ccb342f8af39", + "attachment_start" : "2023-09-15T07:00:00.000Z" + }, + { + "deployment_id" : "0b7b534d-ca79-4db4-b008-179c6c541d12", + "critter_id" : "26b47fe9-44c2-4474-b7bc-e85839ec91f6", + "attachment_start" : "2023-09-18T07:00:00.000Z" + }, + { + "deployment_id" : "b77400c8-67ed-432e-829c-ae9006e7f3b1", + "critter_id" : "af6d2b96-d719-4f18-af43-66dab7d80be5", + "attachment_start" : "2023-09-25T07:00:00.000Z" + }, + { + "deployment_id" : "e6d513c8-fd29-4af2-b410-425bf6f5d58d", + "critter_id" : "19e80e58-0dc3-41a0-b3f7-481a659d0452", + "attachment_start" : "2023-09-25T07:00:00.000Z" + }, + { + "deployment_id" : "128a439a-72f3-45e9-b913-dd51ffb27231", + "critter_id" : "5163ce5d-c488-42fc-9077-8125d81bbb3e", + "attachment_start" : "2023-09-27T07:00:00.000Z" + }, + { + "deployment_id" : "442e443e-20ca-47ff-b7e5-8727e72c98aa", + "critter_id" : "1239ec04-2311-453a-87b6-ff8e29d0bde7", + "attachment_start" : "2023-09-25T07:00:00.000Z" + }, + { + "deployment_id" : "5444f13a-37ac-4c3e-a117-972241cf7fea", + "critter_id" : "7d6125fb-7459-428b-8d90-a259576c34f6", + "attachment_start" : "2023-10-02T07:00:00.000Z" + }, + { + "deployment_id" : "95075c99-2c5f-485a-b37b-d4c76407db47", + "critter_id" : "2ec17d8a-8edd-4adc-8cff-622642fd1a54", + "attachment_start" : "2023-10-12T07:00:00.000Z" + }, + { + "deployment_id" : "63590c64-2089-4fe6-b947-8911c3f59f1c", + "critter_id" : "bda29fa0-2f98-4e6e-89e1-e07b5209e081", + "attachment_start" : "2023-10-17T07:00:00.000Z" + }, + { + "deployment_id" : "d2fc8104-8654-4c07-b40e-4c6a2e2116fc", + "critter_id" : "67420ac3-fd0b-44fe-9aea-8d01b799a00f", + "attachment_start" : "2023-10-01T07:00:00.000Z" + }, + { + "deployment_id" : "937e1f89-ad35-4dc3-88a8-e5fb9c329863", + "critter_id" : "a82bc327-5a87-427d-8516-35e92d6026d5", + "attachment_start" : "2023-11-15T08:00:00.000Z" + }, + { + "deployment_id" : "613294ae-8ed8-49f9-b95a-b0d57b0d9ff2", + "critter_id" : "a97cfecc-e810-4961-8656-e42666167a83", + "attachment_start" : "2023-11-22T08:00:00.000Z" + }, + { + "deployment_id" : "323555c7-b451-4afc-a7cd-8fc052fac7e1", + "critter_id" : "050a29f5-92a1-40a5-a2d2-75317bd00543", + "attachment_start" : "2023-11-25T08:00:00.000Z" + }, + { + "deployment_id" : "8c8e1221-867f-4faa-a2ba-d53fd233e7cb", + "critter_id" : "0e2fca91-c7d3-4cd4-a27a-74764c4d1ec0", + "attachment_start" : "2023-12-01T08:00:00.000Z" + }, + { + "deployment_id" : "646dd76c-0073-4333-a000-ced094380a60", + "critter_id" : "32a32d16-d3c5-44f9-b5ae-e430d98b103a", + "attachment_start" : "2023-11-27T08:00:00.000Z" + }, + { + "deployment_id" : "9033baf3-abdf-425b-b419-f58d54a87853", + "critter_id" : "de1c3cde-eca9-4ce3-8a8e-b9b94a86a197", + "attachment_start" : "2023-11-22T08:00:00.000Z" + }, + { + "deployment_id" : "6bed090b-12ea-4168-9bf8-41f50285d954", + "critter_id" : "8db41e9a-3f71-40f5-aabb-250211ca1106", + "attachment_start" : "2023-11-27T08:00:00.000Z" + }, + { + "deployment_id" : "eb6d1c92-d698-41ad-a831-b8bbe49da127", + "critter_id" : "a8364e3d-476d-4364-9467-095df46c7f2a", + "attachment_start" : "2023-12-23T08:00:00.000Z" + }, + { + "deployment_id" : "c611abec-dc51-48b8-b30b-7c70e27cd5bd", + "critter_id" : "d15aa3e5-84c3-4ec9-a3f5-a8b9b108273c", + "attachment_start" : "2023-11-01T07:00:00.000Z" + }, + { + "deployment_id" : "517b95e0-1f8a-46f0-8bd4-e94175ae3a0c", + "critter_id" : "0221cdf2-4589-4cfc-aa06-2af8baeb9fff", + "attachment_start" : "2024-01-09T08:00:00.000Z" + }, + { + "deployment_id" : "f97e9af4-bd40-45e2-bd76-822ced3739b8", + "critter_id" : "7054aeb6-72f7-4926-a80f-ed10eba9d3db", + "attachment_start" : "2024-01-03T08:00:00.000Z" + }, + { + "deployment_id" : "7f85b825-5608-4d1c-881e-6e487cfacddf", + "critter_id" : "68087784-bb9f-41ba-8d6e-7851d03e3bd2", + "attachment_start" : "2024-01-18T08:00:00.000Z" + }, + { + "deployment_id" : "95a71077-c4bb-4b3d-845a-fc1796c6513d", + "critter_id" : "54c1037e-e052-48ef-9a51-00bf66d1b546", + "attachment_start" : "2024-01-21T08:00:00.000Z" + }, + { + "deployment_id" : "bde3b6d6-84c4-48ed-a81a-7d8d225a5271", + "critter_id" : "bd0eeb43-670e-4d5f-909b-f027a6d764c3", + "attachment_start" : "2024-01-28T08:00:00.000Z" + }, + { + "deployment_id" : "c521c0bf-8024-4355-96e2-aeabccf7d71e", + "critter_id" : "7054aeb6-72f7-4926-a80f-ed10eba9d3db", + "attachment_start" : "2024-01-18T08:00:00.000Z" + }, + { + "deployment_id" : "a378d3b1-f7e9-4b84-a62e-28d167adcf3b", + "critter_id" : "9408981d-91ca-4681-aff1-1bc7d6bc0124", + "attachment_start" : "2024-01-21T08:00:00.000Z" + }, + { + "deployment_id" : "60b7a277-ed59-4deb-b545-fc312d63dff8", + "critter_id" : "fb16edcb-0e2c-4bf7-ac6d-83446cf31b01", + "attachment_start" : "2024-01-21T08:00:00.000Z" + }, + { + "deployment_id" : "0de0efd6-7c8e-46cd-ac01-20b4caa2f630", + "critter_id" : "636e6bae-584e-4800-a08e-3c3bfa86bff0", + "attachment_start" : "2024-01-01T08:00:00.000Z" + }, + { + "deployment_id" : "3c83b9be-204d-4391-b09e-02181370b8aa", + "critter_id" : "2553064b-0f29-4ae4-a514-fec68dc1ca22", + "attachment_start" : "2024-02-01T08:00:00.000Z" + }, + { + "deployment_id" : "ac0d43a5-005b-4de0-9ddf-fcac7a0a3ca0", + "critter_id" : "fc9d5a68-e82f-4082-ac15-3686aba3aa98", + "attachment_start" : "2024-02-20T08:00:00.000Z" + }, + { + "deployment_id" : "4af0985a-ca10-4e7d-8f46-9abc4d59fe58", + "critter_id" : "e1989147-3b0a-4799-8b76-0907aadc400c", + "attachment_start" : "2024-02-13T08:00:00.000Z" + }, + { + "deployment_id" : "936f8fc9-71a8-4051-ae75-c71ea3607d1b", + "critter_id" : "1244bb11-2580-4445-990f-b864deaf0752", + "attachment_start" : "2024-02-11T08:00:00.000Z" + }, + { + "deployment_id" : "c115ac0b-0a24-4eea-9474-bfa00d8eb011", + "critter_id" : "1244bb11-2580-4445-990f-b864deaf0752", + "attachment_start" : "2024-02-11T08:00:00.000Z" + }, + { + "deployment_id" : "16784c57-1cc9-485d-92c2-01dfa7ad498d", + "critter_id" : "b2498f46-9c6e-4b03-9e4c-7b49eca22384", + "attachment_start" : "2024-01-01T08:00:00.000Z" + }, + { + "deployment_id" : "b6412aa3-ce92-43ad-ab43-73320de90f2f", + "critter_id" : "a2e6262e-346c-4ec6-8367-a4697cc1f362", + "attachment_start" : "2024-02-28T08:00:00.000Z" + }, + { + "deployment_id" : "aee5a8a3-78fc-4c3d-9110-61079631a7fe", + "critter_id" : "11111111-1111-1111-1111-111111111111", + "attachment_start" : "2024-03-29T07:00:00.000Z" + }, + { + "deployment_id" : "29d5a00b-32d6-4956-b20b-bd3f16c4a472", + "critter_id" : "af6d2b96-d719-4f18-af43-66dab7d80be5", + "attachment_start" : "2023-09-25T07:00:00.000Z" + }, + { + "deployment_id" : "648a42d2-ce0a-42b2-8af8-08b57b8dfe8c", + "critter_id" : "4128a5b9-3030-4a69-a897-48bed658ddce", + "attachment_start" : "2023-09-25T07:00:00.000Z" + }, + { + "deployment_id" : "58a37ada-cd7c-48e2-9260-82c838a43c13", + "critter_id" : "fce4ad2e-f575-49a7-8337-cff466b26908", + "attachment_start" : "2023-10-31T07:00:00.000Z" + }, + { + "deployment_id" : "712398a8-ad2b-41ea-a6ae-6201df38755d", + "critter_id" : "05d5baee-7c9d-4622-9fa6-fe5cc64716f1", + "attachment_start" : "2023-10-01T07:00:00.000Z" + }, + { + "deployment_id" : "1d411e51-7d3c-4c0a-8cc6-254dd16da3fb", + "critter_id" : "84aa52c4-4299-48ba-a504-015d87dbc91b", + "attachment_start" : "2023-10-23T07:00:00.000Z" + }, + { + "deployment_id" : "367f66cb-41bf-4d76-ac51-6805f58b4a09", + "critter_id" : "bda29fa0-2f98-4e6e-89e1-e07b5209e081", + "attachment_start" : "2023-10-10T07:00:00.000Z" + }, + { + "deployment_id" : "26dac571-cc9f-495e-be85-c8d8b6f53cb7", + "critter_id" : "4b2848eb-c722-4409-93dc-1b3c0e5918a8", + "attachment_start" : "2017-10-25T07:00:00.000Z" + }, + { + "deployment_id" : "ad4920ad-82dd-4878-ac69-fea2a81401d7", + "critter_id" : "a82bc327-5a87-427d-8516-35e92d6026d5", + "attachment_start" : "2023-11-21T08:00:00.000Z" + }, + { + "deployment_id" : "d8669069-601e-4f69-b467-8581a3b1ab9f", + "critter_id" : "600f2266-3b62-4d16-b79d-1990c7c50d36", + "attachment_start" : "2023-11-06T08:00:00.000Z" + }, + { + "deployment_id" : "5bf4ae74-6298-449e-95ee-e198db3b6b04", + "critter_id" : "050a29f5-92a1-40a5-a2d2-75317bd00543", + "attachment_start" : "2023-11-16T08:00:00.000Z" + }, + { + "deployment_id" : "6c0151b7-c609-4cf1-9b15-b8ccc5d97885", + "critter_id" : "0e2fca91-c7d3-4cd4-a27a-74764c4d1ec0", + "attachment_start" : "2023-11-08T08:00:00.000Z" + }, + { + "deployment_id" : "1654349c-876c-4ff3-ba8b-c4984b5b1d33", + "critter_id" : "32a32d16-d3c5-44f9-b5ae-e430d98b103a", + "attachment_start" : "2023-11-12T08:00:00.000Z" + }, + { + "deployment_id" : "00b0620c-8596-4dc7-bdf7-2870635b3380", + "critter_id" : "ef3915d9-4aca-411a-833a-e3c722db4df9", + "attachment_start" : "2023-11-05T07:00:00.000Z" + }, + { + "deployment_id" : "c4e25b46-ba4a-443a-84e4-9525b2a17e66", + "critter_id" : "b1219e78-e682-47d0-b700-e8eda93d5d9d", + "attachment_start" : "2023-12-11T08:00:00.000Z" + }, + { + "deployment_id" : "de4c8833-55e2-466d-84ac-a2f0416603a8", + "critter_id" : "f9e45a68-8113-444a-a4e0-07f227680b08", + "attachment_start" : "2024-01-09T08:00:00.000Z" + }, + { + "deployment_id" : "96c0e7ba-4275-4bf5-80fb-258f9f30f6f4", + "critter_id" : "ca0884f2-e6d0-47b9-b4a3-2d616555dd0e", + "attachment_start" : "2024-01-07T08:00:00.000Z" + }, + { + "deployment_id" : "bdfa7e98-1188-4d48-ab27-e9b70f83374e", + "critter_id" : "9408981d-91ca-4681-aff1-1bc7d6bc0124", + "attachment_start" : "2024-01-24T08:00:00.000Z" + }, + { + "deployment_id" : "e620a1d6-b4a3-439f-b023-47ca65add442", + "critter_id" : "fb16edcb-0e2c-4bf7-ac6d-83446cf31b01", + "attachment_start" : "2024-01-14T08:00:00.000Z" + }, + { + "deployment_id" : "233b88c9-4765-426d-a752-c87ed5badb58", + "critter_id" : "546ba2f4-fbc1-403a-bc37-44faae17014c", + "attachment_start" : "2024-01-21T08:00:00.000Z" + }, + { + "deployment_id" : "cda2f036-9b61-48a6-b89b-39f0741b4b03", + "critter_id" : "fc9d5a68-e82f-4082-ac15-3686aba3aa98", + "attachment_start" : "2024-02-21T08:00:00.000Z" + }, + { + "deployment_id" : "ab465074-9234-4fe4-8773-1bd3bf7e6815", + "critter_id" : "e1989147-3b0a-4799-8b76-0907aadc400c", + "attachment_start" : "2024-02-12T08:00:00.000Z" + }, + { + "deployment_id" : "fcdd761f-8edc-423b-a775-899dc8b57454", + "critter_id" : "8576eb64-43cc-4f3e-b465-2d6c3eca9ccf", + "attachment_start" : "2024-03-22T07:00:00.000Z" + }, + { + "deployment_id" : "0c3eb8aa-186f-4f3f-87da-0da6391b2a1d", + "critter_id" : "d9597713-3a24-49f7-bdca-a9993d73b8c3", + "attachment_start" : "2024-03-26T07:00:00.000Z" + }, + { + "deployment_id" : "2fd88495-b9dd-4020-b824-c5772a68acf5", + "critter_id" : "b07c8dca-2144-47b2-b768-32f5953350df", + "attachment_start" : "2024-03-27T07:00:00.000Z" + }, + { + "deployment_id" : "7bcc047a-909d-4df6-b1a5-7c511a680711", + "critter_id" : "454ca976-f4ad-4407-87ed-c6117da6bca9", + "attachment_start" : "2020-01-01T08:00:00.000Z" + }, + { + "deployment_id" : "f13e16e4-8568-45b5-8e99-61ea87d1e59f", + "critter_id" : "3a2eff29-5919-47e1-820f-2312f9e359d3", + "attachment_start" : "2020-01-01T08:00:00.000Z" + }, + { + "deployment_id" : "3fa8ea28-f01d-4967-832e-8ce3429fcf4f", + "critter_id" : "1638cfee-2a79-4e72-992d-462d64e6b77d", + "attachment_start" : "2024-04-13T07:00:00.000Z" + }, + { + "deployment_id" : "8aa38bc3-2fba-471f-99ef-e134f4bcf758", + "critter_id" : "309b0232-678f-49e9-8730-31e109b95d2f", + "attachment_start" : "2023-09-25T07:00:00.000Z" + }, + { + "deployment_id" : "f5725288-37c3-43c1-bf9e-f65cdbc54cbb", + "critter_id" : "63d5f66a-6205-4fc5-98da-eada1c160a60", + "attachment_start" : "2023-09-25T07:00:00.000Z" + }, + { + "deployment_id" : "964d9092-aecc-4a95-954f-f67e9f978539", + "critter_id" : "05d5baee-7c9d-4622-9fa6-fe5cc64716f1", + "attachment_start" : "2023-10-01T07:00:00.000Z" + }, + { + "deployment_id" : "e7363ff0-1fec-4334-bb51-a0a2f32cb399", + "critter_id" : "2c22fc5b-7e60-4690-9564-39413bb20918", + "attachment_start" : "2023-10-10T07:00:00.000Z" + }, + { + "deployment_id" : "075f2411-b316-4157-a313-58f5b924cc1f", + "critter_id" : "84aa52c4-4299-48ba-a504-015d87dbc91b", + "attachment_start" : "2023-10-01T07:00:00.000Z" + }, + { + "deployment_id" : "901b0386-1ee4-4c69-a5a4-e9b4cb217528", + "critter_id" : "82e76a36-3817-44a9-af59-0323790b22d5", + "attachment_start" : "2023-10-31T07:00:00.000Z" + }, + { + "deployment_id" : "c0342cda-aaa8-4e0c-9a9b-b858dbfba532", + "critter_id" : "2b4d0d89-2aeb-4c76-ba24-fc4df55b2b8d", + "attachment_start" : "2023-11-17T08:00:00.000Z" + }, + { + "deployment_id" : "f82407ca-7a1d-4099-afcd-cd9009d52231", + "critter_id" : "bf554737-2dd7-4427-b9d4-26c761a1271e", + "attachment_start" : "2023-11-22T08:00:00.000Z" + }, + { + "deployment_id" : "d97f15e4-874f-41c4-a112-1799639a8f92", + "critter_id" : "498ebab9-2af0-40d0-af86-915d259d48e1", + "attachment_start" : "2023-11-19T08:00:00.000Z" + }, + { + "deployment_id" : "43e8c1f0-5f5e-4b10-8ed0-0cf8dec2a74d", + "critter_id" : "de1c3cde-eca9-4ce3-8a8e-b9b94a86a197", + "attachment_start" : "2023-11-20T08:00:00.000Z" + }, + { + "deployment_id" : "06b0b0fb-9e2e-4535-a0af-465e40e2762e", + "critter_id" : "de1c3cde-eca9-4ce3-8a8e-b9b94a86a197", + "attachment_start" : "2023-11-20T08:00:00.000Z" + }, + { + "deployment_id" : "809d5443-cbc9-472c-9aaa-681e19f2f7e7", + "critter_id" : "327b9224-27a0-4301-baed-de0fc60ea4ee", + "attachment_start" : "2023-12-24T08:00:00.000Z" + }, + { + "deployment_id" : "bab47f1a-cb4c-4443-9947-a3369fed0bd1", + "critter_id" : "232f2278-1f32-43d3-b20a-8d65aab83f95", + "attachment_start" : "1992-01-18T08:00:00.000Z" + }, + { + "deployment_id" : "660b12ad-27e8-4685-8c56-0a8b9cbe9d71", + "critter_id" : "d1f11ac8-29f4-4786-b869-81b28ef1caa9", + "attachment_start" : "2024-01-24T08:00:00.000Z" + }, + { + "deployment_id" : "4acb364a-1443-4feb-a185-39a0a1d86ce0", + "critter_id" : "d1f11ac8-29f4-4786-b869-81b28ef1caa9", + "attachment_start" : "2024-01-24T08:00:00.000Z" + }, + { + "deployment_id" : "30c0c9a1-0709-471e-8102-3996e52bc04c", + "critter_id" : "d1f11ac8-29f4-4786-b869-81b28ef1caa9", + "attachment_start" : "2024-01-23T08:00:00.000Z" + }, + { + "deployment_id" : "e701b108-4de0-491e-b7ae-c806867b9ab3", + "critter_id" : "0a70e948-a859-4bd2-96f1-8d3e9a473879", + "attachment_start" : "2024-02-01T08:00:00.000Z" + }, + { + "deployment_id" : "8551f578-b185-4dc5-bb14-eab1e2416d00", + "critter_id" : "e1989147-3b0a-4799-8b76-0907aadc400c", + "attachment_start" : "2024-02-12T08:00:00.000Z" + }, + { + "deployment_id" : "7430ba58-b246-40f4-a3dd-dddddccf1cfc", + "critter_id" : "8b028b5b-a7d9-40a0-ab2a-dbc3bc70bbce", + "attachment_start" : "2024-03-27T07:00:00.000Z" + }, + { + "deployment_id" : "51d344a2-c799-4cfe-a610-903097ed25f1", + "critter_id" : "2ddb8e91-a74c-4d36-b738-d3e6cbbc0347", + "attachment_start" : "2020-01-01T08:00:00.000Z" + }, + { + "deployment_id" : "1d738c24-64f5-4279-8a8d-c2bd5c8c1f9f", + "critter_id" : "fa740fdd-a167-4e13-85a1-612930407b36", + "attachment_start" : "2020-01-01T08:00:00.000Z" + }, + { + "deployment_id" : "7e951f56-e217-419a-bb2a-1ed504bcb6ef", + "critter_id" : "309b0232-678f-49e9-8730-31e109b95d2f", + "attachment_start" : "2023-09-25T07:00:00.000Z" + }, + { + "deployment_id" : "d54f6192-d5d3-4d00-853a-a74bb4857208", + "critter_id" : "63d5f66a-6205-4fc5-98da-eada1c160a60", + "attachment_start" : "2023-09-24T07:00:00.000Z" + }, + { + "deployment_id" : "2a62eceb-1f28-4f4c-af8c-97f8a1d4b753", + "critter_id" : "7d6125fb-7459-428b-8d90-a259576c34f6", + "attachment_start" : "2023-10-01T07:00:00.000Z" + }, + { + "deployment_id" : "54e68523-c9e2-425f-8819-47dd91b39941", + "critter_id" : "93e08585-f2a2-40a2-819a-e086c7f86d6b", + "attachment_start" : "2023-10-06T07:00:00.000Z" + }, + { + "deployment_id" : "58b4bb70-4a50-4c16-baac-ae97ae636bb9", + "critter_id" : "a82bc327-5a87-427d-8516-35e92d6026d5", + "attachment_start" : "2023-11-22T08:00:00.000Z" + }, + { + "deployment_id" : "98873f11-dab6-408d-8e40-19fe34a31796", + "critter_id" : "bf554737-2dd7-4427-b9d4-26c761a1271e", + "attachment_start" : "2023-11-23T08:00:00.000Z" + }, + { + "deployment_id" : "ff3eb3d1-12ff-438e-ac22-6035ea9b324a", + "critter_id" : "fb4a817d-d1d3-4aa6-a4b9-8b57484cb168", + "attachment_start" : "2023-11-17T08:00:00.000Z" + }, + { + "deployment_id" : "ab250243-d4a4-48f6-9808-d130bcf585e3", + "critter_id" : "ef3915d9-4aca-411a-833a-e3c722db4df9", + "attachment_start" : "2023-11-20T08:00:00.000Z" + }, + { + "deployment_id" : "d52ae69f-198a-490f-99ca-958586a09bf8", + "critter_id" : "de1c3cde-eca9-4ce3-8a8e-b9b94a86a197", + "attachment_start" : "2023-11-21T08:00:00.000Z" + }, + { + "deployment_id" : "0e08ab6c-d852-4dc7-b4bd-5e1d8c2c1934", + "critter_id" : "23f75833-ab57-4770-89e0-ef806a2d06ed", + "attachment_start" : "2022-12-07T08:00:00.000Z" + }, + { + "deployment_id" : "ce40ad11-3856-41e7-9c3f-b52a918013ed", + "critter_id" : "7054aeb6-72f7-4926-a80f-ed10eba9d3db", + "attachment_start" : "2024-01-02T08:00:00.000Z" + }, + { + "deployment_id" : "6fc5af31-9446-4102-8e45-e4c63c4ef6be", + "critter_id" : "d1f11ac8-29f4-4786-b869-81b28ef1caa9", + "attachment_start" : "2024-01-24T08:00:00.000Z" + }, + { + "deployment_id" : "c291799c-5f8c-4352-a894-79ec6fb366fa", + "critter_id" : "d1f11ac8-29f4-4786-b869-81b28ef1caa9", + "attachment_start" : "2024-01-20T08:00:00.000Z" + }, + { + "deployment_id" : "7b1aca82-c1ac-4ea0-8317-7fd54d5c9a4d", + "critter_id" : "0a70e948-a859-4bd2-96f1-8d3e9a473879", + "attachment_start" : "2024-02-11T08:00:00.000Z" + }, + { + "deployment_id" : "746fee53-956e-4b89-9b94-6ac15daa349a", + "critter_id" : "e1989147-3b0a-4799-8b76-0907aadc400c", + "attachment_start" : "2024-02-13T08:00:00.000Z" + }, + { + "deployment_id" : "23efa83f-ea35-4e42-a0f2-d6800b824305", + "critter_id" : "92f23bbc-15cf-435d-ba33-78411c548e5d", + "attachment_start" : "2024-03-11T07:00:00.000Z" + }, + { + "deployment_id" : "0db07c42-d8c9-4e19-82ba-7507d181b6c7", + "critter_id" : "a126fc4a-2dd0-4213-8a85-18b5916bdf24", + "attachment_start" : "2024-01-22T08:00:00.000Z" + }, + { + "deployment_id" : "7dcf420e-60e9-46fc-9345-71478f0ab379", + "critter_id" : "5c5784a8-1f15-469f-92b1-27e96dce733c", + "attachment_start" : "2024-01-22T08:00:00.000Z" + }, + { + "deployment_id" : "0df5e991-2fc2-44c0-b84f-b6e961b7ef4b", + "critter_id" : "7e8c9721-b2b7-4b2b-83ed-a24bc43c830e", + "attachment_start" : "2024-01-21T08:00:00.000Z" + }, + { + "deployment_id" : "1814411f-e5ca-42ab-adde-413a2e71a33f", + "critter_id" : "e7037552-ae02-4aab-87ec-c929c0e0c112", + "attachment_start" : "2024-01-21T08:00:00.000Z" + }, + { + "deployment_id" : "b1ecace4-efc7-4295-9412-3046ea31c867", + "critter_id" : "c7c3e9e9-4e92-4dd7-8938-6254b8b59204", + "attachment_start" : "2024-01-27T08:00:00.000Z" + }, + { + "deployment_id" : "dbc23d1d-348d-4113-bb13-1d9b91192242", + "critter_id" : "f204b1bc-04e1-4102-8dd9-dd6814007bff", + "attachment_start" : "2024-01-28T08:00:00.000Z" + }, + { + "deployment_id" : "c5e2a6ac-5f59-406c-95ea-03f6b97be614", + "critter_id" : "f72135cf-eabb-409f-a087-436656b3ec51", + "attachment_start" : "2024-01-28T08:00:00.000Z" + }, + { + "deployment_id" : "52d5f386-36f2-4174-b380-22d0beee8762", + "critter_id" : "490f6af8-38aa-4b6c-b083-4aa4d2e322c8", + "attachment_start" : "2024-01-28T08:00:00.000Z" + }, + { + "deployment_id" : "adf403e1-1a51-4c16-aab2-c741f9f80f75", + "critter_id" : "5d491266-2ecc-49bc-8eae-5aa18f5182a3", + "attachment_start" : "2024-01-25T08:00:00.000Z" + }, + { + "deployment_id" : "3fa3ada6-0761-4f0a-b620-9b3c5a5ad28c", + "critter_id" : "9830fac3-2d6e-45a6-828d-16c87a3ff2f4", + "attachment_start" : "2024-01-26T08:00:00.000Z" + }, + { + "deployment_id" : "ec8232aa-f744-4252-95a1-03e8cfa01371", + "critter_id" : "917dc3f8-1d06-4779-8b09-80fd9c4565d2", + "attachment_start" : "2024-01-25T08:00:00.000Z" + }, + { + "deployment_id" : "c5185c10-8a42-4c8f-b9a9-244fc8be70e3", + "critter_id" : "98c55362-4f58-416b-aaf4-c94249cdf65d", + "attachment_start" : "2024-01-26T08:00:00.000Z" + }, + { + "deployment_id" : "8b80efa4-4a10-460c-979a-0c1032bbcf08", + "critter_id" : "99650c55-5e1c-4651-9157-b305ee494ff9", + "attachment_start" : "2024-01-25T08:00:00.000Z" + }, + { + "deployment_id" : "1474f2cb-1ba5-49cf-8b94-386bda63c1d1", + "critter_id" : "405d229d-e6c8-41f4-a1e5-69609a78126a", + "attachment_start" : "2024-01-28T08:00:00.000Z" + }, + { + "deployment_id" : "f1eba3d6-83cc-4638-978c-1f1562370519", + "critter_id" : "e968ae8e-b03c-420d-a816-9f3a1324488c", + "attachment_start" : "2024-01-26T08:00:00.000Z" + }, + { + "deployment_id" : "758cda6d-0295-4b5c-bf96-ebea169b4625", + "critter_id" : "f09382b0-6c75-417b-bb90-fd91b42d2d10", + "attachment_start" : "2024-01-29T08:00:00.000Z" + }, + { + "deployment_id" : "913eef59-46f7-422a-ab9d-46def7cd278d", + "critter_id" : "c8db7f13-859d-4736-80e6-7f8a445dc965", + "attachment_start" : "2024-01-29T08:00:00.000Z" + }, + { + "deployment_id" : "c21430a7-f802-497e-bc0b-ed9617966370", + "critter_id" : "98ca71a7-d999-4f14-9068-9eabae9f071a", + "attachment_start" : "2024-01-29T08:00:00.000Z" + }, + { + "deployment_id" : "1e87a011-de7d-4990-ad1c-b1a76f590ba0", + "critter_id" : "0acd2a42-319f-4b3f-b008-7789b34b5a7b", + "attachment_start" : "2024-01-29T08:00:00.000Z" + }, + { + "deployment_id" : "33d47a4c-97b0-41d7-bc7c-c93c18f3bcf8", + "critter_id" : "a863b0e9-8b62-432f-8d3a-bffd88138877", + "attachment_start" : "2024-01-29T08:00:00.000Z" + }, + { + "deployment_id" : "f0cefb16-9fc3-4d42-80a7-6bdb7f50dffd", + "critter_id" : "57da06b1-8675-41bc-8684-9ab060a9e6ee", + "attachment_start" : "2024-01-28T08:00:00.000Z" + }, + { + "deployment_id" : "7f244f8f-59cf-4a20-8e0c-9961a4a52ce7", + "critter_id" : "bb5bc10a-ef8d-4e10-b8a8-bfd1a15535ca", + "attachment_start" : "2024-01-20T08:00:00.000Z" + }, + { + "deployment_id" : "2db4eacd-f43f-4482-a97e-41f2e0e41c9f", + "critter_id" : "22b83161-f44c-4536-a598-1608ad70bb30", + "attachment_start" : "2024-01-19T08:00:00.000Z" + }, + { + "deployment_id" : "705a791f-e14e-409d-8609-b03d42c3bc0b", + "critter_id" : "301f7ae7-dc16-45af-a06b-990117861a4c", + "attachment_start" : "2024-01-19T08:00:00.000Z" + }, + { + "deployment_id" : "68002b63-5c0a-4228-b9c3-a3022bdb5474", + "critter_id" : "6e478dcd-3f93-4772-921f-8f1cc000fa58", + "attachment_start" : "2024-01-19T08:00:00.000Z" + }, + { + "deployment_id" : "be5b71ab-24ef-4a29-8ea1-2f5246dc3bf1", + "critter_id" : "8fb8a3c3-f3f0-4dd0-8337-1f9410010715", + "attachment_start" : "2024-01-20T08:00:00.000Z" + }, + { + "deployment_id" : "3e3f636c-ff0b-43a5-b230-b53777f0141e", + "critter_id" : "fe8bcb7a-9d3b-4667-8461-f9d57df21c78", + "attachment_start" : "2024-01-20T08:00:00.000Z" + }, + { + "deployment_id" : "9f917f6b-11f1-4121-ab67-ef8f5d899bb1", + "critter_id" : "ef886a7d-dccf-422c-a26a-d279734efc38", + "attachment_start" : "2024-01-19T08:00:00.000Z" + }, + { + "deployment_id" : "8a6a5ea3-045a-43ee-a65f-4a3c2f541594", + "critter_id" : "6ffd4227-132b-4c3d-9f5e-345a46528e6b", + "attachment_start" : "2024-01-22T08:00:00.000Z" + }, + { + "deployment_id" : "658f04f7-d674-4106-a251-bdaeb88caf26", + "critter_id" : "2f873ffd-9108-40a3-8f0b-3ad1b19cbc7e", + "attachment_start" : "2023-09-25T07:00:00.000Z" + }, + { + "deployment_id" : "10da9482-855f-4616-846e-1a6707a97f8e", + "critter_id" : "63d5f66a-6205-4fc5-98da-eada1c160a60", + "attachment_start" : "2023-09-24T07:00:00.000Z" + }, + { + "deployment_id" : "a5d2ac6d-a7e4-44f3-b994-5a12e03f589e", + "critter_id" : "7d6125fb-7459-428b-8d90-a259576c34f6", + "attachment_start" : "2023-10-01T07:00:00.000Z" + }, + { + "deployment_id" : "90260ea5-73e5-4562-8172-0afeb7e4b952", + "critter_id" : "bf554737-2dd7-4427-b9d4-26c761a1271e", + "attachment_start" : "2023-11-10T08:00:00.000Z" + }, + { + "deployment_id" : "405a64c3-e4a9-4866-abe6-18d177a73578", + "critter_id" : "de1c3cde-eca9-4ce3-8a8e-b9b94a86a197", + "attachment_start" : "2023-11-20T08:00:00.000Z" + }, + { + "deployment_id" : "abc5369a-ebf1-40f7-844d-ccb298179c92", + "critter_id" : "970f9aab-f2f7-45aa-8ed7-26ae3db7dee8", + "attachment_start" : "2023-09-01T07:00:00.000Z" + }, + { + "deployment_id" : "6bb58169-743c-4409-a5a3-3e8ecdca9f13", + "critter_id" : "d1f11ac8-29f4-4786-b869-81b28ef1caa9", + "attachment_start" : "2024-01-22T08:00:00.000Z" + }, + { + "deployment_id" : "8adc87ea-d5ad-4d37-970c-b6554eb8cf64", + "critter_id" : "0a70e948-a859-4bd2-96f1-8d3e9a473879", + "attachment_start" : "2024-01-01T08:00:00.000Z" + }, + { + "deployment_id" : "034e4bbd-ca69-47d8-a09f-c48a56db9c67", + "critter_id" : "e1989147-3b0a-4799-8b76-0907aadc400c", + "attachment_start" : "2024-02-14T08:00:00.000Z" + }, + { + "deployment_id" : "fd011c6b-46c2-4ee2-885d-725f409d7dae", + "critter_id" : "0fa58dd5-0ab8-4380-adf9-31396d472519", + "attachment_start" : "2024-01-25T08:00:00.000Z" + }, + { + "deployment_id" : "4a7ca089-7026-4a17-9459-f9f1fbdf2ba2", + "critter_id" : "9ba2badf-f25d-422b-8e3e-3a642cda8cb9", + "attachment_start" : "2024-01-25T08:00:00.000Z" + }, + { + "deployment_id" : "35619656-0f5f-4514-9c7f-cc817da94f87", + "critter_id" : "8cd80c3a-7297-4414-8c6f-a18c3b19043d", + "attachment_start" : "2024-01-25T08:00:00.000Z" + }, + { + "deployment_id" : "e08a7909-8932-4565-8737-3c4c98360cd1", + "critter_id" : "023f3fc3-1f73-4c4e-9322-d637b8654109", + "attachment_start" : "2024-01-25T08:00:00.000Z" + }, + { + "deployment_id" : "f82ac501-c04d-4936-9856-bc9ce9735f2e", + "critter_id" : "0ae17026-4fde-4f56-ae3c-f281d365b374", + "attachment_start" : "2024-01-25T08:00:00.000Z" + }, + { + "deployment_id" : "6bb5676b-f379-4e46-b96a-c801a547681a", + "critter_id" : "e737234a-2210-4e5e-8de8-e6ed96f14680", + "attachment_start" : "2024-01-23T08:00:00.000Z" + }, + { + "deployment_id" : "8235145a-cfae-444a-a9fd-5ccb91960660", + "critter_id" : "908ae117-c9d0-4fa9-9368-2a498a74bc6d", + "attachment_start" : "2024-01-23T08:00:00.000Z" + }, + { + "deployment_id" : "c694f0eb-3421-4bad-8222-ffeaeb7758f3", + "critter_id" : "518c2817-b96b-4112-a29b-1c7cca415f2d", + "attachment_start" : "2024-01-23T08:00:00.000Z" + }, + { + "deployment_id" : "9032f6be-d3e6-4124-a159-9a83db06aa9d", + "critter_id" : "4bba3b1a-c094-4d89-9d86-c658e6a0f8bc", + "attachment_start" : "2024-01-24T08:00:00.000Z" + }, + { + "deployment_id" : "45cb56f1-8ef1-476d-b689-ef44d114720d", + "critter_id" : "36c1f913-03d7-45f7-b015-1327b35419b8", + "attachment_start" : "2024-01-24T08:00:00.000Z" + }, + { + "deployment_id" : "8a96ef45-f86f-429d-bf7e-a308ee78278b", + "critter_id" : "cf3345ca-6e61-41b2-b128-f3a48ee4026a", + "attachment_start" : "2024-01-24T08:00:00.000Z" + }, + { + "deployment_id" : "ab762686-e074-4dbf-a1c9-e1c97b0b8b7a", + "critter_id" : "7b203759-28a8-46f2-a28b-60f6168a4e9d", + "attachment_start" : "2024-01-26T08:00:00.000Z" + }, + { + "deployment_id" : "a70cd095-a483-4d0b-9953-76ba4bdcbbe8", + "critter_id" : "3f5e17b8-8f8d-4eae-a4f7-dd169f34c753", + "attachment_start" : "2024-01-19T08:00:00.000Z" + }, + { + "deployment_id" : "abf7d2da-d059-4b65-83c7-e780d6002922", + "critter_id" : "510c6579-d337-483b-96e2-7164281fac86", + "attachment_start" : "2024-01-22T08:00:00.000Z" + }, + { + "deployment_id" : "7dca5eb1-3cb2-43b0-bc16-90bd7ea135fa", + "critter_id" : "1963bc29-93c1-4059-9475-4242f16eaa4b", + "attachment_start" : "2024-01-22T08:00:00.000Z" + }, + { + "deployment_id" : "c10e32b1-d7e6-4d99-9821-ee48365338b4", + "critter_id" : "36451f9e-06df-47e2-9aca-740a43b83421", + "attachment_start" : "2024-01-23T08:00:00.000Z" + }, + { + "deployment_id" : "676cdbc8-5b22-4cbc-87b0-fd0934af7bf6", + "critter_id" : "7fb00bf3-3a50-4ce2-b451-52635b506582", + "attachment_start" : "2024-01-23T08:00:00.000Z" + }, + { + "deployment_id" : "0f72857f-61eb-492b-a87c-ede41453b257", + "critter_id" : "732ea3c4-080f-41d1-8ee3-3ae7aa100688", + "attachment_start" : "2024-01-23T08:00:00.000Z" + }, + { + "deployment_id" : "1dc876ec-356a-4d88-b781-3164c809c3ae", + "critter_id" : "d5e7998f-b6e4-4d41-a803-831e4470e8d3", + "attachment_start" : "2024-01-20T08:00:00.000Z" + }, + { + "deployment_id" : "80319b1b-e661-4455-96d0-8cc8c702acb3", + "critter_id" : "9fff301d-860c-4f92-b803-7d873ccef022", + "attachment_start" : "2024-01-23T08:00:00.000Z" + }, + { + "deployment_id" : "28caf6d6-9bdb-4e7d-8fa9-f84a6ef02044", + "critter_id" : "9b8e9af1-5194-43b5-995b-375f32f82dc8", + "attachment_start" : "2024-01-24T08:00:00.000Z" + }, + { + "deployment_id" : "738ab655-bd3a-41e7-ab31-5a4a2f75e9d2", + "critter_id" : "22215361-927a-4818-84d9-a48f1e56c692", + "attachment_start" : "2024-01-28T08:00:00.000Z" + }, + { + "deployment_id" : "a8c068f6-34bb-4e4d-8ff9-21eb683429f8", + "critter_id" : "b31ec4ca-b17d-4d1b-ba4f-8a5b0e0b46c4", + "attachment_start" : "2024-01-24T08:00:00.000Z" + }, + { + "deployment_id" : "25b6eeaf-1987-40ed-8c3b-3eab527b4d84", + "critter_id" : "66f4e10f-0485-48e8-9727-77e81cc09285", + "attachment_start" : "2024-01-24T08:00:00.000Z" + }, + { + "deployment_id" : "80e08d40-3143-4dc6-9408-04eafa72679b", + "critter_id" : "e848af72-8972-4cc3-bb05-7a3e47e18245", + "attachment_start" : "2024-01-24T08:00:00.000Z" + }, + { + "deployment_id" : "7a5b3850-5322-4332-ba1b-ec1baa66f6f7", + "critter_id" : "2cde0b2d-c966-45e0-b2a8-bb16672cdae9", + "attachment_start" : "2024-01-25T08:00:00.000Z" + }, + { + "deployment_id" : "0230b928-034b-4fe6-a5a7-c0839ddbc843", + "critter_id" : "3377d539-de98-484a-afe9-b008628d9203", + "attachment_start" : "2024-01-24T08:00:00.000Z" + }, + { + "deployment_id" : "c31800ce-c020-45a1-b497-103e0ac8bfc5", + "critter_id" : "0e751598-2b6e-4706-8cdb-1fc402718321", + "attachment_start" : "2024-01-24T08:00:00.000Z" + }, + { + "deployment_id" : "e8720471-9c0a-4fd3-8f42-ba29ce323bf1", + "critter_id" : "d53ccd1e-0662-4cff-ad2c-3278f98d03c0", + "attachment_start" : "2024-01-22T08:00:00.000Z" + }, + { + "deployment_id" : "32de8a05-313d-4682-b9f5-b5d1ed32ceba", + "critter_id" : "64abe392-2e4d-4e6d-b9b7-ecd31857e7e4", + "attachment_start" : "2024-01-22T08:00:00.000Z" + }, + { + "deployment_id" : "2d6339ee-2cbc-45a5-bb67-46b818e0824a", + "critter_id" : "bf639dca-1209-4c02-9ecd-044b6053f248", + "attachment_start" : "2024-01-22T08:00:00.000Z" + }, + { + "deployment_id" : "b5096e74-b770-41fe-9b1b-d96ee5fd0b4f", + "critter_id" : "9a9ba918-636b-492a-919b-b931cf208c9d", + "attachment_start" : "2024-01-23T08:00:00.000Z" + }, + { + "deployment_id" : "4342b0df-2c36-411a-aed8-23fed05cd424", + "critter_id" : "85a84b65-48d5-4848-a612-2bd7f2e58147", + "attachment_start" : "2024-01-22T08:00:00.000Z" + }, + { + "deployment_id" : "ccccf1ce-af7f-4ffd-a28d-0db6487a169f", + "critter_id" : "f0604f93-60df-47e8-83e4-efc948712961", + "attachment_start" : "2024-01-22T08:00:00.000Z" + }, + { + "deployment_id" : "7b9d546e-c4cf-4e44-b602-7db5dbefe59b", + "critter_id" : "63d5f66a-6205-4fc5-98da-eada1c160a60", + "attachment_start" : "2023-09-24T07:00:00.000Z" + }, + { + "deployment_id" : "66f7c40f-fe0b-43e5-981b-96b965340537", + "critter_id" : "4b2848eb-c722-4409-93dc-1b3c0e5918a8", + "attachment_start" : "2023-10-24T07:00:00.000Z" + }, + { + "deployment_id" : "6c26cea7-9257-42e5-a06c-f49172bafa21", + "critter_id" : "bf554737-2dd7-4427-b9d4-26c761a1271e", + "attachment_start" : "2023-11-03T07:00:00.000Z" + }, + { + "deployment_id" : "0c58222e-2f0a-4e24-bea2-7e6c2e0885dc", + "critter_id" : "de1c3cde-eca9-4ce3-8a8e-b9b94a86a197", + "attachment_start" : "2023-11-16T08:00:00.000Z" + }, + { + "deployment_id" : "ac8d2752-a7f8-4fab-bae5-d52e6edeb552", + "critter_id" : "e1989147-3b0a-4799-8b76-0907aadc400c", + "attachment_start" : "2024-02-13T08:00:00.000Z" + }, + { + "deployment_id" : "892b5485-24e8-401b-93bc-5261f12a4f3e", + "critter_id" : "c24e8b0b-ec27-4ab9-8953-843aaf58a344", + "attachment_start" : "2024-01-01T08:00:00.000Z" + }, + { + "deployment_id" : "e98afb2d-d1ad-4500-a00a-aa1ae1442bf0", + "critter_id" : "df4b1592-16f2-449b-9fd1-a56c70f5871a", + "attachment_start" : "2024-01-22T08:00:00.000Z" + }, + { + "deployment_id" : "8997e3dc-fbdc-447e-835a-8d145708f43a", + "critter_id" : "9ac75566-eb1e-4ab2-a15f-10499d1c6370", + "attachment_start" : "2024-01-23T08:00:00.000Z" + }, + { + "deployment_id" : "3c5b65e8-67d1-4b88-91e9-aa1c78947738", + "critter_id" : "a0ae0e67-09e5-4961-8ed5-f25be75de205", + "attachment_start" : "2024-01-23T08:00:00.000Z" + }, + { + "deployment_id" : "826b39e7-fdd2-4b50-9303-27370540c04d", + "critter_id" : "0ff095ee-dff2-4f96-bfd1-648825ae0a09", + "attachment_start" : "2024-01-23T08:00:00.000Z" + }, + { + "deployment_id" : "70321d96-4871-43e5-bc7f-89b28160a802", + "critter_id" : "4604e40d-c4bb-4c6a-931e-90350645a409", + "attachment_start" : "2024-01-23T08:00:00.000Z" + }, + { + "deployment_id" : "09285b5e-246b-411f-bddb-f7dc4f1afdcd", + "critter_id" : "1314c475-32b6-4ad9-a41c-c27a2555af39", + "attachment_start" : "2024-02-07T08:00:00.000Z" + }, + { + "deployment_id" : "f047d758-c5e6-4bd1-b4da-e0bce588ed20", + "critter_id" : "9b590882-cdaf-40c0-9f18-6b8799f8501a", + "attachment_start" : "2024-02-07T08:00:00.000Z" + }, + { + "deployment_id" : "b6d893ac-1c47-4f9a-98f8-fdc141fd6f32", + "critter_id" : "dc1b89f0-8e19-4eef-acf7-0e7e53c26f69", + "attachment_start" : "2024-02-07T08:00:00.000Z" + }, + { + "deployment_id" : "d643edfa-d509-4839-bed0-6c6c0d188135", + "critter_id" : "e7d0a937-4bd8-4dc6-bfd0-407c99f9118f", + "attachment_start" : "2024-02-07T08:00:00.000Z" + }, + { + "deployment_id" : "699c2da2-37b1-4723-af93-bdf80865397f", + "critter_id" : "ca83686a-1a48-4e91-afd6-7c4724dc41d0", + "attachment_start" : "2024-02-06T08:00:00.000Z" + }, + { + "deployment_id" : "1238558b-4c03-428a-aa60-4b6cabd5142a", + "critter_id" : "685185a4-aebb-4fe8-a037-8b64fb2ac106", + "attachment_start" : "2024-02-04T08:00:00.000Z" + }, + { + "deployment_id" : "9ef9af9f-08c3-4aa3-a9bb-9899592580af", + "critter_id" : "a3c7dfda-bcf8-4d14-b7b5-9e82d0adf4a9", + "attachment_start" : "2024-02-05T08:00:00.000Z" + }, + { + "deployment_id" : "e1658bb9-5c85-49a2-bf70-0495b5417c1a", + "critter_id" : "213a1da3-2db4-4d04-8064-51f630cf110f", + "attachment_start" : "2024-02-04T08:00:00.000Z" + }, + { + "deployment_id" : "eb630183-522a-4e46-bb1d-0f62e0725b29", + "critter_id" : "ec7c7081-8673-4f74-bfd2-ba71853c63f0", + "attachment_start" : "2024-02-05T08:00:00.000Z" + }, + { + "deployment_id" : "4dd98d7d-39ee-4cce-8cb5-50959abe5d2a", + "critter_id" : "f785a500-d9d8-4b52-b911-8edb5a63469a", + "attachment_start" : "2024-02-05T08:00:00.000Z" + }, + { + "deployment_id" : "6a8c15ea-5d0f-447e-b8c5-a004e3bd67de", + "critter_id" : "2cde0b2d-c966-45e0-b2a8-bb16672cdae9", + "attachment_start" : "2024-02-05T08:00:00.000Z" + }, + { + "deployment_id" : "1cc043b6-488f-4ffe-9f8c-d02480afd81d", + "critter_id" : "af38aee8-e564-4b52-a774-a516eee48a59", + "attachment_start" : "2024-02-05T08:00:00.000Z" + }, + { + "deployment_id" : "80856ecc-982e-47c4-a7fa-92dcef059572", + "critter_id" : "4e1471a8-5d47-4168-ae26-13531eedb285", + "attachment_start" : "2024-02-05T08:00:00.000Z" + }, + { + "deployment_id" : "130afc09-e6c0-4302-863f-9d8e966b3036", + "critter_id" : "7aa8ec15-5731-4acd-9a9a-60ec16cc1ad7", + "attachment_start" : "2024-02-04T08:00:00.000Z" + }, + { + "deployment_id" : "96c83823-e650-492c-8a55-ada682776870", + "critter_id" : "da0e3fec-7c4d-460c-a191-70d9b9e44493", + "attachment_start" : "2024-02-04T08:00:00.000Z" + }, + { + "deployment_id" : "9e5c4184-6b0f-4dab-a55d-466a4c50c1b5", + "critter_id" : "d2c590b1-b4e3-48ea-8b69-3f97bc040d20", + "attachment_start" : "2024-02-05T08:00:00.000Z" + }, + { + "deployment_id" : "94a4b9d0-251e-44a3-9d82-5db1d25bba7f", + "critter_id" : "445d6909-930d-48ca-a6a9-0de7faefc05c", + "attachment_start" : "2024-02-04T08:00:00.000Z" + }, + { + "deployment_id" : "46a7ff86-267c-48fd-a1ed-9839cf26c324", + "critter_id" : "968642cc-8e70-4bad-a127-cfe25a67a10c", + "attachment_start" : "2024-02-04T08:00:00.000Z" + }, + { + "deployment_id" : "2354a542-37f1-400c-b253-53bc0b9f8b71", + "critter_id" : "8be3deab-47d1-4753-8637-11f37694c626", + "attachment_start" : "2024-02-04T08:00:00.000Z" + }, + { + "deployment_id" : "f5137eae-82e4-4d8e-a449-9cd6a0469ac7", + "critter_id" : "34fa40a0-fcfb-45bb-b2c7-9b30cbf01031", + "attachment_start" : "2024-02-06T08:00:00.000Z" + }, + { + "deployment_id" : "ca25c558-940e-4734-95a3-2ed1b8b9f2e6", + "critter_id" : "2b46f142-eae4-4ced-acd5-c253e00892fd", + "attachment_start" : "2024-02-04T08:00:00.000Z" + }, + { + "deployment_id" : "d73e7794-dc21-41b3-a6fd-772267341b04", + "critter_id" : "28c724fc-a68b-476c-9081-4588d051772b", + "attachment_start" : "2024-02-04T08:00:00.000Z" + }, + { + "deployment_id" : "5fdb6a49-2365-43bb-85d4-4ff95ba141bb", + "critter_id" : "9c7618cb-b037-466f-b562-0f4331181c26", + "attachment_start" : "2024-02-05T08:00:00.000Z" + }, + { + "deployment_id" : "a52ff08d-1da8-41f3-87d9-96df0ffad1a7", + "critter_id" : "8d2da6c4-13d7-4e47-9f3e-9f0530b9305a", + "attachment_start" : "2024-02-05T08:00:00.000Z" + }, + { + "deployment_id" : "8180a69d-da35-4c82-8667-c2c204a89292", + "critter_id" : "270a10f4-cfe2-44cb-9089-c272b716e92d", + "attachment_start" : "2024-02-04T08:00:00.000Z" + }, + { + "deployment_id" : "c875527f-21e2-4aae-bba5-3bf4567ee007", + "critter_id" : "d22678ea-ae95-4f6b-b1a4-50b06e8aab8c", + "attachment_start" : "2024-01-21T08:00:00.000Z" + }, + { + "deployment_id" : "0290ae19-6ba5-481e-b4e9-40cbff6da13d", + "critter_id" : "037a82b2-97e4-4d59-9832-24f3128e85d8", + "attachment_start" : "2024-01-27T08:00:00.000Z" + }, + { + "deployment_id" : "507ce96c-01f1-4c10-9d77-b2f7143a75d6", + "critter_id" : "47c24545-1b3b-4e11-8ddb-7a2566bcf38f", + "attachment_start" : "2024-01-27T08:00:00.000Z" + }, + { + "deployment_id" : "8886de7a-3232-447b-a451-3d00da315968", + "critter_id" : "5ac481f2-2235-4633-ad38-bdbda604de00", + "attachment_start" : "2024-01-22T08:00:00.000Z" + }, + { + "deployment_id" : "c2ca1089-e7d6-474f-8444-a511d7500fd8", + "critter_id" : "64d6953a-81c3-4827-8510-b4cbe65bb862", + "attachment_start" : "2024-01-21T08:00:00.000Z" + }, + { + "deployment_id" : "8647d07f-c21e-42af-8285-23a19c8fb072", + "critter_id" : "f7bfa8d1-1a56-4ae3-8fdf-9501288a75a9", + "attachment_start" : "2024-01-21T08:00:00.000Z" + }, + { + "deployment_id" : "17ef1ff4-19b6-4814-b557-92daa8f1a31a", + "critter_id" : "8cca8977-10fc-4aae-b05c-9c12fa22e903", + "attachment_start" : "2023-10-05T07:00:00.000Z" + }, + { + "deployment_id" : "1b107ceb-edeb-4b27-b741-8ce826b81e87", + "critter_id" : "5429a6bb-4c5c-4736-b127-34e7e1368f73", + "attachment_start" : "2023-10-05T07:00:00.000Z" + }, + { + "deployment_id" : "49dfd856-dc4f-4bc1-8ff4-36b7b5fea433", + "critter_id" : "c9a3f88c-6528-4b0d-a6d3-ccb258760e33", + "attachment_start" : "2023-11-14T08:00:00.000Z" + }, + { + "deployment_id" : "3ee27b89-8396-431b-b303-68ae573ca8f1", + "critter_id" : "bf554737-2dd7-4427-b9d4-26c761a1271e", + "attachment_start" : "2023-11-16T08:00:00.000Z" + }, + { + "deployment_id" : "8d9b6e09-dd71-432d-adfd-efe9e212c3da", + "critter_id" : "ef3915d9-4aca-411a-833a-e3c722db4df9", + "attachment_start" : "2023-11-26T08:00:00.000Z" + }, + { + "deployment_id" : "f67d65a4-7678-4ccb-9397-58aef131b600", + "critter_id" : "e1989147-3b0a-4799-8b76-0907aadc400c", + "attachment_start" : "2024-02-14T08:00:00.000Z" + }, + { + "deployment_id" : "e8f29688-a6ad-438c-8897-913c52f54720", + "critter_id" : "a3d81471-35f1-4553-a60f-c8493509010c", + "attachment_start" : "2023-10-01T07:00:00.000Z" + }, + { + "deployment_id" : "92139d38-c929-4c2a-9672-4eb1d124966a", + "critter_id" : "f3791551-0052-4a56-b050-086227ad953d", + "attachment_start" : "2023-10-11T07:00:00.000Z" + }, + { + "deployment_id" : "14f2e284-8523-4a1f-82cf-fa7ce1689b10", + "critter_id" : "5429a6bb-4c5c-4736-b127-34e7e1368f73", + "attachment_start" : "2023-10-24T07:00:00.000Z" + }, + { + "deployment_id" : "c1218295-5bba-454b-959c-49ed09df0a7f", + "critter_id" : "c9a3f88c-6528-4b0d-a6d3-ccb258760e33", + "attachment_start" : "2023-11-13T08:00:00.000Z" + }, + { + "deployment_id" : "9b9b2841-2165-4b1d-b293-f1ebf7a547d4", + "critter_id" : "e1989147-3b0a-4799-8b76-0907aadc400c", + "attachment_start" : "2024-02-13T08:00:00.000Z" + }, + { + "deployment_id" : "063d2310-5121-4bb0-a9ad-45b2474b18cd", + "critter_id" : "a3d81471-35f1-4553-a60f-c8493509010c", + "attachment_start" : "2023-10-01T07:00:00.000Z" + }, + { + "deployment_id" : "b6250886-4e10-4670-b942-52a82b8276c5", + "critter_id" : "22ab5ecb-ec4d-4740-8ac3-e8b4096c0468", + "attachment_start" : "2023-10-10T07:00:00.000Z" + }, + { + "deployment_id" : "7de0e607-b903-4c54-a63f-7d5a0d7fed66", + "critter_id" : "1d88bc9d-a6f5-433c-8a79-0826fdbd570a", + "attachment_start" : "2023-11-17T08:00:00.000Z" + }, + { + "deployment_id" : "d22cc474-9dba-4c2b-b428-0f86b32ae140", + "critter_id" : "6a459081-b73d-4756-bcb1-df946db7c1f1", + "attachment_start" : "2023-10-01T07:00:00.000Z" + }, + { + "deployment_id" : "62e3424d-1950-49fd-83ac-17289bb2cc7c", + "critter_id" : "deec020f-f0df-4008-a9d0-ddb5ceb81ba5", + "attachment_start" : "2023-11-14T08:00:00.000Z" + }, + { + "deployment_id" : "12187ba5-4e27-4b05-b5bd-5d678c307c5c", + "critter_id" : "7054aeb6-72f7-4926-a80f-ed10eba9d3db", + "attachment_start" : "2024-01-23T08:00:00.000Z" + }, + { + "deployment_id" : "fc18201f-9a36-4b96-a08f-281f3ed295f2", + "critter_id" : "1d88bc9d-a6f5-433c-8a79-0826fdbd570a", + "attachment_start" : "2023-10-03T07:00:00.000Z" + }, + { + "deployment_id" : "32678116-e150-4bec-ba64-089194b11cff", + "critter_id" : "11813f16-7c61-443a-b2e4-a149f3621ac8", + "attachment_start" : "2023-11-01T07:00:00.000Z" + }, + { + "deployment_id" : "fb40b313-2dc7-4cea-9d44-adcbc036ada0", + "critter_id" : "7054aeb6-72f7-4926-a80f-ed10eba9d3db", + "attachment_start" : "2024-01-15T08:00:00.000Z" + }, + { + "deployment_id" : "e35503a8-7a99-4fc0-83a4-1d848700255b", + "critter_id" : "a771ddc6-008e-4faf-b541-0c19a8255fef", + "attachment_start" : "2023-10-01T07:00:00.000Z" + }, + { + "deployment_id" : "8d41ffff-acd7-42dd-a1b1-65f1c3c99fba", + "critter_id" : "e6a3d2cd-fade-45cb-9d93-db9276a510ee", + "attachment_start" : "2023-11-21T08:00:00.000Z" + }, + { + "deployment_id" : "19e63c1d-f23f-472e-822a-7e26a118a397", + "critter_id" : "a6174539-ed28-4682-879c-7e89b3e2ccce", + "attachment_start" : "2023-11-14T08:00:00.000Z" + }, + { + "deployment_id" : "e0c4559d-1c1e-45e7-b485-1695fd50e4a7", + "critter_id" : "66edc84f-b481-45bb-a42c-cebc71c815ad", + "attachment_start" : "2023-11-14T08:00:00.000Z" + }, + { + "deployment_id" : "cbd088a6-e8f3-474a-9011-9584da68a11e", + "critter_id" : "b4d8cf53-c7be-47a1-b61e-c0a5f78b0c7f", + "attachment_start" : "2023-10-02T07:00:00.000Z" + }, + { + "deployment_id" : "3819e9a5-ab3c-4c4d-90db-d538ab409f6a", + "critter_id" : "b8c35e50-3745-46b3-ae3f-574f545f6cb1", + "attachment_start" : "2020-01-01T08:00:00.000Z" + }, + { + "deployment_id" : "f752635a-ffe4-4b82-bb7c-d6d2cc520da2", + "critter_id" : "6b41b3fe-5209-47ef-b380-6671ee20e093", + "attachment_start" : "2020-01-01T08:00:00.000Z" + }, + { + "deployment_id" : "0e11e175-a8b3-4326-bcb4-525fe7e84ffb", + "critter_id" : "e391b44b-89f2-427f-bcc2-4b37beb7a5e5", + "attachment_start" : "2020-10-29T07:00:00.000Z" + }, + { + "deployment_id" : "46d3563c-1e12-427e-8d6e-32f9c9d0553d", + "critter_id" : "e4b83bb4-5375-4bf7-ac03-ede06cb2dd4a", + "attachment_start" : "2020-10-31T07:00:00.000Z" + }, + { + "deployment_id" : "ce0c975a-2753-4e66-9c44-45aa141b11c0", + "critter_id" : "491e7cea-9081-4c50-a04c-956eecba287d", + "attachment_start" : "2020-10-31T07:00:00.000Z" + }, + { + "deployment_id" : "8bd9b7b3-7602-47e5-a88d-157ba0cddbd1", + "critter_id" : "58610e1f-e0b8-4332-9035-cbce305facf6", + "attachment_start" : "2020-10-30T07:00:00.000Z" + }, + { + "deployment_id" : "e8a57225-621a-40ac-82fe-286cfbc7ba97", + "critter_id" : "43657b23-2ee4-4fd6-b062-8fa1094933e0", + "attachment_start" : "2020-10-30T07:00:00.000Z" + }, + { + "deployment_id" : "51b25b4d-a1c6-4a1b-8604-3e81396aace9", + "critter_id" : "01546977-a937-4667-ac75-77dbeac5afda", + "attachment_start" : "2020-10-31T07:00:00.000Z" + }, + { + "deployment_id" : "de31fa26-82d8-4c08-9312-db1bba74fc1f", + "critter_id" : "9fc8ed4f-e477-41a6-b7fd-5351f7f8da8e", + "attachment_start" : "2020-11-06T08:00:00.000Z" + }, + { + "deployment_id" : "007f4965-15d5-486e-92e6-0696b1a0460a", + "critter_id" : "e99802c8-a13d-4863-8941-ea4413a07be6", + "attachment_start" : "2020-11-06T08:00:00.000Z" + }, + { + "deployment_id" : "baf352d4-2cde-490b-bb7f-497a8fc30d54", + "critter_id" : "d15ae7ec-a2dc-4ef3-a0f7-a66a3d7d3c4f", + "attachment_start" : "2020-11-06T08:00:00.000Z" + }, + { + "deployment_id" : "898d1b4f-00ec-499a-a1be-6e1bf84a61ba", + "critter_id" : "17ba55a2-11c9-4177-ac0d-55774cbe9dbb", + "attachment_start" : "2020-11-06T08:00:00.000Z" + }, + { + "deployment_id" : "d132fb4f-8547-4551-b174-f0b1bdc8894f", + "critter_id" : "218f7685-23b6-4cf9-a75e-c331704ce91b", + "attachment_start" : "2020-11-05T08:00:00.000Z" + }, + { + "deployment_id" : "e7bde3cc-fbfb-45e7-bdf1-db696ee2f3cf", + "critter_id" : "b69bfdab-731f-4526-bbae-41f1e8194998", + "attachment_start" : "2020-11-03T08:00:00.000Z" + }, + { + "deployment_id" : "54c98501-bed4-468f-8f11-6b2bbd9bbc0e", + "critter_id" : "77891e0d-040a-467f-9c99-b861cb6f4a13", + "attachment_start" : "2020-11-03T08:00:00.000Z" + }, + { + "deployment_id" : "a1cf0a09-4301-400b-84d0-50b38cda7dff", + "critter_id" : "8d153547-c6d4-4b4b-8b75-a5221e52035b", + "attachment_start" : "2020-11-03T08:00:00.000Z" + }, + { + "deployment_id" : "7c5ab46b-53f9-4668-8196-ae16e9d9d6f6", + "critter_id" : "78165f0f-2015-4e6c-8419-1e818eabdf8a", + "attachment_start" : "2020-11-03T08:00:00.000Z" + }, + { + "deployment_id" : "a7fa9cbf-72de-4519-92b8-33c3e7cb0948", + "critter_id" : "139c1e72-0986-473d-8b85-f64722d25760", + "attachment_start" : "2020-11-04T08:00:00.000Z" + }, + { + "deployment_id" : "d0d481fc-8433-4f3d-bda6-4b590d1dd461", + "critter_id" : "a63c0afa-47c2-4688-b8a3-fc4463d0d14e", + "attachment_start" : "2020-11-05T08:00:00.000Z" + }, + { + "deployment_id" : "dbb55f69-a642-4c7c-883d-34581284b421", + "critter_id" : "af69aefd-1489-4c3a-98e3-71eb028a2159", + "attachment_start" : "2020-11-03T08:00:00.000Z" + }, + { + "deployment_id" : "8c9c1ece-6665-4f9a-8d5b-a18743cf968d", + "critter_id" : "20572f4c-a7d9-4e84-974d-d0bd3c0fe646", + "attachment_start" : "2020-11-05T08:00:00.000Z" + }, + { + "deployment_id" : "7a5c43ee-0dbd-40f1-a092-a5a5dde91e51", + "critter_id" : "e5cf7f4f-2d13-4cd3-a5fd-35df5887f595", + "attachment_start" : "2020-11-04T08:00:00.000Z" + }, + { + "deployment_id" : "a4062b08-ccd3-423c-9281-718bb05e3e0f", + "critter_id" : "dc71ef90-0d71-4094-b75c-2d1d47f43ae6", + "attachment_start" : "2020-11-04T08:00:00.000Z" + }, + { + "deployment_id" : "8c47d5a3-f738-4ab3-a55d-0b8918091eaa", + "critter_id" : "d59f87a6-4e58-48ee-99f4-6336d09447ba", + "attachment_start" : "2020-11-04T08:00:00.000Z" + }, + { + "deployment_id" : "14cc9b24-279b-4ce7-b721-97de107fd7c1", + "critter_id" : "6146d732-c3f6-4f5f-8bb1-d8d502867df6", + "attachment_start" : "2020-11-04T08:00:00.000Z" + }, + { + "deployment_id" : "4ce49771-ccc9-46d0-8880-c7ab7c3feef2", + "critter_id" : "9ecb6001-0b48-4e99-a48b-2a5bd3ed0ad7", + "attachment_start" : "2020-11-06T08:00:00.000Z" + }, + { + "deployment_id" : "820f8d1f-69cd-40d6-9e8d-967bd3d22ce3", + "critter_id" : "4d8692cd-073d-40cd-8651-4432c1804cd1", + "attachment_start" : "2020-11-03T08:00:00.000Z" + }, + { + "deployment_id" : "57b3db9d-7e1f-4e5e-8642-13134ad9bb00", + "critter_id" : "b4718b72-d5b1-4aa4-b1b3-fa862dbd4ed8", + "attachment_start" : "2020-11-03T08:00:00.000Z" + }, + { + "deployment_id" : "67c2a0a7-a86c-4b07-ab92-143e2aafe18a", + "critter_id" : "b393838a-0ceb-4acf-8efc-c0262db35c17", + "attachment_start" : "2020-11-03T08:00:00.000Z" + }, + { + "deployment_id" : "a9e7977c-37a3-42cb-9ffc-14ba69fbe1d8", + "critter_id" : "12ef22f4-93ca-4f22-a652-e306ba0ba8c9", + "attachment_start" : "2020-11-03T08:00:00.000Z" + }, + { + "deployment_id" : "13084db0-fdc3-4cfc-b392-9b4bb61e03c5", + "critter_id" : "0610af2f-0219-4f47-86e0-5403e825ebbd", + "attachment_start" : "2020-11-06T08:00:00.000Z" + }, + { + "deployment_id" : "4c73ee9f-d458-4f13-8392-fdc6256337af", + "critter_id" : "eba2063b-b520-43be-9817-953e00373b39", + "attachment_start" : "2020-11-04T08:00:00.000Z" + }, + { + "deployment_id" : "c604bb3f-f578-4079-9045-9426b04c631f", + "critter_id" : "9ed42a0a-9c65-4d31-9ef9-8ad2788d290a", + "attachment_start" : "2020-11-05T08:00:00.000Z" + }, + { + "deployment_id" : "d07c92ad-82c3-423d-a519-af93b6682d83", + "critter_id" : "9b747d66-3265-4943-b234-93cc1a1c8925", + "attachment_start" : "2020-11-03T08:00:00.000Z" + }, + { + "deployment_id" : "2979f729-c0ff-493d-a9fb-0db452894f6e", + "critter_id" : "d489f891-b5b9-4769-a6b4-6fadec2954bb", + "attachment_start" : "2020-11-04T08:00:00.000Z" + }, + { + "deployment_id" : "24458c8f-3ca2-4a9a-8159-cc6b83b1bcca", + "critter_id" : "10b70b66-1329-4698-801f-bc6b06563a4b", + "attachment_start" : "2020-11-04T08:00:00.000Z" + }, + { + "deployment_id" : "b59b18b4-38cb-4ea6-ad81-a85e47801d1e", + "critter_id" : "7204c4e9-d6bd-42d3-ada3-ec64a86b594e", + "attachment_start" : "2020-11-10T08:00:00.000Z" + }, + { + "deployment_id" : "b0e9ab8b-d2b8-4425-9b82-296963a48638", + "critter_id" : "5ea33e2c-c681-4e94-b7ab-221b7fc2f2d1", + "attachment_start" : "2020-11-11T08:00:00.000Z" + }, + { + "deployment_id" : "9573d637-27a0-4832-9ee0-58a7f7aa554e", + "critter_id" : "d05b2538-c4b1-4f5b-8412-31d361b819d9", + "attachment_start" : "2020-11-09T08:00:00.000Z" + }, + { + "deployment_id" : "ec84fe96-872b-45c1-a83a-dc2ccfceb7d3", + "critter_id" : "616b10c9-fcb3-4599-aa15-17d5f5b22ec3", + "attachment_start" : "2020-11-08T08:00:00.000Z" + }, + { + "deployment_id" : "920a8804-a7bb-4c8b-b121-521ce53962ff", + "critter_id" : "53faa9f3-83ba-4a79-8ea0-6940add54976", + "attachment_start" : "2020-11-10T08:00:00.000Z" + }, + { + "deployment_id" : "4b8c12c1-ca26-48e2-941d-3a210669b8ac", + "critter_id" : "bfa877c1-8d0a-4748-a8b5-2b2d75641d18", + "attachment_start" : "2020-11-09T08:00:00.000Z" + }, + { + "deployment_id" : "1fc9b22d-cacc-4350-a751-ed6145b87682", + "critter_id" : "2bd47688-0f08-4de5-94ad-88935f878998", + "attachment_start" : "2020-11-08T08:00:00.000Z" + }, + { + "deployment_id" : "f4e86fb4-8e8d-4e7a-9df1-05e1d72a6038", + "critter_id" : "fdc69af7-55ed-4f2d-97ba-9d903cfb157a", + "attachment_start" : "2020-11-10T08:00:00.000Z" + }, + { + "deployment_id" : "d6b1dc1a-1bd7-4da9-9f14-084536ef988c", + "critter_id" : "29262248-1b56-4d27-a838-8bc336b1cacb", + "attachment_start" : "2020-11-10T08:00:00.000Z" + }, + { + "deployment_id" : "0fad3dff-08b7-4b73-ab1f-8007de138f4f", + "critter_id" : "803d382f-51a3-4618-9fd0-c662a5502801", + "attachment_start" : "2020-11-09T08:00:00.000Z" + }, + { + "deployment_id" : "cea5ebdd-eef5-481b-bbe7-4a22000e135a", + "critter_id" : "6fb42f09-1d0e-423c-98f4-d7246b202ba0", + "attachment_start" : "2020-11-08T08:00:00.000Z" + }, + { + "deployment_id" : "5fef268f-de64-41a6-86b8-93ff4d65ec76", + "critter_id" : "f4b858dc-39f8-4db0-bea3-57a1cefd62cc", + "attachment_start" : "2020-11-11T08:00:00.000Z" + }, + { + "deployment_id" : "a8209896-dcda-4afe-bb9b-262e54ebe8fb", + "critter_id" : "dcaaeb00-5d99-4550-a1f8-c7f265e08d00", + "attachment_start" : "2020-11-10T08:00:00.000Z" + }, + { + "deployment_id" : "8762d74a-5b4b-47bb-a67a-b79dbee00278", + "critter_id" : "d65f0463-5cb6-4070-8a73-b6110f070e59", + "attachment_start" : "2020-11-09T08:00:00.000Z" + }, + { + "deployment_id" : "1a787b57-c2f7-41f7-ba3f-9a9b2d3d1f1d", + "critter_id" : "276994b6-696e-4805-a0c2-e6f23bbc49c7", + "attachment_start" : "2020-11-09T08:00:00.000Z" + }, + { + "deployment_id" : "4ff1849a-6062-4c32-a85d-ef5b544787e6", + "critter_id" : "d428af71-f626-40d1-b8af-cb1e9c863cde", + "attachment_start" : "2020-11-10T08:00:00.000Z" + }, + { + "deployment_id" : "02d0e7f8-665a-4195-9e0b-308127304733", + "critter_id" : "7b8810f1-693d-47b3-99ac-3cc673791e8b", + "attachment_start" : "2020-11-09T08:00:00.000Z" + }, + { + "deployment_id" : "c26a63d8-eb72-4ec5-884c-9c6f8c6d954b", + "critter_id" : "3b56ff10-b7de-42cd-a7fb-6af26603742b", + "attachment_start" : "2020-11-09T08:00:00.000Z" + }, + { + "deployment_id" : "2b0842fa-e70d-4c2e-a31d-3a6d57040c6b", + "critter_id" : "5f5b1c2c-4103-4c50-8137-961e983cd4d8", + "attachment_start" : "2020-11-08T08:00:00.000Z" + }, + { + "deployment_id" : "3db8f027-0c1a-4dd1-9d17-0e2d1b4adce3", + "critter_id" : "e9761d5e-3a0e-43ce-a9a3-4ed7f553bfb2", + "attachment_start" : "2020-11-11T08:00:00.000Z" + }, + { + "deployment_id" : "f884825b-50d2-4d87-ae62-262d0cee3355", + "critter_id" : "9568186a-109c-4c59-95ab-f66968c532b0", + "attachment_start" : "2020-11-10T08:00:00.000Z" + }, + { + "deployment_id" : "c34896f9-dc52-4673-a73b-e994382f87eb", + "critter_id" : "cd480a27-0086-44ab-8b07-43e987bc075b", + "attachment_start" : "2020-11-09T08:00:00.000Z" + }, + { + "deployment_id" : "a7d4c318-d2fc-43b0-90b1-01cee8907691", + "critter_id" : "c9858cf5-59d7-4658-abe0-80cc297b14dc", + "attachment_start" : "2020-11-10T08:00:00.000Z" + }, + { + "deployment_id" : "7934f779-3152-4abc-883f-d4dfd26994d2", + "critter_id" : "c54aa820-5de6-4735-b1b2-257cfe8ad98b", + "attachment_start" : "2020-11-09T08:00:00.000Z" + }, + { + "deployment_id" : "9b09f1ef-3519-4c1b-8014-c8ba821dd7e6", + "critter_id" : "3d5f4d77-d1c8-478a-a9d3-15da0ada352b", + "attachment_start" : "2020-11-10T08:00:00.000Z" + }, + { + "deployment_id" : "8b07b371-b2d1-442d-bf84-cab49e9b69b4", + "critter_id" : "2437be39-de11-4007-a8d2-034edb7c40ba", + "attachment_start" : "2020-11-11T08:00:00.000Z" + }, + { + "deployment_id" : "812febf6-573a-40d5-8a2e-d4fa7252f160", + "critter_id" : "e63a53cc-7758-4b88-b5cd-f7aca601453c", + "attachment_start" : "2020-11-11T08:00:00.000Z" + }, + { + "deployment_id" : "3cfc700f-72fe-48c4-ae7a-31dfcc80a76c", + "critter_id" : "56fcc449-99b0-4963-abbe-c23bae4e796b", + "attachment_start" : "2020-10-29T07:00:00.000Z" + }, + { + "deployment_id" : "92367c18-94f5-4892-b3e9-491d3e10ba39", + "critter_id" : "499509dc-1fbc-4051-adf4-b3cee6defee1", + "attachment_start" : "2020-10-28T07:00:00.000Z" + }, + { + "deployment_id" : "2e4818ee-42ee-4b12-aeaa-bc1e835d456d", + "critter_id" : "516f1f61-ed87-4260-ba72-f129df474f19", + "attachment_start" : "2020-10-29T07:00:00.000Z" + }, + { + "deployment_id" : "41902845-220a-435f-b145-550ec3767f92", + "critter_id" : "c9313e6d-cab9-4dc4-aa89-9bfda3f4477f", + "attachment_start" : "2020-10-27T07:00:00.000Z" + }, + { + "deployment_id" : "4a9d9464-433f-4610-a90e-66e6988acb17", + "critter_id" : "fa42ce43-c235-403b-8cb2-9eca463d3644", + "attachment_start" : "2020-10-27T07:00:00.000Z" + }, + { + "deployment_id" : "c38205d3-d7ec-4e9a-985d-49af3e6a8715", + "critter_id" : "b0b3cb77-888c-48b7-ae15-31208f74c541", + "attachment_start" : "2020-10-29T07:00:00.000Z" + }, + { + "deployment_id" : "7e386f32-d377-4768-903b-924a859ad9f7", + "critter_id" : "610e01e4-a223-450f-88cc-afc26af22b2f", + "attachment_start" : "2020-10-28T07:00:00.000Z" + }, + { + "deployment_id" : "ab743510-069e-4df2-87c8-8a14cfc15960", + "critter_id" : "10ce9846-f059-474c-a0a0-b40778d95a50", + "attachment_start" : "2020-10-29T07:00:00.000Z" + }, + { + "deployment_id" : "ffec530f-fd90-40ec-9950-35d26e0fec76", + "critter_id" : "6d1ff56d-f7f1-4346-8394-2f9c65a8016a", + "attachment_start" : "2020-10-27T07:00:00.000Z" + }, + { + "deployment_id" : "42edbcce-41ac-47e4-9195-dc0dbbfb0029", + "critter_id" : "480b9fe5-eca3-4d88-a0ff-e8d28bde8e1d", + "attachment_start" : "2020-10-28T07:00:00.000Z" + }, + { + "deployment_id" : "09ab92d1-09df-43d6-9952-b03766683e5f", + "critter_id" : "89fb36e8-3070-4e97-aca1-5cfee787c4f8", + "attachment_start" : "2020-10-28T07:00:00.000Z" + }, + { + "deployment_id" : "23544925-6f5d-42dd-945b-acf370bd2fac", + "critter_id" : "b7cfbc5f-ac1d-45bb-9f96-2767abb9dc02", + "attachment_start" : "2020-10-28T07:00:00.000Z" + }, + { + "deployment_id" : "b513a2b3-52b8-4f73-a1b5-c446bd88ff64", + "critter_id" : "de85152a-f841-40aa-8e86-754df980e109", + "attachment_start" : "2020-10-27T07:00:00.000Z" + }, + { + "deployment_id" : "6591e4f7-c868-4315-9e05-fe5ab832f4dc", + "critter_id" : "dff6618e-8f1b-4a18-bbbb-05373259ac66", + "attachment_start" : "2020-10-29T07:00:00.000Z" + }, + { + "deployment_id" : "351fca63-35c2-46c0-a029-bec2e83d270c", + "critter_id" : "c3d8454c-aee7-4f01-958b-73766d14c1db", + "attachment_start" : "2020-10-28T07:00:00.000Z" + }, + { + "deployment_id" : "528d5e7c-bdf2-461c-bcb9-41985f0548b9", + "critter_id" : "12611433-5914-4ed7-a110-ea564aed2b9a", + "attachment_start" : "2020-10-29T07:00:00.000Z" + }, + { + "deployment_id" : "0d2210fe-f42a-4adb-a5e0-0d35a5af0bc0", + "critter_id" : "cefb3beb-2a44-47c0-a679-41e17796d2c4", + "attachment_start" : "2020-11-05T08:00:00.000Z" + }, + { + "deployment_id" : "f90328db-52e2-4b8c-80ae-03721910fe1d", + "critter_id" : "716a3833-5abe-4462-a28c-aae9c9cee893", + "attachment_start" : "2020-10-30T07:00:00.000Z" + }, + { + "deployment_id" : "0f11c87c-b922-4944-a2e5-f5429563f52f", + "critter_id" : "7271650f-6ad2-43a6-86e1-28d578ff3b02", + "attachment_start" : "2020-12-15T08:00:00.000Z" + }, + { + "deployment_id" : "c3624669-9c67-4eb2-a252-0d519595f116", + "critter_id" : "765bf964-a62e-434e-9b5d-ebdc45a8b435", + "attachment_start" : "2020-12-11T08:00:00.000Z" + }, + { + "deployment_id" : "3db6c4b0-b653-4004-8742-dc020c47e233", + "critter_id" : "082c3d32-20f6-4ddb-9fed-4b0a65306429", + "attachment_start" : "2020-12-10T08:00:00.000Z" + }, + { + "deployment_id" : "5d78b1b6-4a62-4121-9460-dc3bdf71b536", + "critter_id" : "2d02ba3c-027d-4964-8893-353070d2d52a", + "attachment_start" : "2019-02-05T08:00:00.000Z" + }, + { + "deployment_id" : "88f1ab7c-958a-4bcb-b2fa-0f42ca71f885", + "critter_id" : "8970fb79-a408-4309-ac03-f59091d4492d", + "attachment_start" : "2022-10-20T22:40:00.000Z" + }, + { + "deployment_id" : "88f1ab7c-958a-4bcb-b2fa-0f42ca71f885", + "critter_id" : "8970fb79-a408-4309-ac03-f59091d4492d", + "attachment_start" : "2022-10-20T22:44:00.000Z" + }, + { + "deployment_id" : "88f1ab7c-958a-4bcb-b2fa-0f42ca71f885", + "critter_id" : "8970fb79-a408-4309-ac03-f59091d4492d", + "attachment_start" : "2022-10-20T23:02:00.000Z" + }, + { + "deployment_id" : "88f1ab7c-958a-4bcb-b2fa-0f42ca71f885", + "critter_id" : "8970fb79-a408-4309-ac03-f59091d4492d", + "attachment_start" : "2022-10-21T17:27:00.000Z" + }, + { + "deployment_id" : "ff520152-b5a7-46bc-b17b-c2e7fb6e8dc5", + "critter_id" : "f4208d6e-2572-42f0-8c05-5fc7856345c5", + "attachment_start" : "2019-01-18T08:00:00.000Z" + }, + { + "deployment_id" : "dff8edb3-ddd8-420a-868f-8e870a73d90e", + "critter_id" : "6995c2ac-f137-4336-8085-e5bc5975e8f7", + "attachment_start" : "2022-10-21T18:30:00.000Z" + }, + { + "deployment_id" : "b9bf0ff5-3639-462a-86eb-7b4379e9b233", + "critter_id" : "2d02ba3c-027d-4964-8893-353070d2d52a", + "attachment_start" : "2022-10-21T18:32:00.000Z" + }, + { + "deployment_id" : "dff8edb3-ddd8-420a-868f-8e870a73d90e", + "critter_id" : "6995c2ac-f137-4336-8085-e5bc5975e8f7", + "attachment_start" : "2022-10-21T18:35:00.000Z" + }, + { + "deployment_id" : "dff8edb3-ddd8-420a-868f-8e870a73d90e", + "critter_id" : "6995c2ac-f137-4336-8085-e5bc5975e8f7", + "attachment_start" : "2022-10-21T18:37:00.000Z" + }, + { + "deployment_id" : "dff8edb3-ddd8-420a-868f-8e870a73d90e", + "critter_id" : "6995c2ac-f137-4336-8085-e5bc5975e8f7", + "attachment_start" : "2022-10-21T18:39:00.000Z" + }, + { + "deployment_id" : "88f1ab7c-958a-4bcb-b2fa-0f42ca71f885", + "critter_id" : "8970fb79-a408-4309-ac03-f59091d4492d", + "attachment_start" : "2022-10-21T18:47:00.000Z" + }, + { + "deployment_id" : "dff8edb3-ddd8-420a-868f-8e870a73d90e", + "critter_id" : "6995c2ac-f137-4336-8085-e5bc5975e8f7", + "attachment_start" : "2022-10-21T18:48:00.000Z" + }, + { + "deployment_id" : "dff8edb3-ddd8-420a-868f-8e870a73d90e", + "critter_id" : "6995c2ac-f137-4336-8085-e5bc5975e8f7", + "attachment_start" : "2022-10-21T18:52:00.000Z" + }, + { + "deployment_id" : "dff8edb3-ddd8-420a-868f-8e870a73d90e", + "critter_id" : "6995c2ac-f137-4336-8085-e5bc5975e8f7", + "attachment_start" : "2022-10-21T18:55:00.000Z" + }, + { + "deployment_id" : "dff8edb3-ddd8-420a-868f-8e870a73d90e", + "critter_id" : "6995c2ac-f137-4336-8085-e5bc5975e8f7", + "attachment_start" : "2022-10-21T19:04:00.000Z" + }, + { + "deployment_id" : "dff8edb3-ddd8-420a-868f-8e870a73d90e", + "critter_id" : "6995c2ac-f137-4336-8085-e5bc5975e8f7", + "attachment_start" : "2022-10-21T19:07:00.000Z" + }, + { + "deployment_id" : "a7ef73aa-cbaa-44d9-933a-748e81dc5dbf", + "critter_id" : "f4208d6e-2572-42f0-8c05-5fc7856345c5", + "attachment_start" : "2022-10-21T20:39:00.000Z" + }, + { + "deployment_id" : "dff8edb3-ddd8-420a-868f-8e870a73d90e", + "critter_id" : "6995c2ac-f137-4336-8085-e5bc5975e8f7", + "attachment_start" : "2022-10-21T22:31:00.000Z" + }, + { + "deployment_id" : "dff8edb3-ddd8-420a-868f-8e870a73d90e", + "critter_id" : "6995c2ac-f137-4336-8085-e5bc5975e8f7", + "attachment_start" : "2022-10-21T23:07:00.000Z" + }, + { + "deployment_id" : "dff8edb3-ddd8-420a-868f-8e870a73d90e", + "critter_id" : "6995c2ac-f137-4336-8085-e5bc5975e8f7", + "attachment_start" : "2022-10-24T16:02:00.000Z" + }, + { + "deployment_id" : "dff8edb3-ddd8-420a-868f-8e870a73d90e", + "critter_id" : "6995c2ac-f137-4336-8085-e5bc5975e8f7", + "attachment_start" : "2022-10-24T18:05:00.000Z" + }, + { + "deployment_id" : "dff8edb3-ddd8-420a-868f-8e870a73d90e", + "critter_id" : "6995c2ac-f137-4336-8085-e5bc5975e8f7", + "attachment_start" : "2022-10-24T19:02:00.000Z" + }, + { + "deployment_id" : "dff8edb3-ddd8-420a-868f-8e870a73d90e", + "critter_id" : "6995c2ac-f137-4336-8085-e5bc5975e8f7", + "attachment_start" : "2022-10-24T19:22:00.000Z" + }, + { + "deployment_id" : "dff8edb3-ddd8-420a-868f-8e870a73d90e", + "critter_id" : "6995c2ac-f137-4336-8085-e5bc5975e8f7", + "attachment_start" : "2022-10-24T19:26:00.000Z" + }, + { + "deployment_id" : "dff8edb3-ddd8-420a-868f-8e870a73d90e", + "critter_id" : "6995c2ac-f137-4336-8085-e5bc5975e8f7", + "attachment_start" : "2019-01-18T08:00:00.000Z" + }, + { + "deployment_id" : "dff8edb3-ddd8-420a-868f-8e870a73d90e", + "critter_id" : "6995c2ac-f137-4336-8085-e5bc5975e8f7", + "attachment_start" : "2022-10-24T19:35:00.000Z" + }, + { + "deployment_id" : "4f1bc950-8f13-4b47-9506-bcef9df91128", + "critter_id" : "0e634a79-de08-4db0-8a33-71dfdfdd9405", + "attachment_start" : "2022-10-27T16:44:00.000Z" + }, + { + "deployment_id" : "4f1bc950-8f13-4b47-9506-bcef9df91128", + "critter_id" : "0e634a79-de08-4db0-8a33-71dfdfdd9405", + "attachment_start" : "2022-10-27T16:44:00.000Z" + }, + { + "deployment_id" : "88f1ab7c-958a-4bcb-b2fa-0f42ca71f885", + "critter_id" : "8970fb79-a408-4309-ac03-f59091d4492d", + "attachment_start" : "2022-10-24T19:35:00.000Z" + }, + { + "deployment_id" : "16c66585-8ae7-40ea-b690-bc8769550139", + "critter_id" : "5da41efc-9b55-43f0-83ec-7be8623f4a39", + "attachment_start" : "2021-02-18T08:00:00.000Z" + }, + { + "deployment_id" : "188bfbc6-bb36-449a-b4f8-3b347c46219f", + "critter_id" : "5223d7a3-1df2-4fe1-9342-84a1c8456b0d", + "attachment_start" : "2023-05-01T23:45:00.000Z" + }, + { + "deployment_id" : "794cfaa5-94af-436d-8454-a4d4617f3a5a", + "critter_id" : "3e4c8346-7c30-4426-8521-96a5b8453635", + "attachment_start" : "2020-03-17T07:00:00.000Z" + }, + { + "deployment_id" : "a5764fb9-c6f3-40ee-9da6-d33144510b95", + "critter_id" : "d3512ece-c1c1-433f-abb0-19121d8f03d9", + "attachment_start" : "2023-03-03T08:00:00.000Z" + }, + { + "deployment_id" : "bb830b1e-f247-443d-be6a-5af14f461e75", + "critter_id" : "3d8b980b-1f9c-4c72-bf6f-a5a29d0f1d07", + "attachment_start" : "2023-01-01T08:00:00.000Z" + }, + { + "deployment_id" : "55844176-c9ad-4583-9bb9-b26c74d8e401", + "critter_id" : "f558bca5-c0ea-4d1e-a045-53eb82cdea7c", + "attachment_start" : "2018-05-01T07:00:00.000Z" + }, + { + "deployment_id" : "7223bbe2-fd7d-4860-984c-46f775a81486", + "critter_id" : "6d402569-e04d-4e02-9082-d9f960d4e115", + "attachment_start" : "2023-04-04T07:00:00.000Z" + }, + { + "deployment_id" : "bc57cb04-e347-4434-b8b6-097d6227dc04", + "critter_id" : "3d8b980b-1f9c-4c72-bf6f-a5a29d0f1d07", + "attachment_start" : "2023-01-01T08:00:00.000Z" + }, + { + "deployment_id" : "3d5135d0-1c29-493e-90ea-434439bfad16", + "critter_id" : "3d8b980b-1f9c-4c72-bf6f-a5a29d0f1d07", + "attachment_start" : "2023-01-01T08:00:00.000Z" + }, + { + "deployment_id" : "971e15dd-a07f-418a-8743-80a6dda10d05", + "critter_id" : "cd9798d4-1f45-4fdc-a760-c73d4276c35b", + "attachment_start" : "2023-10-02T07:00:00.000Z" + }, + { + "deployment_id" : "9989848f-9ec9-4ec5-bc97-013e1b75ed73", + "critter_id" : "cd9798d4-1f45-4fdc-a760-c73d4276c35b", + "attachment_start" : "2023-10-01T07:00:00.000Z" + }, + { + "deployment_id" : "201a855b-ca2c-4113-ba79-9de3aae460cd", + "critter_id" : "43201d4d-f16f-4f8e-8413-dde5d4a195e6", + "attachment_start" : "2018-03-25T07:00:00.000Z" + } +] diff --git a/scripts/bctw-deployments/main.js b/scripts/bctw-deployments/main.js new file mode 100755 index 0000000000..9ca84111b0 --- /dev/null +++ b/scripts/bctw-deployments/main.js @@ -0,0 +1,216 @@ +#! /usr/bin/env node + +const fs = require("fs"); +const util = require("util"); +const fsPromises = require("fs").promises; +const exec = util.promisify(require("child_process").exec); + +/** + * Static config for SQL formatting. + * Potentially could move this into an .env file / cli arguments, + * but maybe overkill for this script. + * + */ +const CONFIG = { + first_name: "Macgregor", + last_name: "Aubertin-Young", + email: "Macgregor.Aubertin-Young@gov.bc.ca", + project_role: "Coordinator", + project_program: "Wildlife", + survey_status: "Completed", + survey_type: "Monitoring", + survey_intended_outcome_1: "Mortality", + survey_intended_outcome_2: "Distribution or Range Map", + caribou_tsn: 180701, +}; + +const BCGW_CARIBOU_ENDPOINT = + "https://openmaps.gov.bc.ca/geo/pub/wfs?request=GetFeature&service=WFS&version=2.0.0&typeNames=WHSE_WILDLIFE_INVENTORY.GCPB_CARIBOU_POPULATION_SP&outputFormat=json&srsName=EPSG:4326"; + +const CARIBOU_FEATURES_FILE = "files/features.json"; + +/** + * Wrapper for exec util, grants ability to call bin commands from js script. + * + * @async + * @param {string} command - Command to execute. ie: `jq < ${fileName}` | `echo 'Hello World'` + * @throws {error} - stderr - Error from command. + * @returns {Promise} Standard output as string. + */ +const execute = async (command) => { + const { stdout, stderr } = await exec(`${command}`, { + maxBuffer: 2048 * 2048, + }); + if (stderr) { + // This is the error message from the external command. + throw stderr; + } + return stdout; +}; + +/** + * Pre-parse input file with jq and output as JSON object. + * + * Steps: + * 1. Group by critter_id. + * 2. Group by herd (unit_name). + * 3. Find start/end dates and format year property. + * 4. Group surveys by matching herd and year. + * 5. Associate deployments to each applicable survey. + * + * @async + * @param {string} fileName - Filename passed in via argument. + * @returns {Promise} JQ parsed file as object. + */ +const jqPreParseInputFile = async (fileName) => { + const data = await execute( + ` + jq '[group_by(.critter_id)[]|add] + | map(select( + .unit_name != null and + .deployment_id != null and + .attachment_start != null and + .critter_id != null)) + | group_by(.unit_name) | .[] |= + { + herd: .[].unit_name, + start_date: min_by(.attachment_start) | .attachment_start, + end_date: max_by(.attachment_start) | .attachment_start, + surveys: (group_by(.attachment_start | .[:4]) | .[] |= + { + year: (.[].attachment_start | .[:4]), + deployments: (.[] |= + { + deployment_id: .deployment_id, + critter_id: .critter_id, + attachment_start: .attachment_start, + }) + }) + }' < ${fileName} + `, + ); + + return JSON.parse(data); +}; + +/** + * Fetch Caribou features (geojson geometries) and write to file. + * + * Note: Jq is unable to accept "large" json objects as arguments. + * So output the geometries to a file then use jq again to parse from the file. + * + * @async + * @returns {Promise} + */ +const writeCaribouHerdFeaturesToFile = async () => { + const res = await fetch(BCGW_CARIBOU_ENDPOINT); + await fsPromises.writeFile(CARIBOU_FEATURES_FILE, await res.text()); +}; + +/** + * Get specific herd GeoJson feature and geometry from features.json file. + * + * @async + * @param {string} herd - Caribou herd region. ie: `Atlin`. + * @returns {Promise<{feature: string, geometry: string}>} Object containing string GeoJsons. + */ +const getCaribouHerdGeoJson = async (herd) => { + const featureJq = `jq -c '.features[] | select(.properties.HERD_NAME == "${herd}")' < ${CARIBOU_FEATURES_FILE}`; + const geometryJq = `jq -c '.features[] | select(.properties.HERD_NAME == "${herd}") |.geometry' < ${CARIBOU_FEATURES_FILE}`; + + const [feature, geometry] = await Promise.all([ + execute(featureJq), + execute(geometryJq), + ]); + + return { feature, geometry }; +}; + +/** + * Generate SIMS SQL for existing Caribou deployments in BCTW. + * + * Steps: + * 1. Validate file name is passed as arugment to script. + * 2. If features.json file does not exist, fetch geometries and write to file. + * 3. Pre-parse input file with jq. + * 4. Loop through each item in JSON array and generate project meta SQL. + * 5. For each project generate surveys meta SQL and inject Caribou herd geometries. + * 6. For each survey generate critter and deployment SQL. + * 7. Wrap SQL in transaction block. + * + * @async + * @returns {Promise} SIMS SQL. + */ +async function main() { + const file_name_argument = process.argv[2]; + + try { + if (!file_name_argument) { + throw `Error: Missing file name argument -> ./main.js {input-filename}.json > deployments.sql`; + } + + //If features.json file already exists skip. + if (!fs.existsSync(CARIBOU_FEATURES_FILE)) { + await writeCaribouHerdFeaturesToFile(); + } + + const data = await jqPreParseInputFile(file_name_argument); + + let sql = ""; + + for (let pIndex = 0; pIndex < data.length; pIndex++) { + const project = data[pIndex]; + + sql += `WITH p AS (INSERT INTO project (name, objectives, coordinator_first_name, coordinator_last_name, coordinator_email_address, start_date, end_date) VALUES ($$Caribou - ${project.herd} - BCTW Telemetry$$, $$BCTW telemetry deployments for ${project.herd} Caribou$$, $$${CONFIG.first_name}$$, $$${CONFIG.last_name}$$, $$${CONFIG.email}$$, $$${project.start_date}$$, $$${project.end_date}$$) RETURNING project_id + ), ppp AS (INSERT INTO project_participation (project_id, system_user_id, project_role_id) SELECT project_id, (select system_user_id from system_user where user_identifier = $$mauberti$$), (select project_role_id from project_role where name = $$${CONFIG.project_role}$$) FROM p + ), pp AS (INSERT INTO project_program (project_id, program_id) SELECT project_id, (select program_id from program where name = $$${CONFIG.project_program}$$) FROM p + `; + for (let sIndex = 0; sIndex < project.surveys.length; sIndex++) { + const survey = project.surveys[sIndex]; + + const { feature, geometry } = await getCaribouHerdGeoJson(project.herd); + + sql += `), s${sIndex} AS (INSERT INTO survey (project_id, name, lead_first_name, lead_last_name, start_date, end_date, progress_id) SELECT project_id, $$Caribou - ${survey.year} - ${project.herd} - BCTW Telemetry$$, $$${CONFIG.first_name}$$, $$${CONFIG.last_name}$$, $$${project.start_date}$$, $$${project.end_date}$$, (select survey_progress_id from survey_progress where name = $$${CONFIG.survey_status}$$) FROM p RETURNING survey_id + ), st${sIndex} AS (INSERT INTO survey_type (survey_id, type_id) SELECT survey_id, (select type_id from type where name = $$${CONFIG.survey_type}$$) FROM s${sIndex} + ), ss${sIndex} AS (INSERT INTO study_species (survey_id, is_focal, itis_tsn) SELECT survey_id, true, ${CONFIG.caribou_tsn} FROM s${sIndex} + ), sio1${sIndex} AS (INSERT INTO survey_intended_outcome (survey_id, intended_outcome_id) SELECT survey_id, (select intended_outcome_id from intended_outcome where name = $$${CONFIG.survey_intended_outcome_1}$$) FROM s${sIndex} + ), sio2${sIndex} AS (INSERT INTO survey_intended_outcome (survey_id, intended_outcome_id) SELECT survey_id, (select intended_outcome_id from intended_outcome where name = $$${CONFIG.survey_intended_outcome_2}$$) FROM s${sIndex} + `; + /** + * This will skip inserting a survey_location if no matching value for herd in BCGW. + * In Production all values should match, but in development environments some herds + * might have been added that don't exist in BCGW. + * + * Potentially this could throw if the herd does not exist in BCGW. + * + */ + if (feature) { + sql += `), sl${sIndex} AS (INSERT INTO survey_location (survey_id, name, description, geojson, geography) SELECT survey_id, $$${project.herd}$$, $$${project.herd} herd region boundary$$, $$[${feature}]$$, public.geography(public.ST_GeomFromGeoJSON($$${geometry}$$)) FROM s${sIndex}`; + } + + for (let dIndex = 0; dIndex < survey.deployments.length; dIndex++) { + const deployment = survey.deployments[dIndex]; + const isLastDeployment = + sIndex === project.surveys.length - 1 && + dIndex === survey.deployments.length - 1; + + const sqlPrepend = isLastDeployment + ? " " + : `, survey${sIndex}deployment${dIndex} AS (`; + + sql += `), survey${sIndex}critter${dIndex} AS (INSERT INTO critter (survey_id, critterbase_critter_id) SELECT survey_id, $$${deployment.critter_id}$$ FROM s${sIndex} RETURNING critter_id + )${sqlPrepend}INSERT INTO deployment (critter_id, bctw_deployment_id) SELECT critter_id, $$${deployment.deployment_id}$$ FROM survey${sIndex}critter${dIndex}`; + } + } + sql += ";"; + } + + process.stdout.write( + `SET search_path=public,biohub; BEGIN; ${sql} COMMIT;`, + ); + } catch (err) { + process.stderr.write(`main.js -> ${err}`); + } +} + +main(); diff --git a/scripts/bctw-deployments/run.sh b/scripts/bctw-deployments/run.sh new file mode 100755 index 0000000000..3b025dc8de --- /dev/null +++ b/scripts/bctw-deployments/run.sh @@ -0,0 +1,27 @@ +#!/bin/bash + +# Author: Mac Deluca +# Date: April 23, 2024 +# Description: Helper script generate and run SQL on docker db container. +# How-to: ./run.sh input-filename.json + + +# Check if the script is called with at least one argument. +if [ $# -eq 0 ]; then + echo "Execute on local DB: ./run.sh files/input.dev.json" + echo "Execute on production DB: ./run.sh files/.json --prod" + exit 1 +fi + +# Check if production flag is used. +if [ "$2" = "--prod" ]; then + # Note: must be port-forwarding to a production database + ./main.js "$1" > files/deployments.sql && \ + psql -h localhost -p 9999 -U postgres -d biohubbc < files/deployments.sql +else + ./main.js "$1" > files/deployments.sql && \ + docker exec -i sims-db-all-container psql -U postgres -d biohubbc < files/deployments.sql +fi + +exit 0 + From f9a90628d8f83e21b0438dbf4a03accbf481a3e6 Mon Sep 17 00:00:00 2001 From: Nick Phura Date: Wed, 1 May 2024 17:46:19 -0700 Subject: [PATCH 08/31] BugFix: Fixes From QA In Test (#1277) * Add migration to patch missing observation_subcount records * Update project/survey user search to handle type ahead search * Update clamav scanner library/code * Update error logging * Remove unneeded migration * Tweak logging --- api/package-lock.json | 23 +++++++--- api/package.json | 3 +- api/src/app.ts | 29 +++++++------ api/src/types/clamdjs.d.ts | 7 ---- api/src/utils/file-utils.test.ts | 27 ------------ api/src/utils/file-utils.ts | 42 +++++++++++++------ .../projects/components/ProjectUserForm.tsx | 28 ++++++++----- .../surveys/components/SurveyUserForm.tsx | 26 +++++++----- 8 files changed, 100 insertions(+), 85 deletions(-) delete mode 100644 api/src/types/clamdjs.d.ts diff --git a/api/package-lock.json b/api/package-lock.json index 9edcdb3e5f..6d0a026f6b 100644 --- a/api/package-lock.json +++ b/api/package-lock.json @@ -17,7 +17,7 @@ "ajv": "^8.12.0", "aws-sdk": "^2.1565.0", "axios": "^1.6.7", - "clamdjs": "^1.0.2", + "clamscan": "^2.2.1", "dayjs": "^1.11.10", "db-migrate": "^0.11.11", "db-migrate-pg": "^1.2.2", @@ -52,6 +52,7 @@ "@istanbuljs/nyc-config-typescript": "^1.0.1", "@types/adm-zip": "^0.4.34", "@types/chai": "^4.3.14", + "@types/clamscan": "^2.0.8", "@types/express": "^4.17.13", "@types/geojson": "^7946.0.8", "@types/jsonwebtoken": "^8.5.5", @@ -1079,6 +1080,15 @@ "integrity": "sha512-Wj71sXE4Q4AkGdG9Tvq1u/fquNz9EdG4LIJMwVVII7ashjD/8cf8fyIfJAjRr6YcsXnSE8cOGQPq1gqeR8z+3w==", "dev": true }, + "node_modules/@types/clamscan": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/@types/clamscan/-/clamscan-2.0.8.tgz", + "integrity": "sha512-HaOKUH+MKgGZAYakboOSHcHga1jGRgD4kpUUslceKtsOqDY16yCLHcURETSF7jOokJOR/Z0k2wk0RL+pN0cbUg==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/connect": { "version": "3.4.38", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", @@ -2170,10 +2180,13 @@ "fsevents": "~2.3.2" } }, - "node_modules/clamdjs": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/clamdjs/-/clamdjs-1.0.2.tgz", - "integrity": "sha512-gVnX5ySMULvwYL2ykZQnP4UK4nIK7ftG6z015drJyOFgWpsqXt1Hcq4fMyPwM8LLsxfgfYKLiZi288xuTfmZBQ==" + "node_modules/clamscan": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/clamscan/-/clamscan-2.2.1.tgz", + "integrity": "sha512-ureXxucH9MfkhyR4nsJMWPnwq/mKlSYHB5RtkuqWltgSF06kET/C36iAeJuGiGXIWc1bi1FMMoptysHLkIRA/g==", + "engines": { + "node": ">=16.0.0" + } }, "node_modules/clean-stack": { "version": "2.2.0", diff --git a/api/package.json b/api/package.json index 7913987b47..a8c1983314 100644 --- a/api/package.json +++ b/api/package.json @@ -34,7 +34,7 @@ "ajv": "^8.12.0", "aws-sdk": "^2.1565.0", "axios": "^1.6.7", - "clamdjs": "^1.0.2", + "clamscan": "^2.2.1", "dayjs": "^1.11.10", "db-migrate": "^0.11.11", "db-migrate-pg": "^1.2.2", @@ -69,6 +69,7 @@ "@istanbuljs/nyc-config-typescript": "^1.0.1", "@types/adm-zip": "^0.4.34", "@types/chai": "^4.3.14", + "@types/clamscan": "^2.0.8", "@types/express": "^4.17.13", "@types/geojson": "^7946.0.8", "@types/jsonwebtoken": "^8.5.5", diff --git a/api/src/app.ts b/api/src/app.ts index 375707833c..0c3ab66061 100644 --- a/api/src/app.ts +++ b/api/src/app.ts @@ -4,7 +4,7 @@ import multer from 'multer'; import { OpenAPIV3 } from 'openapi-types'; import swaggerUIExperss from 'swagger-ui-express'; import { defaultPoolConfig, initDBPool } from './database/db'; -import { ensureHTTPError, HTTPErrorType } from './errors/http-error'; +import { ensureHTTPError, HTTP500 } from './errors/http-error'; import { authorizeAndAuthenticateMiddleware, getCritterbaseProxyMiddleware, @@ -105,13 +105,21 @@ const openAPIFramework = initialize({ } }, errorTransformer: function (_, ajvError: object): object { - // Transform openapi-request-validator and openapi-response-validator errors - defaultLog.error({ label: 'errorTransformer', message: 'ajvError', ajvError }); + // Transform openapi-request-validator or openapi-response-validator errors return ajvError; }, // If `next` is not included express will silently skip calling the `errorMiddleware` entirely. // eslint-disable-next-line @typescript-eslint/no-unused-vars errorMiddleware: function (error, req, res, next) { + defaultLog.error({ + label: 'errorMiddleware', + message: 'error', + error, + req_url: `${req.method} ${req.url}`, + req_params: req.params, + req_body: req.body + }); + // Ensure all errors (intentionally thrown or not) are in the same format as specified by the schema const httpError = ensureHTTPError(error); @@ -175,12 +183,13 @@ function validateAllResponses(req: Request, res: Response, next: NextFunction) { res.json = (...args) => { if (res.get('x-express-openapi-validation-error-for')) { - // Already validated, return + // Already validated this response once, skip validation and return return json.apply(res, args); } const body = args[0]; + // Run openapi response validation function const validationResult: { message: any; errors: any[] } | undefined = res['validateResponse']( res.statusCode, body @@ -204,16 +213,12 @@ function validateAllResponses(req: Request, res: Response, next: NextFunction) { defaultLog.debug({ label: 'validateAllResponses', message: validationMessage, - responseBody: body, - errors: errorList + error: errorList, + req_url: `${req.method} ${req.url}`, + res_body: body }); - return res.status(500).json({ - name: HTTPErrorType.INTERNAL_SERVER_ERROR, - status: 500, - message: validationMessage, - errors: errorList - }); + throw new HTTP500(validationMessage, errorList); } }; } diff --git a/api/src/types/clamdjs.d.ts b/api/src/types/clamdjs.d.ts deleted file mode 100644 index d82f3d9e8f..0000000000 --- a/api/src/types/clamdjs.d.ts +++ /dev/null @@ -1,7 +0,0 @@ -declare module 'clamdjs' { - interface ClamScanner { - scanBuffer: (buffer: string | Buffer, timeout: number, chunkSize: number) => Promise; - } - - export function createScanner(host: string, port: number): ClamScanner; -} diff --git a/api/src/utils/file-utils.test.ts b/api/src/utils/file-utils.test.ts index 08c16057f6..386e7b6422 100644 --- a/api/src/utils/file-utils.test.ts +++ b/api/src/utils/file-utils.test.ts @@ -154,33 +154,6 @@ describe('_getClamAvScanner', () => { const result = _getClamAvScanner(); expect(result).to.not.be.null; }); - - it('should return null if enable file virus scan is not set to true', () => { - process.env.ENABLE_FILE_VIRUS_SCAN = 'false'; - process.env.CLAMAV_HOST = 'host'; - process.env.CLAMAV_PORT = '1111'; - - const result = _getClamAvScanner(); - expect(result).to.be.null; - }); - - it('should return null if CLAMAV_HOST is not set', () => { - process.env.ENABLE_FILE_VIRUS_SCAN = 'true'; - delete process.env.CLAMAV_HOST; - process.env.CLAMAV_PORT = '1111'; - - const result = _getClamAvScanner(); - expect(result).to.be.null; - }); - - it('should return null if CLAMAV_PORT is not set', () => { - process.env.ENABLE_FILE_VIRUS_SCAN = 'true'; - process.env.CLAMAV_HOST = 'host'; - delete process.env.CLAMAV_PORT; - - const result = _getClamAvScanner(); - expect(result).to.be.null; - }); }); describe('_getObjectStoreBucketName', () => { diff --git a/api/src/utils/file-utils.ts b/api/src/utils/file-utils.ts index e51efe7552..965d45e8f7 100644 --- a/api/src/utils/file-utils.ts +++ b/api/src/utils/file-utils.ts @@ -7,20 +7,24 @@ import { ManagedUpload, Metadata } from 'aws-sdk/clients/s3'; -import clamd from 'clamdjs'; +import NodeClam from 'clamscan'; +import { Readable } from 'stream'; +import { getLogger } from './logger'; + +const defaultLog = getLogger('/api/src/utils/file-utils'); /** * Local getter for retrieving the ClamAV client. * - * @returns {*} {clamd.ClamScanner | null} The ClamAV Scanner if `process.env.ENABLE_FILE_VIRUS_SCAN` is set to - * 'true' and other appropriate environment variables are set; `null` otherwise. + * @return {*} {Promise} */ -export const _getClamAvScanner = (): clamd.ClamScanner | null => { - if (process.env.ENABLE_FILE_VIRUS_SCAN === 'true' && process.env.CLAMAV_HOST && process.env.CLAMAV_PORT) { - return clamd.createScanner(process.env.CLAMAV_HOST, Number(process.env.CLAMAV_PORT)); - } - - return null; +export const _getClamAvScanner = async (): Promise => { + return new NodeClam().init({ + clamdscan: { + host: process.env.CLAMAV_HOST, + port: Number(process.env.CLAMAV_PORT) + } + }); }; /** @@ -251,17 +255,31 @@ export function generateS3FileKey(options: IS3FileKey): string { } export async function scanFileForVirus(file: Express.Multer.File): Promise { - const ClamAVScanner = _getClamAvScanner(); + if (process.env.ENABLE_FILE_VIRUS_SCAN !== 'true' || !process.env.CLAMAV_HOST || !process.env.CLAMAV_PORT) { + // Virus scanning is not enabled or necessary environment variables are not set + return true; + } + + const ClamAVScanner = await _getClamAvScanner(); // if virus scan is not to be performed/cannot be performed if (!ClamAVScanner) { return true; } - const clamavScanResult = await ClamAVScanner.scanBuffer(file.buffer, 3000, 1024 * 1024); + const fileStream = Readable.from(file.buffer); + + const clamavScanResult = await ClamAVScanner.scanStream(fileStream); // if virus found in file - if (clamavScanResult.includes('FOUND')) { + if (clamavScanResult.isInfected) { + defaultLog.warn({ + label: 'scanFileForVirus', + message: 'Malicious content detected', + file: file.originalname, + clamavScanResult + }); + return false; } diff --git a/app/src/features/projects/components/ProjectUserForm.tsx b/app/src/features/projects/components/ProjectUserForm.tsx index db35dea818..a98c716593 100644 --- a/app/src/features/projects/components/ProjectUserForm.tsx +++ b/app/src/features/projects/components/ProjectUserForm.tsx @@ -2,7 +2,6 @@ import { mdiMagnify } from '@mdi/js'; import Icon from '@mdi/react'; import Autocomplete, { createFilterOptions } from '@mui/material/Autocomplete'; import Box from '@mui/material/Box'; -import CircularProgress from '@mui/material/CircularProgress'; import Collapse from '@mui/material/Collapse'; import grey from '@mui/material/colors/grey'; import TextField from '@mui/material/TextField'; @@ -17,7 +16,7 @@ import useDataLoader from 'hooks/useDataLoader'; import { ICode } from 'interfaces/useCodesApi.interface'; import { ICreateProjectRequest, IGetProjectParticipant } from 'interfaces/useProjectApi.interface'; import { ISystemUser } from 'interfaces/useUserApi.interface'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { TransitionGroup } from 'react-transition-group'; import { alphabetizeObjects } from 'utils/Utils'; import yup from 'utils/YupSchema'; @@ -51,11 +50,18 @@ const ProjectUserForm = (props: IProjectUserFormProps) => { const { handleSubmit, values, setFieldValue, errors, setErrors } = useFormikContext(); const biohubApi = useBiohubApi(); - const searchUserDataLoader = useDataLoader(() => biohubApi.user.searchSystemUser('')); - searchUserDataLoader.load(); + const searchUserDataLoader = useDataLoader((keyword: string) => biohubApi.user.searchSystemUser(keyword)); const [searchText, setSearchText] = useState(''); + const [sortedUsers, setSortedUsers] = useState([]); + + useEffect(() => { + if (searchUserDataLoader.data) { + setSortedUsers(alphabetizeObjects(searchUserDataLoader.data, 'display_name')); + } + }, [searchUserDataLoader.data]); + const handleAddUser = (user: ISystemUser | IGetProjectParticipant) => { setFieldValue(`participants[${values.participants.length}]`, { system_user_id: user.system_user_id, @@ -127,11 +133,6 @@ const ProjectUserForm = (props: IProjectUserFormProps) => { return values.participants?.[index]?.project_role_names?.[0] || ''; }; - if (!searchUserDataLoader.data || !searchUserDataLoader.hasLoaded) { - // should probably replace this with a skeleton - return ; - } - return (
@@ -164,8 +165,8 @@ const ProjectUserForm = (props: IProjectUserFormProps) => { id={'autocomplete-user-role-search'} data-testid={'autocomplete-user-role-search'} filterSelectedOptions - noOptionsText="No records found" - options={searchText.length > 2 ? alphabetizeObjects(searchUserDataLoader.data, 'display_name') : []} + noOptionsText={'No records found'} + options={sortedUsers} filterOptions={(options, state) => { const searchFilter = createFilterOptions({ ignoreCase: true }); const unselectedOptions = options.filter( @@ -180,6 +181,11 @@ const ProjectUserForm = (props: IProjectUserFormProps) => { setSearchText(''); } else { setSearchText(value); + + if (value.length >= 3) { + // Only search if the search text is at least 3 characters long + searchUserDataLoader.refresh(value); + } } }} onChange={(_, option) => { diff --git a/app/src/features/surveys/components/SurveyUserForm.tsx b/app/src/features/surveys/components/SurveyUserForm.tsx index d775fddeba..c553276f8a 100644 --- a/app/src/features/surveys/components/SurveyUserForm.tsx +++ b/app/src/features/surveys/components/SurveyUserForm.tsx @@ -2,7 +2,6 @@ import { mdiMagnify } from '@mdi/js'; import Icon from '@mdi/react'; import Autocomplete, { createFilterOptions } from '@mui/material/Autocomplete'; import Box from '@mui/material/Box'; -import CircularProgress from '@mui/material/CircularProgress'; import Collapse from '@mui/material/Collapse'; import grey from '@mui/material/colors/grey'; import TextField from '@mui/material/TextField'; @@ -16,7 +15,7 @@ import useDataLoader from 'hooks/useDataLoader'; import { ICode } from 'interfaces/useCodesApi.interface'; import { ICreateSurveyRequest, IGetSurveyParticipant } from 'interfaces/useSurveyApi.interface'; import { ISystemUser } from 'interfaces/useUserApi.interface'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { TransitionGroup } from 'react-transition-group'; import { alphabetizeObjects } from 'utils/Utils'; import yup from 'utils/YupSchema'; @@ -42,11 +41,18 @@ const SurveyUserForm = (props: ISurveyUserFormProps) => { const { handleSubmit, values, setFieldValue, errors, setErrors } = useFormikContext(); const biohubApi = useBiohubApi(); - const searchUserDataLoader = useDataLoader(() => biohubApi.user.searchSystemUser('')); - searchUserDataLoader.load(); + const searchUserDataLoader = useDataLoader((keyword: string) => biohubApi.user.searchSystemUser(keyword)); const [searchText, setSearchText] = useState(''); + const [sortedUsers, setSortedUsers] = useState([]); + + useEffect(() => { + if (searchUserDataLoader.data) { + setSortedUsers(alphabetizeObjects(searchUserDataLoader.data, 'display_name')); + } + }, [searchUserDataLoader.data]); + const handleAddUser = (user: ISystemUser | IGetSurveyParticipant) => { setFieldValue(`participants[${values.participants.length}]`, { system_user_id: user.system_user_id, @@ -106,11 +112,6 @@ const SurveyUserForm = (props: ISurveyUserFormProps) => { return values.participants?.[index]?.survey_job_name || ''; }; - if (!searchUserDataLoader.data || !searchUserDataLoader.hasLoaded) { - // should probably replace this with a skeleton - return ; - } - return ( @@ -135,7 +136,7 @@ const SurveyUserForm = (props: ISurveyUserFormProps) => { data-testid={'autocomplete-user-role-search'} filterSelectedOptions noOptionsText="No records found" - options={searchText.length > 2 ? alphabetizeObjects(searchUserDataLoader.data, 'display_name') : []} + options={sortedUsers} filterOptions={(options, state) => { const searchFilter = createFilterOptions({ ignoreCase: true }); const unselectedOptions = options.filter( @@ -150,6 +151,11 @@ const SurveyUserForm = (props: ISurveyUserFormProps) => { setSearchText(''); } else { setSearchText(value); + + if (value.length >= 3) { + // Only search if the search text is at least 3 characters long + searchUserDataLoader.refresh(value); + } } }} onChange={(_, option) => { From 7ce357663096aaa5e4b354a0eabba7701108106c Mon Sep 17 00:00:00 2001 From: Macgregor Aubertin-Young <108430771+mauberti-bc@users.noreply.github.com> Date: Tue, 7 May 2024 12:52:29 -0700 Subject: [PATCH 09/31] TechDebt: Edit Sampling Site Fixes/Enhancements (#1279) Organize sample site create and edit flows Refactor survey folder sub directories Replace sample_blocks and sample_stratums with blocks and stratums, respectively Remove surveyed_areas_all Update openapi spec Refactor database zod schema validation. Now strict. Fix various endpoints and type definitions to match actual responses. Misc tweaks/updates/cleanup. --------- Co-authored-by: Nick Phura --- Makefile | 10 + api/src/app.ts | 8 +- api/src/database/db-utils.test.ts | 56 +--- api/src/database/db-utils.ts | 28 -- api/src/database/db.ts | 125 +++++--- api/src/models/biohub-create.test.ts | 2 + api/src/models/survey-create.test.ts | 11 +- api/src/models/survey-create.ts | 2 - api/src/models/survey-update.test.ts | 9 - api/src/models/survey-update.ts | 2 - api/src/openapi/schemas/geoJson.ts | 20 +- api/src/openapi/schemas/survey.ts | 33 ++- .../survey/{surveyId}/observations/index.ts | 33 +-- .../{surveyId}/sample-site/index.test.ts | 12 +- .../survey/{surveyId}/sample-site/index.ts | 18 +- .../{surveySampleSiteId}/index.test.ts | 107 ++++++- .../sample-site/{surveySampleSiteId}/index.ts | 271 +++++++++++++++++- api/src/repositories/code-repository.ts | 214 +++++++------- .../observation-repository.test.ts | 4 +- .../repositories/observation-repository.ts | 75 +++-- .../repositories/project-repository.test.ts | 2 +- api/src/repositories/project-repository.ts | 16 +- api/src/repositories/region-repository.ts | 5 +- .../repositories/sample-blocks-repository.ts | 15 +- .../sample-location-repository.test.ts | 18 +- .../sample-location-repository.ts | 149 ++++++++-- .../sample-method-repository.test.ts | 8 +- .../repositories/sample-method-repository.ts | 6 +- .../sample-stratums-repository.ts | 15 +- ...site-selection-strategy-repository.test.ts | 19 +- .../site-selection-strategy-repository.ts | 31 +- .../repositories/subcount-repository.test.ts | 1 + api/src/repositories/subcount-repository.ts | 1 + .../repositories/survey-block-repository.ts | 34 +-- .../survey-location-repository.ts | 18 +- .../repositories/survey-repository.test.ts | 16 +- api/src/repositories/survey-repository.ts | 27 +- api/src/services/bctw-service.ts | 22 +- api/src/services/observation-service.test.ts | 12 +- api/src/services/region-service.test.ts | 6 +- .../services/sample-location-service.test.ts | 25 +- api/src/services/sample-location-service.ts | 22 +- .../services/sample-method-service.test.ts | 20 +- api/src/services/sample-method-service.ts | 6 +- .../site-selection-strategy-service.ts | 7 +- api/src/services/survey-block-service.test.ts | 16 -- api/src/services/survey-block-service.ts | 4 + api/src/zod-schema/geoJsonZodSchema.ts | 2 +- app/package-lock.json | 6 +- app/src/components/dialog/EditDialog.tsx | 68 ++--- app/src/components/fields/DateTimeFields.tsx | 2 +- app/src/contexts/surveyContext.tsx | 2 +- app/src/features/surveys/CreateSurveyPage.tsx | 18 +- app/src/features/surveys/SurveyRouter.tsx | 10 +- .../{ => agreements}/AgreementsForm.tsx | 0 .../ProprietaryDataForm.test.tsx | 2 +- .../{ => agreements}/ProprietaryDataForm.tsx | 0 .../{ => funding}/SurveyFundingSourceForm.tsx | 0 .../GeneralInformationForm.tsx | 6 +- .../SurveySectionFullPageLayout.test.tsx | 0 .../SurveySectionFullPageLayout.tsx | 2 +- .../{ => layout}/SurveySectionHeader.tsx | 0 .../{ => locations}/StudyAreaForm.test.tsx | 6 +- .../{ => locations}/StudyAreaForm.tsx | 6 +- .../components/locations/SurveyAreaList.tsx | 2 +- .../locations/SurveyAreaMapControl.tsx | 2 +- .../locations/SurveyLocationPage.tsx | 64 ----- .../SurveySamplingSiteImportForm.tsx | 4 +- .../PurposeAndMethodologyForm.tsx | 4 +- .../SurveyUserForm.test.tsx | 2 +- .../{ => participants}/SurveyUserForm.tsx | 0 .../SamplingStrategyForm.tsx | 4 +- .../SurveySiteSelectionForm.tsx | 13 +- .../blocks}/BlockForm.tsx | 0 .../blocks}/CreateSurveyBlockDialog.tsx | 4 +- .../blocks}/EditSurveyBlockDialog.tsx | 7 +- .../blocks}/SurveyBlockForm.tsx | 14 +- .../stratums}/StratumCreateOrEditDialog.tsx | 3 +- .../stratums}/SurveyStratumForm.tsx | 79 +++-- .../features/surveys/edit/EditSurveyForm.tsx | 26 +- .../features/surveys/edit/EditSurveyPage.tsx | 34 ++- .../ObservationsTableContainer.tsx | 4 +- .../configure-table/ConfigureColumns.tsx | 2 +- .../ConfigureColumnsPopoverContent.tsx | 2 +- .../components/BlockStratumCard.tsx | 0 .../components/SamplingSiteGroupingsForm.tsx | 16 +- .../{ => components}/SamplingSiteHeader.tsx | 4 +- .../map}/SamplingSiteEditMapControl.tsx | 71 +++-- .../{ => map}/SamplingSiteMapControl.tsx | 14 +- .../map}/SurveySampleSiteEditForm.tsx | 18 +- .../SampleSiteFileUploadItemActionButton.tsx | 0 .../SampleSiteFileUploadItemProgressBar.tsx | 0 .../SampleSiteFileUploadItemSubtext.tsx | 0 .../{ => create}/SamplingSitePage.tsx | 88 +----- .../create/form}/CreateSamplingMethod.tsx | 7 +- .../create/form}/MethodForm.tsx | 48 ++-- .../create/form/SampleSiteCreateForm.tsx | 82 ++++++ .../create/form}/SamplingMethodForm.tsx | 59 ++-- .../edit/SamplingSiteEditPage.tsx | 94 +++--- .../edit/components/SamplingBlockEditForm.tsx | 150 ---------- .../components/SamplingStratumEditForm.tsx | 149 ---------- .../edit/form}/EditSamplingMethod.tsx | 21 +- .../SampleMethodEditForm.tsx | 61 ++-- .../SampleSiteEditForm.tsx | 54 ++-- .../SampleSiteGeneralInformationForm.tsx | 8 +- .../form}/SamplingBlockForm.tsx | 15 +- .../SamplingStratumChips.tsx | 4 +- .../form}/SamplingStratumForm.tsx | 40 ++- .../list/SamplingSiteListMethod.tsx | 4 +- .../list/SamplingSiteListPeriod.tsx | 4 +- .../list/SamplingSiteListSite.tsx | 6 +- .../view/survey-animals/SurveyAnimalsPage.tsx | 2 +- app/src/hooks/api/useSamplingSiteApi.ts | 11 +- app/src/hooks/cb_api/useAuthenticationApi.tsx | 2 +- app/src/hooks/telemetry/useDeviceApi.tsx | 8 +- .../useSamplingSiteApi.interface.ts | 127 ++++++++ app/src/interfaces/useSurveyApi.interface.ts | 158 +++------- app/src/test-helpers/survey-helpers.ts | 3 +- .../project/project-create-page.ts | 1 - 119 files changed, 1702 insertions(+), 1588 deletions(-) rename app/src/features/surveys/components/{ => agreements}/AgreementsForm.tsx (100%) rename app/src/features/surveys/components/{ => agreements}/ProprietaryDataForm.test.tsx (97%) rename app/src/features/surveys/components/{ => agreements}/ProprietaryDataForm.tsx (100%) rename app/src/features/surveys/components/{ => funding}/SurveyFundingSourceForm.tsx (100%) rename app/src/features/surveys/components/{ => general-information}/GeneralInformationForm.tsx (98%) rename app/src/features/surveys/components/{ => layout}/SurveySectionFullPageLayout.test.tsx (100%) rename app/src/features/surveys/components/{ => layout}/SurveySectionFullPageLayout.tsx (96%) rename app/src/features/surveys/components/{ => layout}/SurveySectionHeader.tsx (100%) rename app/src/features/surveys/components/{ => locations}/StudyAreaForm.test.tsx (92%) rename app/src/features/surveys/components/{ => locations}/StudyAreaForm.tsx (96%) delete mode 100644 app/src/features/surveys/components/locations/SurveyLocationPage.tsx rename app/src/features/surveys/components/{ => locations}/SurveySamplingSiteImportForm.tsx (88%) rename app/src/features/surveys/components/{ => methodology}/PurposeAndMethodologyForm.tsx (97%) rename app/src/features/surveys/components/{ => participants}/SurveyUserForm.test.tsx (98%) rename app/src/features/surveys/components/{ => participants}/SurveyUserForm.tsx (100%) rename app/src/features/surveys/components/{ => sampling-strategy}/SamplingStrategyForm.tsx (92%) rename app/src/features/surveys/components/{ => sampling-strategy}/SurveySiteSelectionForm.tsx (93%) rename app/src/features/surveys/components/{ => sampling-strategy/blocks}/BlockForm.tsx (100%) rename app/src/features/surveys/components/{ => sampling-strategy/blocks}/CreateSurveyBlockDialog.tsx (89%) rename app/src/features/surveys/components/{ => sampling-strategy/blocks}/EditSurveyBlockDialog.tsx (79%) rename app/src/features/surveys/components/{ => sampling-strategy/blocks}/SurveyBlockForm.tsx (96%) rename app/src/features/surveys/components/{ => sampling-strategy/stratums}/StratumCreateOrEditDialog.tsx (95%) rename app/src/features/surveys/components/{ => sampling-strategy/stratums}/SurveyStratumForm.tsx (78%) rename app/src/features/surveys/observations/sampling-sites/{edit => }/components/BlockStratumCard.tsx (100%) rename app/src/features/surveys/observations/sampling-sites/{ => components}/SamplingSiteHeader.tsx (96%) rename app/src/features/surveys/observations/sampling-sites/{edit/components => components/map}/SamplingSiteEditMapControl.tsx (78%) rename app/src/features/surveys/observations/sampling-sites/components/{ => map}/SamplingSiteMapControl.tsx (94%) rename app/src/features/surveys/observations/sampling-sites/{edit/components => components/map}/SurveySampleSiteEditForm.tsx (67%) rename app/src/features/surveys/observations/sampling-sites/components/{ => map/file-upload}/SampleSiteFileUploadItemActionButton.tsx (100%) rename app/src/features/surveys/observations/sampling-sites/components/{ => map/file-upload}/SampleSiteFileUploadItemProgressBar.tsx (100%) rename app/src/features/surveys/observations/sampling-sites/components/{ => map/file-upload}/SampleSiteFileUploadItemSubtext.tsx (100%) rename app/src/features/surveys/observations/sampling-sites/{ => create}/SamplingSitePage.tsx (59%) rename app/src/features/surveys/{components => observations/sampling-sites/create/form}/CreateSamplingMethod.tsx (87%) rename app/src/features/surveys/{components => observations/sampling-sites/create/form}/MethodForm.tsx (86%) create mode 100644 app/src/features/surveys/observations/sampling-sites/create/form/SampleSiteCreateForm.tsx rename app/src/features/surveys/{components => observations/sampling-sites/create/form}/SamplingMethodForm.tsx (84%) delete mode 100644 app/src/features/surveys/observations/sampling-sites/edit/components/SamplingBlockEditForm.tsx delete mode 100644 app/src/features/surveys/observations/sampling-sites/edit/components/SamplingStratumEditForm.tsx rename app/src/features/surveys/{components => observations/sampling-sites/edit/form}/EditSamplingMethod.tsx (52%) rename app/src/features/surveys/observations/sampling-sites/edit/{components => form}/SampleMethodEditForm.tsx (84%) rename app/src/features/surveys/observations/sampling-sites/edit/{components => form}/SampleSiteEditForm.tsx (66%) rename app/src/features/surveys/observations/sampling-sites/edit/{components => form}/SampleSiteGeneralInformationForm.tsx (62%) rename app/src/features/surveys/observations/sampling-sites/{components => edit/form}/SamplingBlockForm.tsx (90%) rename app/src/features/surveys/observations/sampling-sites/edit/{components => form}/SamplingStratumChips.tsx (90%) rename app/src/features/surveys/observations/sampling-sites/{components => edit/form}/SamplingStratumForm.tsx (79%) create mode 100644 app/src/interfaces/useSamplingSiteApi.interface.ts diff --git a/Makefile b/Makefile index 0cccbcc54e..9f9bc074c2 100644 --- a/Makefile +++ b/Makefile @@ -309,7 +309,17 @@ pipeline-install: ## Runs `npm install` for all projects ## Run `docker logs -f` commands for all projects ## - You can include additional parameters by appaending an `args` param ## - Ex: `make log-app args="--tail 0"` +## Note: The default args, if not provided, are `--tail 2000` ## ------------------------------------------------------------------------------ + +args ?= --tail 2000 ## Default args if none are provided + +log: ## Runs `docker-compose logs -f` for all containers + @echo "===============================================" + @echo "Running docker logs for the app container" + @echo "===============================================" + @docker-compose logs -f $(args) + log-app: ## Runs `docker logs -f` for the app container @echo "===============================================" @echo "Running docker logs for the app container" diff --git a/api/src/app.ts b/api/src/app.ts index 0c3ab66061..f0961d8249 100644 --- a/api/src/app.ts +++ b/api/src/app.ts @@ -187,12 +187,12 @@ function validateAllResponses(req: Request, res: Response, next: NextFunction) { return json.apply(res, args); } - const body = args[0]; + const reqBody = args[0]; // Run openapi response validation function const validationResult: { message: any; errors: any[] } | undefined = res['validateResponse']( res.statusCode, - body + reqBody ); let validationMessage = ''; @@ -215,7 +215,9 @@ function validateAllResponses(req: Request, res: Response, next: NextFunction) { message: validationMessage, error: errorList, req_url: `${req.method} ${req.url}`, - res_body: body + req_params: req.params, + req_body: req.body, + res_body: reqBody }); throw new HTTP500(validationMessage, errorList); diff --git a/api/src/database/db-utils.test.ts b/api/src/database/db-utils.test.ts index a64c52381a..892ed6949d 100644 --- a/api/src/database/db-utils.test.ts +++ b/api/src/database/db-utils.test.ts @@ -1,7 +1,5 @@ import { expect } from 'chai'; -import { QueryResult } from 'pg'; import sinon from 'sinon'; -import { z } from 'zod'; import { SYSTEM_IDENTITY_SOURCE } from '../constants/database'; import { BceidBasicUserInformation, @@ -9,59 +7,7 @@ import { DatabaseUserInformation, IdirUserInformation } from '../utils/keycloak-utils'; -import { getGenericizedKeycloakUserInformation, getZodQueryResult } from './db-utils'; - -/** - * Enforces that a zod schema satisfies an existing type definition. - * - * Code copied from: https://github.com/colinhacks/zod/issues/372#issuecomment-1280054492 - * An unresolved feature request was opened as well: https://github.com/colinhacks/zod/issues/2084 - * - * Note: This may not be sufficient to cover ALL possible scenarios. - * - * @example - * const myZodSchema = z.object({...}); - * // Compile error if `myZodSchema` doesn't satisfy `TypeDefinitionToCompareTo` - * zodImplements().with(myZodSchema.shape); - * - * @template Model - * @return {*} - */ -function zodImplements() { - type ZodImplements = { - [key in keyof Model]-?: undefined extends Model[key] - ? null extends Model[key] - ? z.ZodNullableType>> - : z.ZodOptionalType> - : null extends Model[key] - ? z.ZodNullableType> - : z.ZodType; - }; - - return { - with: < - Schema extends ZodImplements & { - [unknownKey in Exclude]: never; - } - >( - schema: Schema - ) => z.object(schema) - }; -} - -describe('getZodQueryResult', () => { - it('defines a zod schema that conforms to the real pg `QueryResult` type', () => { - const zodQueryResultRow = z.object({}); - - const zodQueryResult = getZodQueryResult(zodQueryResultRow); - - // Not a traditional test: will just cause a compile error if the zod schema doesn't satisfy the `QueryResult` type - zodImplements().with(zodQueryResult.shape); - - // Dummy assertion to satisfy linter - expect(true).to.be.true; - }); -}); +import { getGenericizedKeycloakUserInformation } from './db-utils'; describe('getGenericizedKeycloakUserInformation', () => { afterEach(() => { diff --git a/api/src/database/db-utils.ts b/api/src/database/db-utils.ts index 87bf71ef75..4cd7956dca 100644 --- a/api/src/database/db-utils.ts +++ b/api/src/database/db-utils.ts @@ -77,34 +77,6 @@ const parseError = (error: any) => { throw new ApiExecuteSQLError('Failed to execute SQL', [error]); }; -/** - * A re-definition of the pg `QueryResult` type using Zod. - * - * @template T - * @param {T} zodQueryResultRow A zod schema, used to define the `rows` field of the response. In pg, this would - * be the `QueryResultRow` type. - */ -export const getZodQueryResult = (zodQueryResultRow: T) => - z.object({ - rows: z.array(zodQueryResultRow), - command: z.string(), - rowCount: z.number().nullable(), - // Using `coerce` as a workaround for an issue with the QueryResult type definition: it specifies oid is always a - // number, but in reality it can return `null`. - oid: z.coerce.number(), - fields: z.array( - z.object({ - name: z.string(), - tableID: z.number(), - columnID: z.number(), - dataTypeID: z.number(), - dataTypeSize: z.number(), - dataTypeModifier: z.number(), - format: z.string() - }) - ) - }); - /** * Converts a type specific keycloak user information object with type specific properties into a new object with * generic properties. diff --git a/api/src/database/db.ts b/api/src/database/db.ts index 30ad45de91..26fec348de 100644 --- a/api/src/database/db.ts +++ b/api/src/database/db.ts @@ -13,12 +13,7 @@ import { ServiceClientUserInformation } from '../utils/keycloak-utils'; import { getLogger } from '../utils/logger'; -import { - asyncErrorWrapper, - getGenericizedKeycloakUserInformation, - getZodQueryResult, - syncErrorWrapper -} from './db-utils'; +import { asyncErrorWrapper, getGenericizedKeycloakUserInformation, syncErrorWrapper } from './db-utils'; const defaultLog = getLogger('database/db'); @@ -152,33 +147,37 @@ export interface IDBConnection { * Performs a query against this connection, returning the results. * * @example - * // Create a basic SQLStatement object * const sqlStatement = SQL`select * from table where name = ${name}`; + * const response = await connection.sql(sqlStatement, ZodSchema); * * @param {SQLStatement} sqlStatement SQL statement object - * @param {z.Schema} zodSchema An optional zod schema - * @return {*} {(Promise>)} + * @param {z.ZodSchema} [ZodSchema] An optional zod schema that defines the expected shape of a `row`. + * @return {*} {(Promise>)} * @throws If the connection is not open. * @memberof IDBConnection */ sql: ( sqlStatement: SQLStatement, - zodSchema?: z.Schema + ZodSchema?: z.ZodSchema ) => Promise>; /** * Performs a query against this connection, returning the results. * + * @example + * const queryBuilder = getKnex().select().from('table').where('name', name); + * const response = await connection.knex(queryBuilder, ZodSchema); + * * @see {@link getKnex} to get a knex instance. * * @param {Knex.QueryBuilder} queryBuilder Knex query builder object - * @param {z.Schema} zodSchema An optional zod schema - * @return {*} {(Promise>)} + * @param {z.ZodSchema} [ZodSchema] An optional zod schema that defines the expected shape of a `row`. + * @return {*} {(Promise>)} * @throws If the connection is not open. * @memberof IDBConnection */ knex: ( queryBuilder: Knex.QueryBuilder, - zodSchema?: z.Schema + ZodSchema?: z.ZodSchema ) => Promise>; /** * Get the ID of the system user in context. @@ -341,41 +340,61 @@ export const getDBConnection = function (keycloakToken: KeycloakUserInformation) * * @template T * @param {SQLStatement} sqlStatement SQL statement object - * @param {z.Schema} zodSchema An optional zod schema + * @param {z.ZodSchema} [ZodSchema] An optional zod schema that defines the expected shape of a `row`. * @throws {Error} if the connection is not open * @return {*} {Promise>} */ const _sql = async ( sqlStatement: SQLStatement, - zodSchema?: z.Schema + ZodSchema?: z.ZodSchema ): Promise> => { - if (process.env.DATABASE_RESPONSE_VALIDATION_ENABLED !== 'true') { - // Don't validate database responses against provided zod schema - return _query(sqlStatement.text, sqlStatement.values); - } - const queryStart = Date.now(); + const response = await _query(sqlStatement.text, sqlStatement.values); + const queryEnd = Date.now(); - if (!zodSchema) { - defaultLog.silly({ label: '_sql', message: sqlStatement.text, queryExecutionTime: queryEnd - queryStart }); - // No zod schema provided + defaultLog.silly({ + label: '_sql', + message: 'Sql performance', + sql: { sql: sqlStatement.text, bindings: sqlStatement.values }, + queryExecutionTime: queryEnd - queryStart + }); + + if (!ZodSchema || process.env.DATABASE_RESPONSE_VALIDATION_ENABLED !== 'true') { + // No zod schema provided, or database response validation is disabled return response; } - // Validate the response against the zod schema + // Validate the response rows against the zod schema const zodStart = Date.now(); - const validatedResponse = getZodQueryResult(zodSchema).parseAsync(response); + + const zodResponse = + ZodSchema instanceof z.ZodObject + ? z.strictObject({ rows: z.array(ZodSchema.strict()) }).safeParse({ rows: response.rows }) + : z.strictObject({ rows: z.array(ZodSchema) }).safeParse({ rows: response.rows }); + const zodEnd = Date.now(); defaultLog.silly({ - label: '_sql + zod', - message: sqlStatement.text, + label: '_sql', + message: 'Zod performance', + sql: { sql: sqlStatement.text, bindings: sqlStatement.values }, queryExecutionTime: queryEnd - queryStart, zodExecutionTime: zodEnd - zodStart }); - return validatedResponse; + + if (!zodResponse.success) { + defaultLog.debug({ + label: '_sql', + message: 'zodResponse', + zodResponse + }); + + throw new ApiExecuteSQLError('Failed to validate database response', zodResponse.error.errors); + } + + return response; }; /** @@ -383,43 +402,63 @@ export const getDBConnection = function (keycloakToken: KeycloakUserInformation) * * @template T * @param {Knex.QueryBuilder} queryBuilder Knex query builder object - * @param {z.Schema} zodSchema An optional zod schema + * @param {z.ZodSchema} [ZodSchema] An optional zod schema that defines the expected shape of a `row`. * @throws {Error} if the connection is not open * @return {*} {Promise>} */ const _knex = async ( queryBuilder: Knex.QueryBuilder, - zodSchema?: z.Schema + ZodSchema?: z.ZodSchema ) => { const { sql, bindings } = queryBuilder.toSQL().toNative(); - if (process.env.DATABASE_RESPONSE_VALIDATION_ENABLED !== 'true') { - // Don't validate database responses against provided zod schema - return _query(sql, bindings as any[]); - } - const queryStart = Date.now(); + const response = await _query(sql, bindings as any[]); + const queryEnd = Date.now(); - if (!zodSchema) { - defaultLog.silly({ label: '_knex', message: sql, queryExecutionTime: queryEnd - queryStart }); - // No zod schema provided + defaultLog.silly({ + label: '_knex', + message: 'Sql performance', + sql: { sql, bindings }, + queryExecutionTime: queryEnd - queryStart + }); + + if (!ZodSchema || process.env.DATABASE_RESPONSE_VALIDATION_ENABLED !== 'true') { + // No zod schema provided, or database response validation is disabled return response; } - // Validate the response against the zod schema + // Validate the response rows against the zod schema const zodStart = Date.now(); - const validatedResponse = getZodQueryResult(zodSchema).parseAsync(response); + + const zodResponse = + ZodSchema instanceof z.ZodObject + ? z.strictObject({ rows: z.array(ZodSchema.strict()) }).safeParse({ rows: response.rows }) + : z.object({ rows: z.array(ZodSchema) }).safeParse({ rows: response.rows }); + const zodEnd = Date.now(); defaultLog.silly({ - label: '_knex + zod', - message: sql, + label: '_knex', + message: 'Zod performance', + sql: { sql, bindings }, queryExecutionTime: queryEnd - queryStart, zodExecutionTime: zodEnd - zodStart }); - return validatedResponse; + + if (!zodResponse.success) { + defaultLog.debug({ + label: '_knex', + message: 'zodResponse', + zodResponse + }); + + throw new ApiExecuteSQLError('Failed to validate database response', zodResponse.error.errors); + } + + return response; }; /** diff --git a/api/src/models/biohub-create.test.ts b/api/src/models/biohub-create.test.ts index c5dedaba10..81261b3d65 100644 --- a/api/src/models/biohub-create.test.ts +++ b/api/src/models/biohub-create.test.ts @@ -16,6 +16,7 @@ describe('PostSurveyObservationToBiohubObject', () => { const obj = { survey_observation_id: 1, survey_id: 1, + wldtaxonomic_units_id: 1, survey_sample_site_id: 1, survey_sample_method_id: 1, survey_sample_period_id: 1, @@ -158,6 +159,7 @@ describe('PostSurveySubmissionToBioHubObject', () => { { survey_observation_id: 1, survey_id: 1, + wldtaxonomic_units_id: 1, survey_sample_site_id: 1, survey_sample_method_id: 1, survey_sample_period_id: 1, diff --git a/api/src/models/survey-create.test.ts b/api/src/models/survey-create.test.ts index 3326bdff85..fdd1ef06ba 100644 --- a/api/src/models/survey-create.test.ts +++ b/api/src/models/survey-create.test.ts @@ -380,10 +380,6 @@ describe('PostPurposeAndMethodologyData', () => { it('sets vantage_code_ids', () => { expect(data.vantage_code_ids).to.eql([]); }); - - it('sets surveyed_all_areas', () => { - expect(data.surveyed_all_areas).to.equal(null); - }); }); describe('All values provided with first nations id', () => { @@ -392,8 +388,7 @@ describe('PostPurposeAndMethodologyData', () => { const obj = { intended_outcome_ids: [1], additional_details: 'additional_detail', - vantage_code_ids: [4, 5], - surveyed_all_areas: true + vantage_code_ids: [4, 5] }; before(() => { @@ -411,10 +406,6 @@ describe('PostPurposeAndMethodologyData', () => { it('sets vantage_code_ids', () => { expect(data.vantage_code_ids).to.eql(obj.vantage_code_ids); }); - - it('sets surveyed_all_areas', () => { - expect(data.surveyed_all_areas).to.eql(obj.surveyed_all_areas); - }); }); }); diff --git a/api/src/models/survey-create.ts b/api/src/models/survey-create.ts index 3614ec8985..18e85a4f34 100644 --- a/api/src/models/survey-create.ts +++ b/api/src/models/survey-create.ts @@ -127,13 +127,11 @@ export class PostPurposeAndMethodologyData { intended_outcome_ids: number[]; additional_details: string; vantage_code_ids: number[]; - surveyed_all_areas: boolean; constructor(obj?: any) { this.intended_outcome_ids = obj?.intended_outcome_ids || []; this.additional_details = obj?.additional_details || null; this.vantage_code_ids = obj?.vantage_code_ids || []; - this.surveyed_all_areas = obj?.surveyed_all_areas || null; } } diff --git a/api/src/models/survey-update.test.ts b/api/src/models/survey-update.test.ts index 8d0cdcfd84..a4f6ef6408 100644 --- a/api/src/models/survey-update.test.ts +++ b/api/src/models/survey-update.test.ts @@ -344,10 +344,6 @@ describe('PutPurposeAndMethodologyData', () => { expect(data.vantage_code_ids).to.eql([]); }); - it('sets surveyed_all_areas', () => { - expect(data.surveyed_all_areas).to.equal(false); - }); - it('sets revision_count', () => { expect(data.revision_count).to.equal(null); }); @@ -360,7 +356,6 @@ describe('PutPurposeAndMethodologyData', () => { intended_outcome_ids: [1], additional_details: 'additional_detail', vantage_code_ids: [4, 5], - surveyed_all_areas: 'true', revision_count: 0 }; @@ -380,10 +375,6 @@ describe('PutPurposeAndMethodologyData', () => { expect(data.vantage_code_ids).to.eql(obj.vantage_code_ids); }); - it('sets surveyed_all_areas', () => { - expect(data.surveyed_all_areas).to.equal(true); - }); - it('sets revision_count', () => { expect(data.revision_count).to.equal(obj.revision_count); }); diff --git a/api/src/models/survey-update.ts b/api/src/models/survey-update.ts index 7dfe222ed8..b934badc18 100644 --- a/api/src/models/survey-update.ts +++ b/api/src/models/survey-update.ts @@ -137,14 +137,12 @@ export class PutSurveyPurposeAndMethodologyData { intended_outcome_ids: number[]; additional_details: string; vantage_code_ids: number[]; - surveyed_all_areas: boolean; revision_count: number; constructor(obj?: any) { this.intended_outcome_ids = (obj?.intended_outcome_ids?.length && obj?.intended_outcome_ids) || []; this.additional_details = obj?.additional_details || null; this.vantage_code_ids = (obj?.vantage_code_ids?.length && obj.vantage_code_ids) || []; - this.surveyed_all_areas = obj?.surveyed_all_areas === 'true' || false; this.revision_count = obj?.revision_count ?? null; } } diff --git a/api/src/openapi/schemas/geoJson.ts b/api/src/openapi/schemas/geoJson.ts index 3174e689d3..2c8839f0d1 100644 --- a/api/src/openapi/schemas/geoJson.ts +++ b/api/src/openapi/schemas/geoJson.ts @@ -11,7 +11,7 @@ import { OpenAPIV3 } from 'openapi-types'; export const GeoJSONPoint: OpenAPIV3.SchemaObject = { title: 'GeoJSON Point', type: 'object', - additionalProperties: false, + additionalProperties: true, required: ['type', 'coordinates'], properties: { type: { @@ -38,7 +38,7 @@ export const GeoJSONPoint: OpenAPIV3.SchemaObject = { export const GeoJSONLineString: OpenAPIV3.SchemaObject = { title: 'GeoJSON LineString', type: 'object', - additionalProperties: false, + additionalProperties: true, required: ['type', 'coordinates'], properties: { type: { @@ -69,7 +69,7 @@ export const GeoJSONLineString: OpenAPIV3.SchemaObject = { export const GeoJSONPolygon: OpenAPIV3.SchemaObject = { title: 'GeoJSON Polygon', type: 'object', - additionalProperties: false, + additionalProperties: true, required: ['type', 'coordinates'], properties: { type: { @@ -103,7 +103,7 @@ export const GeoJSONPolygon: OpenAPIV3.SchemaObject = { export const GeoJSONMultiPoint: OpenAPIV3.SchemaObject = { title: 'GeoJSON MultiPoint', type: 'object', - additionalProperties: false, + additionalProperties: true, required: ['type', 'coordinates'], properties: { type: { @@ -133,7 +133,7 @@ export const GeoJSONMultiPoint: OpenAPIV3.SchemaObject = { export const GeoJSONMultiLineString: OpenAPIV3.SchemaObject = { title: 'GeoJSON MultiLineString', type: 'object', - additionalProperties: false, + additionalProperties: true, required: ['type', 'coordinates'], properties: { type: { @@ -167,7 +167,7 @@ export const GeoJSONMultiLineString: OpenAPIV3.SchemaObject = { export const GeoJSONMultiPolygon: OpenAPIV3.SchemaObject = { title: 'GeoJSON MultiPolygon', type: 'object', - additionalProperties: false, + additionalProperties: true, required: ['type', 'coordinates'], properties: { type: { @@ -204,7 +204,7 @@ export const GeoJSONMultiPolygon: OpenAPIV3.SchemaObject = { export const GeoJSONGeometryCollection: OpenAPIV3.SchemaObject = { title: 'GeoJSON GeometryCollection', type: 'object', - additionalProperties: false, + additionalProperties: true, required: ['type', 'geometries'], properties: { type: { @@ -237,7 +237,7 @@ export const GeoJSONGeometryCollection: OpenAPIV3.SchemaObject = { export const GeoJSONFeature: OpenAPIV3.SchemaObject = { title: 'GeoJSON Feature', type: 'object', - additionalProperties: false, + additionalProperties: true, required: ['type', 'properties', 'geometry'], properties: { type: { @@ -283,7 +283,7 @@ export const GeoJSONFeature: OpenAPIV3.SchemaObject = { export const GeoJSONFeatureCollection: OpenAPIV3.SchemaObject = { title: 'GeoJSON FeatureCollection', type: 'object', - additionalProperties: false, + additionalProperties: true, required: ['type', 'features'], properties: { type: { @@ -295,7 +295,7 @@ export const GeoJSONFeatureCollection: OpenAPIV3.SchemaObject = { items: { title: 'GeoJSON Feature', type: 'object', - additionalProperties: false, + additionalProperties: true, required: ['type', 'properties', 'geometry'], properties: { type: { diff --git a/api/src/openapi/schemas/survey.ts b/api/src/openapi/schemas/survey.ts index ba5841cc29..700bcb20ab 100644 --- a/api/src/openapi/schemas/survey.ts +++ b/api/src/openapi/schemas/survey.ts @@ -378,6 +378,10 @@ export const surveyLocationSchema: OpenAPIV3.SchemaObject = { type: 'integer', nullable: true }, + survey_id: { + description: 'Survey id', + type: 'integer' + }, leaflet_id: { description: 'Leaflet id', type: 'integer', @@ -439,6 +443,7 @@ export const surveySiteSelectionSchema: OpenAPIV3.SchemaObject = { type: 'array', items: { type: 'object', + additionalProperties: false, required: ['name', 'description'], properties: { name: { @@ -453,14 +458,22 @@ export const surveySiteSelectionSchema: OpenAPIV3.SchemaObject = { survey_id: { description: 'Survey id', type: 'integer', - minimum: 1 + nullable: true }, survey_stratum_id: { description: 'Survey stratum id', type: 'integer', + nullable: true, minimum: 1 }, - ...updateCreateUserPropertiesSchema.properties + sample_stratum_count: { + description: 'Sample stratum count', + type: 'number' + }, + revision_count: { + description: 'Revision count', + type: 'integer' + } } } } @@ -471,12 +484,13 @@ export const surveyBlockSchema: OpenAPIV3.SchemaObject = { title: 'Survey Block', type: 'object', additionalProperties: false, - required: ['name', 'description', 'sample_block_count'], + required: ['name', 'description'], properties: { survey_block_id: { description: 'Survey block id', type: 'integer', - nullable: true + nullable: true, + minimum: 1 }, survey_id: { description: 'Survey id', @@ -485,17 +499,22 @@ export const surveyBlockSchema: OpenAPIV3.SchemaObject = { }, name: { description: 'Name', - type: 'string' + type: 'string', + nullable: true }, description: { description: 'Description', - type: 'string' + type: 'string', + nullable: true }, sample_block_count: { description: 'Sample block count', type: 'number' }, - ...updateCreateUserPropertiesSchema.properties + revision_count: { + description: 'Revision count', + type: 'integer' + } } }; diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/observations/index.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/observations/index.ts index 1072d50b7e..38e138d89d 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/observations/index.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/observations/index.ts @@ -116,13 +116,8 @@ GET.apiDoc = { 'latitude', 'longitude', 'count', - 'observation_time', 'observation_date', - 'create_date', - 'create_user', - 'update_date', - 'update_user', - 'revision_count', + 'observation_time', 'survey_sample_site_name', 'survey_sample_method_name', 'survey_sample_period_start_datetime' @@ -167,33 +162,11 @@ GET.apiDoc = { count: { type: 'integer' }, - observation_time: { - type: 'string' - }, observation_date: { type: 'string' }, - create_date: { - oneOf: [{ type: 'object' }, { type: 'string', format: 'date' }], - description: 'ISO 8601 date string for the project start date' - }, - create_user: { - type: 'integer', - minimum: 1 - }, - update_date: { - oneOf: [{ type: 'object' }, { type: 'string', format: 'date' }], - description: 'ISO 8601 date string for the project start date', - nullable: true - }, - update_user: { - type: 'integer', - minimum: 1, - nullable: true - }, - revision_count: { - type: 'integer', - minimum: 0 + observation_time: { + type: 'string' }, survey_sample_site_name: { type: 'string', diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/index.test.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/index.test.ts index 88dac2fa7c..4be0d3eea6 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/index.test.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/index.test.ts @@ -11,7 +11,7 @@ import * as get_survey_sample_site_record from './index'; chai.use(sinonChai); -describe('getSurveySampleLocationRecords', () => { +describe('getSurveySampleLocationRecord', () => { afterEach(() => { sinon.restore(); }); @@ -24,7 +24,7 @@ describe('getSurveySampleLocationRecords', () => { const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); try { - const requestHandler = get_survey_sample_site_record.getSurveySampleLocationRecords(); + const requestHandler = get_survey_sample_site_record.getSurveySampleLocationRecord(); await requestHandler(mockReq, mockRes, mockNext); expect.fail(); } catch (actualError) { @@ -48,7 +48,7 @@ describe('getSurveySampleLocationRecords', () => { sinon.stub(SampleLocationService.prototype, 'getSampleLocationsForSurveyId').rejects(new Error('an error')); try { - const requestHandler = get_survey_sample_site_record.getSurveySampleLocationRecords(); + const requestHandler = get_survey_sample_site_record.getSurveySampleLocationRecord(); await requestHandler(mockReq, mockRes, mockNext); expect.fail(); @@ -81,14 +81,14 @@ describe('getSurveySampleLocationRecords', () => { update_user: 2, revision_count: 1, sample_methods: [], - sample_blocks: [], - sample_stratums: [] + blocks: [], + stratums: [] }; sinon.stub(SampleLocationService.prototype, 'getSampleLocationsCountBySurveyId').resolves(1); sinon.stub(SampleLocationService.prototype, 'getSampleLocationsForSurveyId').resolves([sampleLocation]); - const requestHandler = get_survey_sample_site_record.getSurveySampleLocationRecords(); + const requestHandler = get_survey_sample_site_record.getSurveySampleLocationRecord(); await requestHandler(mockReq, mockRes, mockNext); diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/index.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/index.ts index 78f0ae1c9c..d98c7416b9 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/index.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/index.ts @@ -39,7 +39,7 @@ export const GET: Operation = [ ] }; }), - getSurveySampleLocationRecords() + getSurveySampleLocationRecord() ]; GET.apiDoc = { @@ -181,7 +181,7 @@ GET.apiDoc = { } } }, - sample_blocks: { + blocks: { type: 'array', items: { type: 'object', @@ -206,7 +206,7 @@ GET.apiDoc = { } } }, - sample_stratums: { + stratums: { type: 'array', items: { type: 'object', @@ -263,7 +263,7 @@ GET.apiDoc = { * * @returns {RequestHandler} */ -export function getSurveySampleLocationRecords(): RequestHandler { +export function getSurveySampleLocationRecord(): RequestHandler { return async (req, res) => { if (!req.params.surveyId) { throw new HTTP400('Missing required param `surveyId`'); @@ -292,7 +292,7 @@ export function getSurveySampleLocationRecords(): RequestHandler { pagination: makePaginationResponse(sampleSitesTotalCount, paginationOptions) }); } catch (error) { - defaultLog.error({ label: 'getSurveySampleLocationRecords', message: 'error', error }); + defaultLog.error({ label: 'getSurveySampleLocationRecord', message: 'error', error }); await connection.rollback(); throw error; } finally { @@ -354,7 +354,7 @@ POST.apiDoc = { schema: { type: 'object', additionalProperties: false, - required: ['methods', 'survey_sample_sites'], + required: ['sample_methods', 'survey_sample_sites'], properties: { survey_id: { type: 'integer' @@ -365,13 +365,13 @@ POST.apiDoc = { description: { type: 'string' }, - methods: { + sample_methods: { type: 'array', minItems: 1, items: { type: 'object', additionalProperties: false, - required: ['method_lookup_id', 'description', 'periods', 'method_response_metric_id'], + required: ['method_lookup_id', 'description', 'sample_periods', 'method_response_metric_id'], properties: { survey_sample_site_id: { type: 'integer', @@ -388,7 +388,7 @@ POST.apiDoc = { description: { type: 'string' }, - periods: { + sample_periods: { type: 'array', minItems: 1, items: { diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/{surveySampleSiteId}/index.test.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/{surveySampleSiteId}/index.test.ts index 34a22ac71a..af2db5dc6f 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/{surveySampleSiteId}/index.test.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/{surveySampleSiteId}/index.test.ts @@ -2,14 +2,13 @@ import chai, { expect } from 'chai'; import { describe } from 'mocha'; import sinon from 'sinon'; import sinonChai from 'sinon-chai'; +import { deleteSurveySampleSiteRecord, getSurveySampleLocationRecord, updateSurveySampleSite } from '.'; import * as db from '../../../../../../../database/db'; import { HTTPError } from '../../../../../../../errors/http-error'; import { UpdateSampleSiteRecord } from '../../../../../../../repositories/sample-location-repository'; import { ObservationService } from '../../../../../../../services/observation-service'; import { SampleLocationService } from '../../../../../../../services/sample-location-service'; import { getMockDBConnection, getRequestHandlerMocks } from '../../../../../../../__mocks__/db'; -import * as delete_survey_sample_site_record from './index'; -import * as put_survey_sample_site from './index'; chai.use(sinonChai); @@ -26,7 +25,7 @@ describe('updateSurveySampleSite', () => { const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); try { - const requestHandler = put_survey_sample_site.updateSurveySampleSite(); + const requestHandler = updateSurveySampleSite(); await requestHandler(mockReq, mockRes, mockNext); expect.fail(); } catch (actualError) { @@ -47,7 +46,7 @@ describe('updateSurveySampleSite', () => { }; try { - const requestHandler = put_survey_sample_site.updateSurveySampleSite(); + const requestHandler = updateSurveySampleSite(); await requestHandler(mockReq, mockRes, mockNext); expect.fail(); } catch (actualError) { @@ -69,7 +68,7 @@ describe('updateSurveySampleSite', () => { }; try { - const requestHandler = put_survey_sample_site.updateSurveySampleSite(); + const requestHandler = updateSurveySampleSite(); await requestHandler(mockReq, mockRes, mockNext); expect.fail(); } catch (actualError) { @@ -108,7 +107,7 @@ describe('updateSurveySampleSite', () => { update_date: 'update_date', update_user: 2, revision_count: 1, - methods: [], + sample_methods: [], blocks: [], stratums: [] } as UpdateSampleSiteRecord @@ -117,7 +116,7 @@ describe('updateSurveySampleSite', () => { sinon.stub(SampleLocationService.prototype, 'updateSampleLocationMethodPeriod').rejects(new Error('an error')); try { - const requestHandler = put_survey_sample_site.updateSurveySampleSite(); + const requestHandler = updateSurveySampleSite(); await requestHandler(mockReq, mockRes, mockNext); expect.fail(); @@ -156,7 +155,7 @@ describe('updateSurveySampleSite', () => { update_date: 'update_date', update_user: 2, revision_count: 1, - methods: [], + sample_methods: [], blocks: [], stratums: [] } as UpdateSampleSiteRecord @@ -166,7 +165,7 @@ describe('updateSurveySampleSite', () => { .stub(SampleLocationService.prototype, 'updateSampleLocationMethodPeriod') .resolves(); - const requestHandler = put_survey_sample_site.updateSurveySampleSite(); + const requestHandler = updateSurveySampleSite(); await requestHandler(mockReq, mockRes, mockNext); @@ -196,7 +195,7 @@ describe('deleteSurveySampleSiteRecord', () => { sinon.stub(db, 'getDBConnection').returns(dbConnectionObj); try { - const result = delete_survey_sample_site_record.deleteSurveySampleSiteRecord(); + const result = deleteSurveySampleSiteRecord(); await result( { ...sampleReq, params: { ...sampleReq.params, surveySampleSiteId: null } }, null as unknown as any, @@ -231,7 +230,7 @@ describe('deleteSurveySampleSiteRecord', () => { participants: [[1, 1, 'job']] }; - const requestHandler = delete_survey_sample_site_record.deleteSurveySampleSiteRecord(); + const requestHandler = deleteSurveySampleSiteRecord(); await requestHandler(mockReq, mockRes, mockNext); @@ -239,4 +238,90 @@ describe('deleteSurveySampleSiteRecord', () => { expect(deleteSampleLocationRecordStub).to.have.been.calledOnce; expect(getObservationsCountBySampleSiteIdStub).to.have.been.calledOnce; }); + + it('should successfully delete a survey sample site record', async () => { + sinon.stub(db, 'getDBConnection').returns(dbConnectionObj); + + const getObservationsCountBySampleSiteIdStub = sinon + .stub(ObservationService.prototype, 'getObservationsCountBySampleSiteIds') + .resolves(0); + + const deleteSampleLocationRecordStub = sinon + .stub(SampleLocationService.prototype, 'deleteSampleSiteRecord') + .resolves(); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + + mockReq.params = { + surveyId: '1', + surveySampleSiteId: '2' + }; + + mockReq.body = { + participants: [[1, 1, 'job']] + }; + + const requestHandler = deleteSurveySampleSiteRecord(); + + await requestHandler(mockReq, mockRes, mockNext); + + expect(mockRes.status).to.have.been.calledWith(204); + expect(deleteSampleLocationRecordStub).to.have.been.calledOnce; + expect(getObservationsCountBySampleSiteIdStub).to.have.been.calledOnce; + }); +}); + +describe('getSurveySampleLocationRecord', () => { + const dbConnectionObj = getMockDBConnection(); + + const sampleReq = { + keycloak_token: {}, + params: { + surveyId: 1, + surveySampleSiteId: 1 + } + } as any; + + afterEach(() => { + sinon.restore(); + }); + + it('should throw a 400 error when no surveySampleSiteId in the param', async () => { + sinon.stub(db, 'getDBConnection').returns(dbConnectionObj); + + try { + const result = getSurveySampleLocationRecord(); + await result( + { ...sampleReq, params: { ...sampleReq.params, surveySampleSiteId: null } }, + null as unknown as any, + null as unknown as any + ); + expect.fail(); + } catch (actualError) { + expect((actualError as HTTPError).status).to.equal(400); + expect((actualError as HTTPError).message).to.equal('Missing required param `surveySampleSiteId`'); + } + }); + + it('should successfully get a sample location record', async () => { + sinon.stub(db, 'getDBConnection').returns(dbConnectionObj); + + const getSurveySampleLocationBySiteIdStub = sinon + .stub(SampleLocationService.prototype, 'getSurveySampleLocationBySiteId') + .resolves(); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + + mockReq.params = { + surveyId: '1', + surveySampleSiteId: '2' + }; + + const requestHandler = getSurveySampleLocationRecord(); + + await requestHandler(mockReq, mockRes, mockNext); + + expect(mockRes.status).to.have.been.calledWith(200); + expect(getSurveySampleLocationBySiteIdStub).to.have.been.calledOnce; + }); }); diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/{surveySampleSiteId}/index.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/{surveySampleSiteId}/index.ts index 90a5102f96..4cdb8d8984 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/{surveySampleSiteId}/index.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/{surveySampleSiteId}/index.ts @@ -99,7 +99,7 @@ PUT.apiDoc = { items: { type: 'object', additionalProperties: false, - required: ['method_lookup_id', 'description', 'periods', 'method_response_metric_id'], + required: ['method_lookup_id', 'description', 'sample_periods', 'method_response_metric_id'], properties: { survey_sample_site_id: { type: 'integer', @@ -117,7 +117,7 @@ PUT.apiDoc = { description: { type: 'string' }, - periods: { + sample_periods: { type: 'array', minItems: 1, items: { @@ -165,6 +165,7 @@ PUT.apiDoc = { type: 'array', items: { type: 'object', + additionalProperties: false, required: ['survey_block_id'], properties: { survey_block_id: { @@ -177,6 +178,7 @@ PUT.apiDoc = { type: 'array', items: { type: 'object', + additionalProperties: false, required: ['survey_stratum_id'], properties: { survey_stratum_id: { @@ -252,7 +254,7 @@ export function updateSurveySampleSite(): RequestHandler { return res.status(204).send(); } catch (error) { - defaultLog.error({ label: 'updateSurveySampleSite', message: 'error', error }); + defaultLog.error({ label: 'updateSampleLocationMethodPeriod', message: 'error', error }); await connection.rollback(); throw error; } finally { @@ -378,3 +380,266 @@ export function deleteSurveySampleSiteRecord(): RequestHandler { } }; } + +export const GET: Operation = [ + authorizeRequestHandler((req) => { + return { + or: [ + { + validProjectPermissions: [ + PROJECT_PERMISSION.COORDINATOR, + PROJECT_PERMISSION.COLLABORATOR, + PROJECT_PERMISSION.OBSERVER + ], + surveyId: Number(req.params.surveyId), + discriminator: 'ProjectPermission' + }, + { + validSystemRoles: [SYSTEM_ROLE.DATA_ADMINISTRATOR], + discriminator: 'SystemRole' + } + ] + }; + }), + getSurveySampleLocationRecord() +]; + +GET.apiDoc = { + description: 'Get a survey sample site by id.', + tags: ['survey'], + security: [ + { + Bearer: [] + } + ], + parameters: [ + { + in: 'path', + name: 'projectId', + schema: { + type: 'integer', + minimum: 1 + }, + required: true + }, + { + in: 'path', + name: 'surveyId', + schema: { + type: 'integer', + minimum: 1 + }, + required: true + } + ], + responses: { + 200: { + description: 'A survey sample site', + content: { + 'application/json': { + schema: { + type: 'object', + additionalProperties: false, + required: ['survey_sample_site_id', 'survey_id', 'name', 'description', 'geojson'], + properties: { + survey_sample_site_id: { + type: 'integer', + minimum: 1 + }, + survey_id: { + type: 'integer', + minimum: 1 + }, + name: { + type: 'string', + maxLength: 50 + }, + description: { + type: 'string', + maxLength: 250 + }, + geojson: { + ...(GeoJSONFeature as object) + }, + sample_methods: { + type: 'array', + required: [ + 'survey_sample_method_id', + 'survey_sample_site_id', + 'method_lookup_id', + 'method_response_metric_id', + 'sample_periods' + ], + items: { + type: 'object', + additionalProperties: false, + properties: { + survey_sample_method_id: { + type: 'integer', + minimum: 1 + }, + survey_sample_site_id: { + type: 'integer', + minimum: 1 + }, + method_lookup_id: { + type: 'integer', + minimum: 1 + }, + description: { + type: 'string', + maxLength: 250 + }, + sample_periods: { + type: 'array', + required: [ + 'survey_sample_period_id', + 'survey_sample_method_id', + 'start_date', + 'start_time', + 'end_date', + 'end_time' + ], + items: { + type: 'object', + additionalProperties: false, + properties: { + survey_sample_period_id: { + type: 'integer', + minimum: 1 + }, + survey_sample_method_id: { + type: 'integer', + minimum: 1 + }, + start_date: { + type: 'string' + }, + start_time: { + type: 'string', + nullable: true + }, + end_date: { + type: 'string' + }, + end_time: { + type: 'string', + nullable: true + } + } + } + }, + method_response_metric_id: { type: 'integer', minimum: 1 } + } + } + }, + blocks: { + type: 'array', + items: { + type: 'object', + additionalProperties: false, + required: ['survey_sample_block_id', 'survey_sample_site_id', 'survey_block_id'], + properties: { + survey_sample_block_id: { + type: 'number' + }, + survey_sample_site_id: { + type: 'number' + }, + survey_block_id: { + type: 'number' + }, + name: { + type: 'string' + }, + description: { + type: 'string' + } + } + } + }, + stratums: { + type: 'array', + items: { + type: 'object', + additionalProperties: false, + required: ['survey_sample_stratum_id', 'survey_sample_site_id', 'survey_stratum_id'], + properties: { + survey_sample_stratum_id: { + type: 'number' + }, + survey_sample_site_id: { + type: 'number' + }, + survey_stratum_id: { + type: 'number' + }, + name: { + type: 'string' + }, + description: { + type: 'string' + } + } + } + } + } + } + } + } + }, + 400: { + $ref: '#/components/responses/400' + }, + 401: { + $ref: '#/components/responses/401' + }, + 403: { + $ref: '#/components/responses/403' + }, + 500: { + $ref: '#/components/responses/500' + }, + default: { + $ref: '#/components/responses/default' + } + } +}; + +/** + * Get a single survey sample site by Id + * + * @returns {RequestHandler} + */ +export function getSurveySampleLocationRecord(): RequestHandler { + return async (req, res) => { + if (!req.params.surveyId) { + throw new HTTP400('Missing required param `surveyId`'); + } + if (!req.params.surveySampleSiteId) { + throw new HTTP400('Missing required param `surveySampleSiteId`'); + } + + const connection = getDBConnection(req['keycloak_token']); + + try { + await connection.open(); + + const surveyId = Number(req.params.surveyId); + const surveySampleSiteId = Number(req.params.surveySampleSiteId); + + const sampleLocationService = new SampleLocationService(connection); + const sampleSite = await sampleLocationService.getSurveySampleLocationBySiteId(surveyId, surveySampleSiteId); + + await connection.commit(); + + return res.status(200).json(sampleSite); + } catch (error) { + defaultLog.error({ label: 'getSurveySampleLocationRecord', message: 'error', error }); + await connection.rollback(); + throw error; + } finally { + connection.release(); + } + }; +} diff --git a/api/src/repositories/code-repository.ts b/api/src/repositories/code-repository.ts index 470de98bd1..93003addf7 100644 --- a/api/src/repositories/code-repository.ts +++ b/api/src/repositories/code-repository.ts @@ -2,67 +2,75 @@ import SQL from 'sql-template-strings'; import { z } from 'zod'; import { BaseRepository } from './base-repository'; -export const ICodeWithDescription = z.object({ +export const ICode = z.object({ id: z.number(), - name: z.string(), - description: z.string() + name: z.string() }); - -export type ICodeWithDescription = z.infer; - -export const ICode = ICodeWithDescription.pick({ - id: true, - name: true -}); - export type ICode = z.infer; export const CodeSet = (zodSchema?: T) => { - return (zodSchema && z.array(ICode.extend(zodSchema))) || z.array(ICode); + return (zodSchema && z.array(zodSchema.shape)) || z.array(ICode); }; +const InvestmentActionCategoryCode = ICode.extend({ agency_id: z.number() }); +const ProprietorTypeCode = ICode.extend({ is_first_nation: z.boolean() }); +const IucnConservationActionLevel2SubclassificationCode = ICode.extend({ iucn1_id: z.number() }); +const IucnConservationActionLevel3SubclassificationCode = ICode.extend({ iucn2_id: z.number() }); +const IntendedOutcomeCode = ICode.extend({ description: z.string() }); +const SampleMethodsCode = ICode.extend({ description: z.string() }); +const SurveyProgressCode = ICode.extend({ description: z.string() }); +const MethodResponseMetricsCode = ICode.extend({ description: z.string() }); + export const IAllCodeSets = z.object({ management_action_type: CodeSet(), first_nations: CodeSet(), agency: CodeSet(), - investment_action_category: CodeSet(z.object({ agency_id: z.number() }).shape), + investment_action_category: CodeSet(InvestmentActionCategoryCode.shape), type: CodeSet(), program: CodeSet(), - proprietor_type: CodeSet(z.object({ id: z.number(), name: z.string(), is_first_nation: z.boolean() }).shape), + proprietor_type: CodeSet(ProprietorTypeCode.shape), iucn_conservation_action_level_1_classification: CodeSet(), - iucn_conservation_action_level_2_subclassification: CodeSet( - z.object({ id: z.number(), iucn1_id: z.number(), name: z.string() }).shape - ), - iucn_conservation_action_level_3_subclassification: CodeSet( - z.object({ id: z.number(), iucn2_id: z.number(), name: z.string() }).shape - ), + iucn_conservation_action_level_2_subclassification: CodeSet(IucnConservationActionLevel2SubclassificationCode.shape), + iucn_conservation_action_level_3_subclassification: CodeSet(IucnConservationActionLevel3SubclassificationCode.shape), system_roles: CodeSet(), project_roles: CodeSet(), administrative_activity_status_type: CodeSet(), - intended_outcomes: CodeSet(z.object({ id: z.number(), name: z.string(), description: z.string() }).shape), + intended_outcomes: CodeSet(IntendedOutcomeCode.shape), vantage_codes: CodeSet(), survey_jobs: CodeSet(), site_selection_strategies: CodeSet(), - sample_methods: CodeSet(z.object({ id: z.number(), name: z.string(), description: z.string() }).shape), - survey_progress: CodeSet(z.object({ id: z.number(), name: z.string(), description: z.string() }).shape), - method_response_metrics: CodeSet(z.object({ id: z.number(), name: z.string(), description: z.string() }).shape) + sample_methods: CodeSet(SampleMethodsCode.shape), + survey_progress: CodeSet(SurveyProgressCode.shape), + method_response_metrics: CodeSet(MethodResponseMetricsCode.shape) }); - export type IAllCodeSets = z.infer; export class CodeRepository extends BaseRepository { + /** + * Fetch sample method codes. + * + * @return {*} + * @memberof CodeRepository + */ async getSampleMethods() { const sql = SQL` - SELECT method_lookup_id as id, name, description FROM method_lookup ORDER BY name ASC; + SELECT + method_lookup_id as id, + name, + description + FROM method_lookup + ORDER BY name ASC; `; + const response = await this.connection.sql(sql); + return response.rows; } /** * Fetch management action type codes. * - * @return {ICode} + * @return {*} * @memberof CodeRepository */ async getManagementActionType() { @@ -70,10 +78,8 @@ export class CodeRepository extends BaseRepository { SELECT management_action_type_id as id, name - FROM - management_action_type - WHERE - record_end_date is null; + FROM management_action_type + WHERE record_end_date is null; `; const response = await this.connection.sql(sqlStatement, ICode); @@ -84,7 +90,7 @@ export class CodeRepository extends BaseRepository { /** * Fetch first nation codes. * - * @return {ICode} + * @return {*} * @memberof CodeRepository */ async getFirstNations() { @@ -92,10 +98,9 @@ export class CodeRepository extends BaseRepository { SELECT first_nations_id as id, name - FROM - first_nations - WHERE - record_end_date is null ORDER BY name ASC; + FROM first_nations + WHERE record_end_date is null + ORDER BY name ASC; `; const response = await this.connection.sql(sqlStatement, ICode); @@ -106,7 +111,7 @@ export class CodeRepository extends BaseRepository { /** * Fetch agency codes. * - * @return {ICode} + * @return {*} * @memberof CodeRepository */ async getAgency() { @@ -114,10 +119,9 @@ export class CodeRepository extends BaseRepository { SELECT agency_id as id, name - FROM - agency - WHERE - record_end_date is null ORDER BY name ASC; + FROM agency + WHERE record_end_date is null + ORDER BY name ASC; `; const response = await this.connection.sql(sqlStatement, ICode); @@ -137,13 +141,11 @@ export class CodeRepository extends BaseRepository { proprietor_type_id as id, name, is_first_nation - FROM - proprietor_type - WHERE - record_end_date is null; + FROM proprietor_type + WHERE record_end_date is null; `; - const response = await this.connection.sql(sqlStatement, ICode); + const response = await this.connection.sql(sqlStatement, ProprietorTypeCode); return response.rows; } @@ -151,7 +153,7 @@ export class CodeRepository extends BaseRepository { /** * Fetch activity codes. * - * @return {ICode} + * @return {*} * @memberof CodeRepository */ async getType() { @@ -161,8 +163,7 @@ export class CodeRepository extends BaseRepository { name FROM type - WHERE - record_end_date is null; + WHERE record_end_date is null; `; const response = await this.connection.sql(sqlStatement, ICode); @@ -173,7 +174,7 @@ export class CodeRepository extends BaseRepository { /** * Fetch vantage codes. * - * @return {ICode} + * @return {*} * @memberof CodeRepository */ async getVantageCodes() { @@ -181,8 +182,7 @@ export class CodeRepository extends BaseRepository { SELECT vantage_id as id, name - FROM - vantage + FROM vantage WHERE record_end_date is null; `; @@ -193,8 +193,9 @@ export class CodeRepository extends BaseRepository { /** * Fetch intended outcomes codes. - * @return {ICodeWithDescription} * + * @return {*} + * @memberof CodeRepository */ async getIntendedOutcomes() { const sqlStatement = SQL` @@ -202,12 +203,11 @@ export class CodeRepository extends BaseRepository { intended_outcome_id as id, name, description - FROM - intended_outcome + FROM intended_outcome WHERE record_end_date is null; `; - const response = await this.connection.sql(sqlStatement, ICodeWithDescription); + const response = await this.connection.sql(sqlStatement, IntendedOutcomeCode); return response.rows; } @@ -215,7 +215,7 @@ export class CodeRepository extends BaseRepository { /** * Fetch project type codes. * - * @return {ICode} + * @return {*} * @memberof CodeRepository */ async getProgram() { @@ -223,10 +223,8 @@ export class CodeRepository extends BaseRepository { SELECT program_id as id, name - FROM - program - WHERE - record_end_date is null; + FROM program + WHERE record_end_date is null; `; const response = await this.connection.sql(sqlStatement, ICode); @@ -246,12 +244,12 @@ export class CodeRepository extends BaseRepository { investment_action_category_id as id, agency_id, name - FROM - investment_action_category - WHERE record_end_date is null ORDER BY name ASC; + FROM investment_action_category + WHERE record_end_date is null + ORDER BY name ASC; `; - const response = await this.connection.sql(sqlStatement, ICode); + const response = await this.connection.sql(sqlStatement, InvestmentActionCategoryCode); return response.rows; } @@ -259,7 +257,7 @@ export class CodeRepository extends BaseRepository { /** * Fetch IUCN conservation action level 1 classification codes. * - * @return {ICode} + * @return {*} * @memberof CodeRepository */ async getIUCNConservationActionLevel1Classification() { @@ -267,10 +265,8 @@ export class CodeRepository extends BaseRepository { SELECT iucn_conservation_action_level_1_classification_id as id, name - FROM - iucn_conservation_action_level_1_classification - WHERE - record_end_date is null; + FROM iucn_conservation_action_level_1_classification + WHERE record_end_date is null; `; const response = await this.connection.sql(sqlStatement, ICode); @@ -290,13 +286,11 @@ export class CodeRepository extends BaseRepository { iucn_conservation_action_level_2_subclassification_id as id, iucn_conservation_action_level_1_classification_id as iucn1_id, name - FROM - iucn_conservation_action_level_2_subclassification - WHERE - record_end_date is null; + FROM iucn_conservation_action_level_2_subclassification + WHERE record_end_date is null; `; - const response = await this.connection.sql(sqlStatement, ICode); + const response = await this.connection.sql(sqlStatement, IucnConservationActionLevel2SubclassificationCode); return response.rows; } @@ -313,13 +307,11 @@ export class CodeRepository extends BaseRepository { iucn_conservation_action_level_3_subclassification_id as id, iucn_conservation_action_level_2_subclassification_id as iucn2_id, name - FROM - iucn_conservation_action_level_3_subclassification - WHERE - record_end_date is null; + FROM iucn_conservation_action_level_3_subclassification + WHERE record_end_date is null; `; - const response = await this.connection.sql(sqlStatement, ICode); + const response = await this.connection.sql(sqlStatement, IucnConservationActionLevel3SubclassificationCode); return response.rows; } @@ -327,7 +319,7 @@ export class CodeRepository extends BaseRepository { /** * Fetch system role codes. * - * @return {ICode} + * @return {*} * @memberof CodeRepository */ async getSystemRoles() { @@ -335,10 +327,8 @@ export class CodeRepository extends BaseRepository { SELECT system_role_id as id, name - FROM - system_role - WHERE - record_end_date is null; + FROM system_role + WHERE record_end_date is null; `; const response = await this.connection.sql(sqlStatement, ICode); @@ -349,7 +339,7 @@ export class CodeRepository extends BaseRepository { /** * Fetch project role codes. * - * @return {ICode} + * @return {*} * @memberof CodeRepository */ async getProjectRoles() { @@ -357,10 +347,8 @@ export class CodeRepository extends BaseRepository { SELECT project_role_id as id, name - FROM - project_role - WHERE - record_end_date is null; + FROM project_role + WHERE record_end_date is null; `; const response = await this.connection.sql(sqlStatement, ICode); @@ -371,7 +359,7 @@ export class CodeRepository extends BaseRepository { /** * Fetch survey job codes. * - * @return {ICode} + * @return {*} * @memberof CodeRepository */ async getSurveyJobs() { @@ -379,10 +367,8 @@ export class CodeRepository extends BaseRepository { SELECT survey_job_id as id, name - FROM - survey_job - WHERE - record_end_date is null; + FROM survey_job + WHERE record_end_date is null; `; const response = await this.connection.sql(sqlStatement, ICode); @@ -393,7 +379,7 @@ export class CodeRepository extends BaseRepository { /** * Fetch site selection strategy codes * - * @return {ICode} + * @return {*} * @memberof CodeRepository */ async getSiteSelectionStrategies() { @@ -401,10 +387,8 @@ export class CodeRepository extends BaseRepository { SELECT ss.site_strategy_id as id, ss.name - FROM - site_strategy ss - WHERE - record_end_date is null; + FROM site_strategy ss + WHERE record_end_date is null; `; const response = await this.connection.sql(sqlStatement, ICode); @@ -415,7 +399,7 @@ export class CodeRepository extends BaseRepository { /** * Fetch administrative activity status type codes. * - * @return {ICode} + * @return {*} * @memberof CodeRepository */ async getAdministrativeActivityStatusType() { @@ -423,10 +407,8 @@ export class CodeRepository extends BaseRepository { SELECT administrative_activity_status_type_id as id, name - FROM - administrative_activity_status_type - WHERE - record_end_date is null; + FROM administrative_activity_status_type + WHERE record_end_date is null; `; const response = await this.connection.sql(sqlStatement, ICode); @@ -437,7 +419,7 @@ export class CodeRepository extends BaseRepository { /** * Fetch survey progress codes. * - * @return {ICodeWithDescription} + * @return {*} * @memberof CodeRepository */ async getSurveyProgress() { @@ -446,13 +428,11 @@ export class CodeRepository extends BaseRepository { survey_progress_id as id, name, description - FROM - survey_progress - WHERE - record_end_date is null; + FROM survey_progress + WHERE record_end_date is null; `; - const response = await this.connection.sql(sqlStatement, ICodeWithDescription); + const response = await this.connection.sql(sqlStatement, SurveyProgressCode); return response.rows; } @@ -460,22 +440,20 @@ export class CodeRepository extends BaseRepository { /** * Fetch method response metrics * - * @return {Promise} + * @return {*} * @memberof CodeRepository */ - async getMethodResponseMetrics(): Promise { + async getMethodResponseMetrics() { const sqlStatement = SQL` SELECT method_response_metric_id AS id, name, description - FROM - method_response_metric - WHERE - record_end_date IS null; + FROM method_response_metric + WHERE record_end_date IS null; `; - const response = await this.connection.sql(sqlStatement, ICodeWithDescription); + const response = await this.connection.sql(sqlStatement, MethodResponseMetricsCode); return response.rows; } diff --git a/api/src/repositories/observation-repository.test.ts b/api/src/repositories/observation-repository.test.ts index bead4f3e38..d28877ce69 100644 --- a/api/src/repositories/observation-repository.test.ts +++ b/api/src/repositories/observation-repository.test.ts @@ -140,7 +140,7 @@ describe('ObservationRepository', () => { describe('getSurveyObservationCount', () => { it('gets the count of survey observations for the given survey', async () => { - const mockQueryResponse = { rows: [{ rowCount: 1 }] } as unknown as QueryResult; + const mockQueryResponse = { rows: [{ count: 1 }] } as unknown as QueryResult; const mockDBConnection = getMockDBConnection({ knex: sinon.stub().resolves(mockQueryResponse) @@ -226,7 +226,7 @@ describe('ObservationRepository', () => { describe('getObservationsCountBySampleSiteIds', () => { it('gets the observation count by sample site ids', async () => { - const mockQueryResponse = { rows: [{ observation_count: 50 }], rowCount: 1 } as unknown as QueryResult; + const mockQueryResponse = { rows: [{ count: 50 }], rowCount: 1 } as unknown as QueryResult; const mockDBConnection = getMockDBConnection({ knex: sinon.stub().resolves(mockQueryResponse) diff --git a/api/src/repositories/observation-repository.ts b/api/src/repositories/observation-repository.ts index ac98265945..7f40db9b98 100644 --- a/api/src/repositories/observation-repository.ts +++ b/api/src/repositories/observation-repository.ts @@ -3,6 +3,7 @@ import { z } from 'zod'; import { getKnex } from '../database/db'; import { ApiExecuteSQLError } from '../errors/api-error'; import { getLogger } from '../utils/logger'; +import { GeoJSONPointZodSchema } from '../zod-schema/geoJsonZodSchema'; import { ApiPaginationOptions } from '../zod-schema/pagination'; import { BaseRepository } from './base-repository'; import { @@ -19,6 +20,7 @@ const defaultLog = getLogger('repositories/observation-repository'); export const ObservationRecord = z.object({ survey_observation_id: z.number(), survey_id: z.number(), + wldtaxonomic_units_id: z.number().nullable(), itis_tsn: z.number(), itis_scientific_name: z.string().nullable(), survey_sample_site_id: z.number().nullable(), @@ -68,29 +70,32 @@ const ObservationSubcountsObject = z.object({ /** * An extended observation record. * Includes: - * - all fields from the observation record + * - fields from the observation record * - additional fields about the survey_sample_* data for the observation record * - additional fields about the subcount records for the observation record */ -export const ObservationRecordWithSamplingAndSubcountData = ObservationRecord.extend( - ObservationSamplingData.shape -).extend(ObservationSubcountsObject.shape); - +export const ObservationRecordWithSamplingAndSubcountData = ObservationRecord.pick({ + survey_observation_id: true, + survey_id: true, + itis_tsn: true, + itis_scientific_name: true, + survey_sample_site_id: true, + survey_sample_method_id: true, + survey_sample_period_id: true, + latitude: true, + longitude: true, + count: true, + observation_time: true, + observation_date: true +}) + .extend(ObservationSamplingData.shape) + .extend(ObservationSubcountsObject.shape); export type ObservationRecordWithSamplingAndSubcountData = z.infer; -const GeoJSONPointSchema = z.object({ - type: z - .string() - .optional() - .refine((val) => val === 'Point', { message: 'Type must be "Point"' }), - coordinates: z.array(z.number()).min(2).max(2) // Assuming GeoJSON Point has 2 coordinates (longitude and latitude) -}); - export const ObservationGeometryRecord = z.object({ survey_observation_id: z.number(), - geometry: GeoJSONPointSchema + geometry: GeoJSONPointZodSchema }); - export type ObservationGeometryRecord = z.infer; /** @@ -404,7 +409,18 @@ export class ObservationRepository extends BaseRepository { ) // Return all observations for the surveys, including the additional sampling data, and rolled up subcount data .select( - 'survey_observation.*', + 'survey_observation.survey_observation_id', + 'survey_observation.survey_id', + 'survey_observation.itis_tsn', + 'survey_observation.itis_scientific_name', + 'survey_observation.survey_sample_site_id', + 'survey_observation.survey_sample_method_id', + 'survey_observation.survey_sample_period_id', + 'survey_observation.latitude', + 'survey_observation.longitude', + 'survey_observation.count', + 'survey_observation.observation_date', + 'survey_observation.observation_time', 'w_survey_sample_site.survey_sample_site_name', 'w_survey_sample_method.survey_sample_method_name', 'w_survey_sample_period.survey_sample_period_start_datetime', @@ -522,13 +538,13 @@ export class ObservationRepository extends BaseRepository { const knex = getKnex(); const sqlStatement = knex .queryBuilder() - .count('survey_observation_id as rowCount') + .select(knex.raw('COUNT(survey_observation_id)::integer as count')) .from('survey_observation') .where('survey_id', surveyId); - const response = await this.connection.knex(sqlStatement); + const response = await this.connection.knex(sqlStatement, z.object({ count: z.number() })); - return Number(response.rows[0].rowCount); + return response.rows[0].count; } /** @@ -644,12 +660,12 @@ export class ObservationRepository extends BaseRepository { const knex = getKnex(); const sqlStatement = knex .queryBuilder() - .count('survey_observation_id as observation_count') + .select(knex.raw('COUNT(survey_observation_id)::integer as count')) .from('survey_observation') .where('survey_id', surveyId) .whereIn('survey_sample_site_id', sampleSiteIds); - const response = await this.connection.knex(sqlStatement); + const response = await this.connection.knex(sqlStatement, z.object({ count: z.number() })); if (response?.rowCount !== 1) { throw new ApiExecuteSQLError('Failed to get observations count', [ @@ -658,8 +674,7 @@ export class ObservationRepository extends BaseRepository { ]); } - const observation_count = Number(response.rows[0].observation_count); - return observation_count; + return Number(response.rows[0].count); } /** @@ -673,11 +688,11 @@ export class ObservationRepository extends BaseRepository { const knex = getKnex(); const sqlStatement = knex .queryBuilder() - .count('survey_observation_id as observation_count') + .select(knex.raw('COUNT(survey_observation_id)::integer as count')) .from('survey_observation') .whereIn('survey_sample_method_id', sampleMethodIds); - const response = await this.connection.knex(sqlStatement); + const response = await this.connection.knex(sqlStatement, z.object({ count: z.number() })); if (response?.rowCount !== 1) { throw new ApiExecuteSQLError('Failed to get observations count', [ @@ -686,8 +701,7 @@ export class ObservationRepository extends BaseRepository { ]); } - const observation_count = Number(response.rows[0].observation_count); - return observation_count; + return response.rows[0].count; } /** @@ -701,11 +715,11 @@ export class ObservationRepository extends BaseRepository { const knex = getKnex(); const sqlStatement = knex .queryBuilder() - .count('survey_observation_id as rowCount') + .select(knex.raw('COUNT(survey_observation_id)::integer as count')) .from('survey_observation') .whereIn('survey_sample_period_id', samplePeriodIds); - const response = await this.connection.knex(sqlStatement); + const response = await this.connection.knex(sqlStatement, z.object({ count: z.number() })); if (response?.rowCount !== 1) { throw new ApiExecuteSQLError('Failed to get observations count', [ @@ -714,7 +728,6 @@ export class ObservationRepository extends BaseRepository { ]); } - const observation_count = Number(response.rows[0].observation_count); - return observation_count; + return response.rows[0].count; } } diff --git a/api/src/repositories/project-repository.test.ts b/api/src/repositories/project-repository.test.ts index 42d24324c7..bed645f3d1 100644 --- a/api/src/repositories/project-repository.test.ts +++ b/api/src/repositories/project-repository.test.ts @@ -80,7 +80,7 @@ describe('ProjectRepository', () => { describe('getProjectCount', () => { it('should return a project count', async () => { - const mockResponse = { rows: [{ project_count: 69 }], rowCount: 1 } as any as Promise>; + const mockResponse = { rows: [{ count: 69 }], rowCount: 1 } as any as Promise>; const dbConnection = getMockDBConnection({ knex: () => mockResponse }); const repository = new ProjectRepository(dbConnection); diff --git a/api/src/repositories/project-repository.ts b/api/src/repositories/project-repository.ts index 2f43e80aeb..6cf02e00c3 100644 --- a/api/src/repositories/project-repository.ts +++ b/api/src/repositories/project-repository.ts @@ -48,7 +48,6 @@ export class ProjectRepository extends BaseRepository { .select([ 'p.project_id', 'p.name', - 'p.objectives', 'p.start_date', 'p.end_date', knex.raw(`COALESCE(array_remove(array_agg(DISTINCT rl.region_name), null), '{}') as regions`), @@ -164,9 +163,12 @@ export class ProjectRepository extends BaseRepository { ): Promise { const projectsListQuery = this._makeProjectListQuery(isUserAdmin, systemUserId, filterFields); - const query = getKnex().from(projectsListQuery.as('temp')).count('* as project_count'); + const knex = getKnex(); + + // See https://knexjs.org/guide/query-builder.html#usage-with-typescript-3 for details on count() usage + const query = knex.from(projectsListQuery.as('plq')).select(knex.raw('count(*)::integer as count')); - const response = await this.connection.knex(query, z.object({ project_count: z.string().transform(Number) })); + const response = await this.connection.knex(query, z.object({ count: z.number() })); if (!response.rowCount) { throw new ApiExecuteSQLError('Failed to get project count', [ @@ -175,7 +177,7 @@ export class ProjectRepository extends BaseRepository { ]); } - return response.rows[0].project_count; + return response.rows[0].count; } async getProjectData(projectId: number): Promise { @@ -184,15 +186,9 @@ export class ProjectRepository extends BaseRepository { p.project_id, p.uuid, p.name as project_name, - p.objectives, p.start_date, p.end_date, p.comments, - p.geojson as geometry, - p.create_date, - p.create_user, - p.update_date, - p.update_user, p.revision_count, pp.project_programs FROM diff --git a/api/src/repositories/region-repository.ts b/api/src/repositories/region-repository.ts index ae6c0fca35..19d330033e 100644 --- a/api/src/repositories/region-repository.ts +++ b/api/src/repositories/region-repository.ts @@ -13,8 +13,9 @@ export const IRegion = z.object({ feature_code: z.string(), feature_name: z.string(), object_id: z.number(), - geojson: z.any(), - geography: z.any() + geometry: z.null(), + geography: z.any(), + geojson: z.any() }); export type IRegion = z.infer; diff --git a/api/src/repositories/sample-blocks-repository.ts b/api/src/repositories/sample-blocks-repository.ts index 5f80529c3c..c343b7503a 100644 --- a/api/src/repositories/sample-blocks-repository.ts +++ b/api/src/repositories/sample-blocks-repository.ts @@ -70,17 +70,20 @@ export class SampleBlockRepository extends BaseRepository { * Gets count of all Sample Block records for a given Survey Block * * @param {number} surveyBlockId - * @return {*} {Promise} + * @return {*} {Promise} * @memberof sampleBlockRepository */ async getSampleBlocksCountForSurveyBlockId(surveyBlockId: number): Promise { const sql = SQL` - SELECT COUNT(*) AS sample_blocks_count - FROM survey_sample_block - WHERE survey_block_id = ${surveyBlockId}; + SELECT + COUNT(*)::integer AS count + FROM + survey_sample_block + WHERE + survey_block_id = ${surveyBlockId}; `; - const response = await this.connection.sql(sql, z.object({ sample_blocks_count: z.string().transform(Number) })); + const response = await this.connection.sql(sql, z.object({ count: z.number() })); if (!response.rowCount) { throw new ApiExecuteSQLError('Failed to count sample blocks', [ @@ -89,7 +92,7 @@ export class SampleBlockRepository extends BaseRepository { ]); } - return response.rows[0].sample_blocks_count; + return response.rows[0].count; } /** diff --git a/api/src/repositories/sample-location-repository.test.ts b/api/src/repositories/sample-location-repository.test.ts index e8dba965f7..58fb9fa2f1 100644 --- a/api/src/repositories/sample-location-repository.test.ts +++ b/api/src/repositories/sample-location-repository.test.ts @@ -42,7 +42,7 @@ describe('SampleLocationRepository', () => { describe('getSampleLocationsCountBySurveyId', () => { it('should return the sample location count successfully', async () => { - const mockResponse = { rows: [{ sample_site_count: 69 }], rowCount: 1 } as any as Promise>; + const mockResponse = { rows: [{ count: 69 }], rowCount: 1 } as any as Promise>; const dbConnectionObj = getMockDBConnection({ sql: () => mockResponse }); const repo = new SampleLocationRepository(dbConnectionObj); @@ -66,6 +66,22 @@ describe('SampleLocationRepository', () => { }); }); + describe('getSurveySampleLocationBySiteId', () => { + it('should return a single sample location', async () => { + const mockRows = [{ survey_sample_site_id: 1 }]; + const mockResponse = { rows: [mockRows], rowCount: 1 } as any as Promise>; + const dbConnectionObj = getMockDBConnection({ knex: () => mockResponse }); + + const surveySampleSiteId = 1; + const surveyId = 2; + + const repo = new SampleLocationRepository(dbConnectionObj); + const response = await repo.getSurveySampleLocationBySiteId(surveyId, surveySampleSiteId); + + expect(response).to.eql(mockRows); + }); + }); + describe('updateSampleSite', () => { it('should update the record and return a single row', async () => { const mockRow = {}; diff --git a/api/src/repositories/sample-location-repository.ts b/api/src/repositories/sample-location-repository.ts index 6cede7452c..a5cf3c23bf 100644 --- a/api/src/repositories/sample-location-repository.ts +++ b/api/src/repositories/sample-location-repository.ts @@ -43,7 +43,7 @@ export const SampleLocationRecord = z.object({ }).shape ) ), - sample_blocks: z.array( + blocks: z.array( SampleBlockRecord.pick({ survey_sample_block_id: true, survey_block_id: true, @@ -53,7 +53,7 @@ export const SampleLocationRecord = z.object({ description: z.string() }) ), - sample_stratums: z.array( + stratums: z.array( SampleStratumRecord.pick({ survey_sample_stratum_id: true, survey_stratum_id: true, @@ -74,8 +74,9 @@ export const SampleSiteRecord = z.object({ survey_id: z.number(), name: z.string(), description: z.string().nullable(), - geojson: z.any(), + geometry: z.null(), geography: z.any(), + geojson: z.any(), create_date: z.string(), create_user: z.number(), update_date: z.string().nullable(), @@ -180,7 +181,7 @@ export class SampleLocationRepository extends BaseRepository { 'survey_block_id', ssb.survey_block_id, 'name', sb.name, 'description', sb.description - )) as sample_blocks`) + )) as blocks`) ) .from({ ssb: 'survey_sample_block' }) .leftJoin('survey_block as sb', 'sb.survey_block_id', 'ssb.survey_block_id') @@ -197,7 +198,7 @@ export class SampleLocationRepository extends BaseRepository { 'survey_stratum_id', ssst.survey_stratum_id, 'name', ss.name, 'description', ss.description - )) as sample_stratums`) + )) as stratums`) ) .from({ ssst: 'survey_sample_stratum' }) .leftJoin('survey_stratum as ss', 'ss.survey_stratum_id', 'ssst.survey_stratum_id') @@ -211,8 +212,8 @@ export class SampleLocationRepository extends BaseRepository { 'sss.description', 'sss.geojson', knex.raw(`COALESCE(wssm.sample_methods, '[]'::json) as sample_methods, - COALESCE(wssb.sample_blocks, '[]'::json) as sample_blocks, - COALESCE(wssst.sample_stratums, '[]'::json) as sample_stratums`) + COALESCE(wssb.blocks, '[]'::json) as blocks, + COALESCE(wssst.stratums, '[]'::json) as stratums`) ) .from({ sss: 'survey_sample_site' }) .leftJoin('w_survey_sample_method as wssm', 'wssm.survey_sample_site_id', 'sss.survey_sample_site_id') @@ -242,17 +243,15 @@ export class SampleLocationRepository extends BaseRepository { */ async getSampleLocationsCountBySurveyId(surveyId: number): Promise { const sqlStatement = SQL` - SELECT - COUNT(*) as sample_site_count - FROM - survey_sample_site as sss - WHERE sss.survey_id = ${surveyId}; - `; + SELECT + COUNT(*)::integer AS count + FROM + survey_sample_site + WHERE + survey_id = ${surveyId}; + `; - const response = await this.connection.sql( - sqlStatement, - z.object({ sample_site_count: z.string().transform(Number) }) - ); + const response = await this.connection.sql(sqlStatement, z.object({ count: z.number() })); if (!response.rowCount) { throw new ApiExecuteSQLError('Failed to get sample site count', [ @@ -261,7 +260,7 @@ export class SampleLocationRepository extends BaseRepository { ]); } - return response.rows[0].sample_site_count; + return response.rows[0].count; } /** @@ -296,6 +295,118 @@ export class SampleLocationRepository extends BaseRepository { return response.rows[0]; } + /** + * Gets a sample location by sample site ID, including methods and periods + * + * @param {number} surveyId + * @param {number} surveySampleSiteId + * @return {*} {Promise} + * @memberof SampleLocationService + */ + async getSurveySampleLocationBySiteId(surveyId: number, surveySampleSiteId: number): Promise { + const knex = getKnex(); + const queryBuilder = knex + .queryBuilder() + .with('w_survey_sample_period', (qb) => { + // Aggregate sample periods into an array of objects + qb.select( + 'ssp.survey_sample_method_id', + knex.raw(` + json_agg(json_build_object( + 'survey_sample_period_id', ssp.survey_sample_period_id, + 'survey_sample_method_id', ssp.survey_sample_method_id, + 'start_date', ssp.start_date, + 'start_time', ssp.start_time, + 'end_date', ssp.end_date, + 'end_time', ssp.end_time + )) as sample_periods + `) + ) + .from({ ssp: 'survey_sample_period' }) + .groupBy('ssp.survey_sample_method_id'); + }) + .with('w_survey_sample_method', (qb) => { + // Aggregate sample methods into an array of objects and include the corresponding sample periods + qb.select( + 'ssm.survey_sample_site_id', + knex.raw(` + json_agg(json_build_object( + 'survey_sample_method_id', ssm.survey_sample_method_id, + 'survey_sample_site_id', ssm.survey_sample_site_id, + 'method_lookup_id', ssm.method_lookup_id, + 'description', ssm.description, + 'sample_periods', COALESCE(wssp.sample_periods, '[]'::json), + 'method_response_metric_id', ssm.method_response_metric_id + )) as sample_methods`) + ) + .from({ ssm: 'survey_sample_method' }) + .leftJoin('w_survey_sample_period as wssp', 'wssp.survey_sample_method_id', 'ssm.survey_sample_method_id') + .groupBy('ssm.survey_sample_site_id'); + }) + .with('w_survey_sample_block', (qb) => { + // Aggregate sample blocks into an array of objects + qb.select( + 'ssb.survey_sample_site_id', + knex.raw(` + json_agg(json_build_object( + 'survey_sample_block_id', ssb.survey_sample_block_id, + 'survey_sample_site_id', ssb.survey_sample_site_id, + 'survey_block_id', ssb.survey_block_id, + 'name', sb.name, + 'description', sb.description + )) as blocks`) + ) + .from({ ssb: 'survey_sample_block' }) + .leftJoin('survey_block as sb', 'sb.survey_block_id', 'ssb.survey_block_id') + .groupBy('ssb.survey_sample_site_id'); + }) + .with('w_survey_sample_stratum', (qb) => { + // Aggregate sample stratums into an array of objects + qb.select( + 'ssst.survey_sample_site_id', + knex.raw(` + json_agg(json_build_object( + 'survey_sample_stratum_id', ssst.survey_sample_stratum_id, + 'survey_sample_site_id', ssst.survey_sample_site_id, + 'survey_stratum_id', ssst.survey_stratum_id, + 'name', ss.name, + 'description', ss.description + )) as stratums`) + ) + .from({ ssst: 'survey_sample_stratum' }) + .leftJoin('survey_stratum as ss', 'ss.survey_stratum_id', 'ssst.survey_stratum_id') + .groupBy('ssst.survey_sample_site_id'); + }) + // Fetch sample sites and include the corresponding sample methods, blocks, and stratums + .select( + 'sss.survey_sample_site_id', + 'sss.survey_id', + 'sss.name', + 'sss.description', + 'sss.geojson', + knex.raw(`COALESCE(wssm.sample_methods, '[]'::json) as sample_methods, + COALESCE(wssb.blocks, '[]'::json) as blocks, + COALESCE(wssst.stratums, '[]'::json) as stratums`) + ) + .from({ sss: 'survey_sample_site' }) + .leftJoin('w_survey_sample_method as wssm', 'wssm.survey_sample_site_id', 'sss.survey_sample_site_id') + .leftJoin('w_survey_sample_block as wssb', 'wssb.survey_sample_site_id', 'sss.survey_sample_site_id') + .leftJoin('w_survey_sample_stratum as wssst', 'wssst.survey_sample_site_id', 'sss.survey_sample_site_id') + .where('sss.survey_id', surveyId) + .where('sss.survey_sample_site_id', surveySampleSiteId); + + const response = await this.connection.knex(queryBuilder, SampleLocationRecord); + + if (!response.rowCount) { + throw new ApiExecuteSQLError('Failed to get sample site by ID', [ + 'SampleLocationRepository->getSurveySampleSiteById', + 'rowCount was < 1, expected rowCount > 0' + ]); + } + + return response.rows[0]; + } + /** * Updates a survey sample site record. * @@ -316,7 +427,7 @@ export class SampleLocationRepository extends BaseRepository { public.ST_Force2D( public.ST_SetSRID( `; - const geometryCollectionSQL = generateGeometryCollectionSQL(sample.geojson); + const geometryCollectionSQL = generateGeometryCollectionSQL(sample.geojson as Feature[]); sql.append(geometryCollectionSQL); sql.append(SQL`, 4326)))`); sql.append(SQL` diff --git a/api/src/repositories/sample-method-repository.test.ts b/api/src/repositories/sample-method-repository.test.ts index 70513dfbc3..dc65d2736c 100644 --- a/api/src/repositories/sample-method-repository.test.ts +++ b/api/src/repositories/sample-method-repository.test.ts @@ -57,7 +57,7 @@ describe('SampleMethodRepository', () => { method_response_metric_id: 1, method_lookup_id: 3, description: 'description', - periods: [ + sample_periods: [ { end_date: '2023-01-02', start_date: '2023-10-02', @@ -94,7 +94,7 @@ describe('SampleMethodRepository', () => { method_lookup_id: 3, method_response_metric_id: 1, description: 'description', - periods: [ + sample_periods: [ { end_date: '2023-01-02', start_date: '2023-10-02', @@ -135,7 +135,7 @@ describe('SampleMethodRepository', () => { method_lookup_id: 3, method_response_metric_id: 1, description: 'description', - periods: [ + sample_periods: [ { end_date: '2023-01-02', start_date: '2023-10-02', @@ -168,7 +168,7 @@ describe('SampleMethodRepository', () => { method_response_metric_id: 1, method_lookup_id: 3, description: 'description', - periods: [ + sample_periods: [ { end_date: '2023-01-02', start_date: '2023-10-02', diff --git a/api/src/repositories/sample-method-repository.ts b/api/src/repositories/sample-method-repository.ts index 69310e9225..5b4e6ef635 100644 --- a/api/src/repositories/sample-method-repository.ts +++ b/api/src/repositories/sample-method-repository.ts @@ -10,7 +10,7 @@ import { InsertSamplePeriodRecord, UpdateSamplePeriodRecord } from './sample-per export type InsertSampleMethodRecord = Pick< SampleMethodRecord, 'survey_sample_site_id' | 'method_lookup_id' | 'description' | 'method_response_metric_id' -> & { periods: InsertSamplePeriodRecord[] }; +> & { sample_periods: InsertSamplePeriodRecord[] }; /** * Update object for a single sample method record. @@ -18,7 +18,7 @@ export type InsertSampleMethodRecord = Pick< export type UpdateSampleMethodRecord = Pick< SampleMethodRecord, 'survey_sample_method_id' | 'survey_sample_site_id' | 'method_lookup_id' | 'description' | 'method_response_metric_id' -> & { periods: UpdateSamplePeriodRecord[] }; +> & { sample_periods: UpdateSamplePeriodRecord[] }; /** * A survey_sample_method record. @@ -169,7 +169,7 @@ export class SampleMethodRepository extends BaseRepository { survey_sample_method.survey_sample_site_id = sss.survey_sample_site_id AND survey_sample_method_id = ${surveySampleMethodId} AND survey_id = ${surveyId} - RETURNING *; + RETURNING survey_sample_method.*; `; const response = await this.connection.sql(sqlStatement, SampleMethodRecord); diff --git a/api/src/repositories/sample-stratums-repository.ts b/api/src/repositories/sample-stratums-repository.ts index 1b9c619dc0..761ec6aedd 100644 --- a/api/src/repositories/sample-stratums-repository.ts +++ b/api/src/repositories/sample-stratums-repository.ts @@ -69,17 +69,20 @@ export class SampleStratumRepository extends BaseRepository { * Gets all survey Sample Stratums. * * @param {number} surveyStratumId - * @return {*} {Promise} + * @return {*} {Promise} * @memberof sampleStratumRepository */ async getSampleStratumsCountForSurveyStratumId(surveyStratumId: number): Promise { const sql = SQL` - SELECT COUNT(*) - FROM survey_sample_stratum - WHERE survey_stratum_id = ${surveyStratumId}; + SELECT + COUNT(*)::integer AS count + FROM + survey_sample_stratum + WHERE + survey_stratum_id = ${surveyStratumId}; `; - const response = await this.connection.sql(sql, z.object({ sample_stratums_count: z.string().transform(Number) })); + const response = await this.connection.sql(sql, z.object({ count: z.number() })); if (!response.rowCount) { throw new ApiExecuteSQLError('Failed to count sample stratums', [ @@ -88,7 +91,7 @@ export class SampleStratumRepository extends BaseRepository { ]); } - return response.rows[0].sample_stratums_count; + return response.rows[0].count; } /** diff --git a/api/src/repositories/site-selection-strategy-repository.test.ts b/api/src/repositories/site-selection-strategy-repository.test.ts index 3929fd3939..e30107a4e6 100644 --- a/api/src/repositories/site-selection-strategy-repository.test.ts +++ b/api/src/repositories/site-selection-strategy-repository.test.ts @@ -31,7 +31,6 @@ describe('SiteSelectionStrategyRepository', () => { survey_id: 1, survey_stratum_id: 2, revision_count: 0, - update_date: '2023-05-20', sample_stratum_count: 1 }, { @@ -40,7 +39,6 @@ describe('SiteSelectionStrategyRepository', () => { survey_id: 1, survey_stratum_id: 2, revision_count: 0, - update_date: '2023-05-20', sample_stratum_count: 1 } ]; @@ -247,8 +245,7 @@ describe('SiteSelectionStrategyRepository', () => { description: '', survey_id: 1, survey_stratum_id: 1, - revision_count: 1, - update_date: '2023-10-23' + revision_count: 1 } ]; const mockRows2: SurveyStratumRecord[] = [ @@ -257,8 +254,7 @@ describe('SiteSelectionStrategyRepository', () => { description: '', survey_id: 1, survey_stratum_id: 2, - revision_count: 1, - update_date: '2023-10-23' + revision_count: 1 } ]; const mockResponse1 = { rows: mockRows1, rowCount: 1 } as any as Promise>; @@ -272,8 +268,8 @@ describe('SiteSelectionStrategyRepository', () => { const surveyId = 1; const stratums: SurveyStratumRecord[] = [ - { name: 'stratum1', description: '', survey_id: 1, survey_stratum_id: 1, revision_count: 0, update_date: null }, - { name: 'stratum1', description: '', survey_id: 1, survey_stratum_id: 2, revision_count: 0, update_date: null } + { name: 'stratum1', description: '', survey_id: 1, survey_stratum_id: 1, revision_count: 0 }, + { name: 'stratum1', description: '', survey_id: 1, survey_stratum_id: 2, revision_count: 0 } ]; const response = await repo.updateSurveyStratums(surveyId, stratums); @@ -289,8 +285,7 @@ describe('SiteSelectionStrategyRepository', () => { description: '', survey_id: 1, survey_stratum_id: 1, - revision_count: 1, - update_date: '2023-10-23' + revision_count: 1 } ]; const mockResponse1 = { rows: mockRows1, rowCount: 1 } as any as Promise>; @@ -306,8 +301,8 @@ describe('SiteSelectionStrategyRepository', () => { // stratums length = 2, total rowCount = 1 const stratums: SurveyStratumRecord[] = [ - { name: 'stratum1', description: '', survey_id: 1, survey_stratum_id: 1, revision_count: 0, update_date: null }, - { name: 'stratum1', description: '', survey_id: 1, survey_stratum_id: 2, revision_count: 0, update_date: null } + { name: 'stratum1', description: '', survey_id: 1, survey_stratum_id: 1, revision_count: 0 }, + { name: 'stratum1', description: '', survey_id: 1, survey_stratum_id: 2, revision_count: 0 } ]; try { diff --git a/api/src/repositories/site-selection-strategy-repository.ts b/api/src/repositories/site-selection-strategy-repository.ts index 5ebaeb8a34..37541932b4 100644 --- a/api/src/repositories/site-selection-strategy-repository.ts +++ b/api/src/repositories/site-selection-strategy-repository.ts @@ -14,11 +14,10 @@ export type SurveyStratum = z.infer; export const SurveyStratumRecord = z.object({ name: z.string(), - description: z.string().nullable(), + description: z.string(), survey_id: z.number(), - survey_stratum_id: z.number(), - revision_count: z.number(), - update_date: z.string().nullable() + survey_stratum_id: z.number().nullable(), + revision_count: z.number() }); export type SurveyStratumRecord = z.infer; @@ -70,27 +69,13 @@ export class SiteSelectionStrategyRepository extends BaseRepository { 'ss.survey_id', 'ss.name', 'ss.description', - 'ss.create_date', - 'ss.create_user', - 'ss.update_date', - 'ss.update_user', 'ss.revision_count', getKnex().raw('COUNT(sss.survey_stratum_id)::INTEGER AS sample_stratum_count') ) .from('survey_stratum as ss') .leftJoin('survey_sample_stratum as sss', 'ss.survey_stratum_id', 'sss.survey_stratum_id') .where('ss.survey_id', surveyId) - .groupBy( - 'ss.survey_stratum_id', - 'ss.survey_id', - 'ss.name', - 'ss.description', - 'ss.create_date', - 'ss.create_user', - 'ss.update_date', - 'ss.update_user', - 'ss.revision_count' - ); + .groupBy('ss.survey_stratum_id', 'ss.survey_id', 'ss.name', 'ss.description', 'ss.revision_count'); const [strategiesResponse, stratumsResponse] = await Promise.all([ this.connection.knex(strategiesQuery, z.object({ name: z.string() })), @@ -198,7 +183,7 @@ export class SiteSelectionStrategyRepository extends BaseRepository { .delete() .from('survey_stratum') .whereIn('survey_stratum_id', stratumIds) - .returning('*'); + .returning(['survey_stratum_id', 'survey_id', 'name', 'description', 'revision_count']); const response = await this.connection.knex(deleteQuery, SurveyStratumRecord); @@ -225,7 +210,7 @@ export class SiteSelectionStrategyRepository extends BaseRepository { description: stratum.description })) ) - .returning('*'); + .returning(['survey_stratum_id', 'survey_id', 'name', 'description', 'revision_count']); const response = await this.connection.knex(insertQuery, SurveyStratumRecord); @@ -254,13 +239,13 @@ export class SiteSelectionStrategyRepository extends BaseRepository { return getKnex() .table('survey_stratum') .update({ - survey_id: surveyId, name: stratum.name, description: stratum.description, update_date: 'now()' }) .where('survey_stratum_id', stratum.survey_stratum_id) - .returning('*'); + .where('survey_id', surveyId) + .returning(['survey_stratum_id', 'survey_id', 'name', 'description', 'revision_count']); }; const responses = await Promise.all( diff --git a/api/src/repositories/subcount-repository.test.ts b/api/src/repositories/subcount-repository.test.ts index e91ffae9f9..c7c039f8bb 100644 --- a/api/src/repositories/subcount-repository.test.ts +++ b/api/src/repositories/subcount-repository.test.ts @@ -27,6 +27,7 @@ describe('SubCountRepository', () => { observation_subcount_id: 1, survey_observation_id: 1, subcount: 5, + observation_subcount_sign_id: null, create_date: '1970-01-01', create_user: 1, update_date: null, diff --git a/api/src/repositories/subcount-repository.ts b/api/src/repositories/subcount-repository.ts index 4e20cd55bc..ad81c08815 100644 --- a/api/src/repositories/subcount-repository.ts +++ b/api/src/repositories/subcount-repository.ts @@ -7,6 +7,7 @@ export const ObservationSubCountRecord = z.object({ observation_subcount_id: z.number(), survey_observation_id: z.number(), subcount: z.number().nullable(), + observation_subcount_sign_id: z.number().nullable(), create_date: z.string(), create_user: z.number(), update_date: z.string().nullable(), diff --git a/api/src/repositories/survey-block-repository.ts b/api/src/repositories/survey-block-repository.ts index 60971da7cb..83fe2acdba 100644 --- a/api/src/repositories/survey-block-repository.ts +++ b/api/src/repositories/survey-block-repository.ts @@ -13,13 +13,8 @@ export interface PostSurveyBlock { // 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; @@ -30,10 +25,6 @@ export const SurveyBlockRecordWithCount = z.object({ 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(), sample_block_count: z.number() }); @@ -61,10 +52,6 @@ export class SurveyBlockRepository extends BaseRepository { sb.survey_id, sb.name, sb.description, - sb.create_date, - sb.create_user, - sb.update_date, - sb.update_user, sb.revision_count, COUNT(ssb.survey_block_id)::integer AS sample_block_count FROM @@ -78,10 +65,6 @@ export class SurveyBlockRepository extends BaseRepository { sb.survey_id, sb.name, sb.description, - sb.create_date, - sb.create_user, - sb.update_date, - sb.update_user, sb.revision_count; `; @@ -106,8 +89,11 @@ export class SurveyBlockRepository extends BaseRepository { survey_id=${block.survey_id} WHERE survey_block_id = ${block.survey_block_id} - RETURNING - *; + RETURNING + survey_block_id, + name, + description, + revision_count; `; const response = await this.connection.sql(sql, SurveyBlockRecord); @@ -140,7 +126,10 @@ export class SurveyBlockRepository extends BaseRepository { ${block.description} ) RETURNING - *; + survey_block_id, + name, + description, + revision_count; `; const response = await this.connection.sql(sql, SurveyBlockRecord); @@ -168,7 +157,10 @@ export class SurveyBlockRepository extends BaseRepository { WHERE survey_block_id = ${surveyBlockId} RETURNING - *; + survey_block_id, + name, + description, + revision_count; `; const response = await this.connection.sql(sqlStatement, SurveyBlockRecord); diff --git a/api/src/repositories/survey-location-repository.ts b/api/src/repositories/survey-location-repository.ts index b2e47eea7a..e4b02c0c1b 100644 --- a/api/src/repositories/survey-location-repository.ts +++ b/api/src/repositories/survey-location-repository.ts @@ -3,16 +3,16 @@ import { z } from 'zod'; import { ApiExecuteSQLError } from '../errors/api-error'; import { PostSurveyLocationData } from '../models/survey-update'; import { generateGeometryCollectionSQL } from '../utils/spatial-utils'; -import { GeoJSONFeatureZodSchema } from '../zod-schema/geoJsonZodSchema'; import { BaseRepository } from './base-repository'; export const SurveyLocationRecord = z.object({ survey_location_id: z.number(), + survey_id: z.number(), name: z.string(), description: z.string(), geometry: z.record(z.any()).nullable(), geography: z.string(), - geojson: z.array(GeoJSONFeatureZodSchema), + geojson: z.any(), revision_count: z.number() }); @@ -83,15 +83,15 @@ export class SurveyLocationRepository extends BaseRepository { */ async getSurveyLocationsData(surveyId: number): Promise { const sqlStatement = SQL` - SELECT + SELECT + survey_id, survey_location_id, - name, - description, - geography, - geojson, + name, + description, geometry, - name, - revision_count + geography, + geojson, + revision_count FROM survey_location WHERE diff --git a/api/src/repositories/survey-repository.test.ts b/api/src/repositories/survey-repository.test.ts index 632478a0d5..134ec07c92 100644 --- a/api/src/repositories/survey-repository.test.ts +++ b/api/src/repositories/survey-repository.test.ts @@ -32,7 +32,7 @@ describe('SurveyRepository', () => { describe('getSurveyCountByProjectId', () => { it('should return the survey count successfully', async () => { - const mockResponse = { rows: [{ survey_count: 69 }], rowCount: 1 } as any as Promise>; + const mockResponse = { rows: [{ count: 69 }], rowCount: 1 } as any as Promise>; const dbConnectionObj = getMockDBConnection({ sql: () => mockResponse }); const repo = new SurveyRepository(dbConnectionObj); @@ -42,7 +42,7 @@ describe('SurveyRepository', () => { }); it('should throw an exception if row count is 0', async () => { - const mockResponse = { rows: [], rowCount: 0 } as any as Promise>; + const mockResponse = { rows: [], count: 0 } as any as Promise>; const dbConnectionObj = getMockDBConnection({ sql: sinon.stub().resolves(mockResponse) }); const repo = new SurveyRepository(dbConnectionObj); @@ -413,8 +413,7 @@ describe('SurveyRepository', () => { }, purpose_and_methodology: { additional_details: '', - intended_outcome_id: 1, - surveyed_all_areas: 'Y' + intended_outcome_id: 1 }, locations: [{ geometry: [{ id: 1 }] }] } as unknown as PostSurveyObject; @@ -440,8 +439,7 @@ describe('SurveyRepository', () => { }, purpose_and_methodology: { additional_details: '', - intended_outcome_id: 1, - surveyed_all_areas: 'Y' + intended_outcome_id: 1 }, locations: [{ geometry: [] }] } as unknown as PostSurveyObject; @@ -467,8 +465,7 @@ describe('SurveyRepository', () => { }, purpose_and_methodology: { additional_details: '', - intended_outcome_id: 1, - surveyed_all_areas: 'Y' + intended_outcome_id: 1 }, locations: [{ geometry: [{ id: 1 }] }] } as unknown as PostSurveyObject; @@ -792,7 +789,6 @@ describe('SurveyRepository', () => { purpose_and_methodology: { additional_details: '', intended_outcome_id: 1, - surveyed_all_areas: 'Y', revision_count: 1 }, locations: [{ geometry: [{ id: 1 }] }] @@ -819,7 +815,6 @@ describe('SurveyRepository', () => { purpose_and_methodology: { additional_details: '', intended_outcome_id: 1, - surveyed_all_areas: 'Y', revision_count: 1 }, locations: [{ geometry: [] }] @@ -846,7 +841,6 @@ describe('SurveyRepository', () => { purpose_and_methodology: { additional_details: '', intended_outcome_id: 1, - surveyed_all_areas: 'Y', revision_count: 1 }, locations: [{ geometry: [] }] diff --git a/api/src/repositories/survey-repository.ts b/api/src/repositories/survey-repository.ts index bf3cc0eb7b..f93ed62c39 100644 --- a/api/src/repositories/survey-repository.ts +++ b/api/src/repositories/survey-repository.ts @@ -46,20 +46,24 @@ export interface ISurveyProprietorModel { } const SurveyRecord = z.object({ - project_id: z.number(), survey_id: z.number(), - name: z.string().nullable(), + project_id: z.number(), + field_method_id: z.number().nullable(), uuid: z.string().uuid().nullable(), + name: z.string().nullable(), + additional_details: z.string().nullable(), start_date: z.string(), + lead_first_name: z.string().nullable(), + lead_last_name: z.string().nullable(), end_date: z.string().nullable(), - additional_details: z.string().nullable(), - progress_id: z.number(), - comments: z.string().nullable(), create_date: z.string(), create_user: z.number(), update_date: z.string().nullable(), update_user: z.number().nullable(), - revision_count: z.number() + revision_count: z.number(), + ecological_season_id: z.number().nullable(), + comments: z.string().nullable(), + progress_id: z.number() }); export type SurveyRecord = z.infer; @@ -481,15 +485,14 @@ export class SurveyRepository extends BaseRepository { async getSurveyCountByProjectId(projectId: number): Promise { const sqlStatement = SQL` SELECT - COUNT(*) as survey_count + COUNT(*)::integer AS count FROM - survey as s + survey WHERE - s.project_id = ${projectId} - ; + project_id = ${projectId}; `; - const response = await this.connection.sql(sqlStatement, z.object({ survey_count: z.string().transform(Number) })); + const response = await this.connection.sql(sqlStatement, z.object({ count: z.number() })); if (!response.rowCount) { throw new ApiExecuteSQLError('Failed to get survey count', [ @@ -498,7 +501,7 @@ export class SurveyRepository extends BaseRepository { ]); } - return response.rows[0].survey_count; + return response.rows[0].count; } /** diff --git a/api/src/services/bctw-service.ts b/api/src/services/bctw-service.ts index 66874b84cf..816fbad25b 100644 --- a/api/src/services/bctw-service.ts +++ b/api/src/services/bctw-service.ts @@ -1,11 +1,11 @@ import axios, { AxiosError, AxiosInstance, AxiosResponse } from 'axios'; import { Request } from 'express'; import FormData from 'form-data'; +import { GeometryCollection } from 'geojson'; import { URLSearchParams } from 'url'; import { z } from 'zod'; import { ApiError, ApiErrorType } from '../errors/api-error'; import { HTTP500 } from '../errors/http-error'; -import { GeoJSONFeatureCollectionZodSchema } from '../zod-schema/geoJsonZodSchema'; import { KeycloakService } from './keycloak-service'; export const IDeployDevice = z.object({ @@ -110,8 +110,6 @@ interface ICodeResponse { long_description: string; } -export type CritterTelemetryResponse = z.infer; - export type IBctwUser = z.infer; export interface ICreateManualTelemetry { @@ -418,13 +416,10 @@ export class BctwService { * @param critterId uuid * @param startDate * @param endDate - * @returns {*} CritterTelemetryResponse + * @return {*} {Promise} + * @memberof BctwService */ - async getCritterTelemetryPoints( - critterId: string, - startDate: Date, - endDate: Date - ): Promise { + async getCritterTelemetryPoints(critterId: string, startDate: Date, endDate: Date): Promise { return this._makeGetRequest(GET_TELEMETRY_POINTS_ENDPOINT, { critter_id: critterId, start: startDate.toISOString(), @@ -440,13 +435,10 @@ export class BctwService { * @param critterId uuid * @param startDate * @param endDate - * @returns {*} CritterTelemetryResponse + * @return {*} {Promise} + * @memberof BctwService */ - async getCritterTelemetryTracks( - critterId: string, - startDate: Date, - endDate: Date - ): Promise { + async getCritterTelemetryTracks(critterId: string, startDate: Date, endDate: Date): Promise { return this._makeGetRequest(GET_TELEMETRY_TRACKS_ENDPOINT, { critter_id: critterId, start: startDate.toISOString(), diff --git a/api/src/services/observation-service.test.ts b/api/src/services/observation-service.test.ts index 2e9eabca7c..4368d4102d 100644 --- a/api/src/services/observation-service.test.ts +++ b/api/src/services/observation-service.test.ts @@ -40,6 +40,7 @@ describe('ObservationService', () => { { survey_observation_id: 11, survey_id: 1, + wldtaxonomic_units_id: 2, latitude: 3, longitude: 4, count: 5, @@ -59,6 +60,7 @@ describe('ObservationService', () => { { survey_observation_id: 6, survey_id: 1, + wldtaxonomic_units_id: 2, latitude: 8, longitude: 9, count: 10, @@ -129,11 +131,6 @@ describe('ObservationService', () => { itis_scientific_name: 'itis_scientific_name', observation_date: '2023-01-01', observation_time: '12:00:00', - create_date: '2023-04-04', - create_user: 1, - update_date: null, - update_user: null, - revision_count: 0, survey_sample_method_name: 'METHOD_NAME', survey_sample_period_start_datetime: '2000-01-01 00:00:00', survey_sample_site_name: 'SITE_NAME', @@ -152,11 +149,6 @@ describe('ObservationService', () => { itis_scientific_name: 'itis_scientific_name', observation_date: '2023-02-02', observation_time: '13:00:00', - create_date: '2023-03-03', - create_user: 1, - update_date: '2023-04-04', - update_user: 2, - revision_count: 1, survey_sample_method_name: 'METHOD_NAME', survey_sample_period_start_datetime: '2000-01-01 00:00:00', survey_sample_site_name: 'SITE_NAME', diff --git a/api/src/services/region-service.test.ts b/api/src/services/region-service.test.ts index 3752ad2679..d4fe197f4f 100644 --- a/api/src/services/region-service.test.ts +++ b/api/src/services/region-service.test.ts @@ -28,7 +28,8 @@ describe('RegionRepository', () => { feature_name: 'source_layer', object_id: 1234, geojson: '{}', - geography: '{}' + geography: '{}', + geometry: null } ]); @@ -43,7 +44,8 @@ describe('RegionRepository', () => { feature_name: 'source_layer', object_id: 1234, geojson: '{}', - geography: '{}' + geography: '{}', + geometry: null }); }); }); diff --git a/api/src/services/sample-location-service.test.ts b/api/src/services/sample-location-service.test.ts index 703535251b..49fa0e1e7d 100644 --- a/api/src/services/sample-location-service.test.ts +++ b/api/src/services/sample-location-service.test.ts @@ -47,13 +47,13 @@ describe('SampleLocationService', () => { } } ], - methods: [ + sample_methods: [ { survey_sample_site_id: 1, method_lookup_id: 1, method_response_metric_id: 1, description: '', - periods: [ + sample_periods: [ { survey_sample_method_id: 1, start_date: '2023-01-01', @@ -83,8 +83,9 @@ describe('SampleLocationService', () => { survey_id: 1, name: 'Sample Site 1', description: '', - geojson: [], + geometry: null, geography: [], + geojson: [], create_date: '', create_user: 1, update_date: '', @@ -112,9 +113,9 @@ describe('SampleLocationService', () => { name: 'Sample Site 1', description: '', geojson: [], - sample_blocks: [], + blocks: [], sample_methods: [], - sample_stratums: [] + stratums: [] } ]); @@ -178,8 +179,9 @@ describe('SampleLocationService', () => { survey_id: 1, name: 'Sample Site 1', description: '', - geojson: [], + geometry: null, geography: [], + geojson: [], create_date: '', create_user: 1, update_date: '', @@ -216,9 +218,9 @@ describe('SampleLocationService', () => { method_lookup_id: 3, method_response_metric_id: 1, description: 'Cool method', - periods: [] + sample_periods: [] } as any, - { method_lookup_id: 4, method_response_metric_id: 1, description: 'Cool method', periods: [] } as any + { method_lookup_id: 4, method_response_metric_id: 1, description: 'Cool method', sample_periods: [] } as any ]; const blocks = [ { @@ -258,8 +260,9 @@ describe('SampleLocationService', () => { survey_id: 1, name: 'Cool new site', description: 'Check out this description', - geojson: [], + geometry: null, geography: [], + geojson: [], create_date: '', create_user: 1, update_date: '', @@ -320,7 +323,7 @@ describe('SampleLocationService', () => { method_response_metric_id: 1, description: 'Cool method', - periods: [] + sample_periods: [] }); expect(updateSampleMethodStub).to.be.calledOnceWith(mockSurveyId, { survey_sample_site_id: survey_sample_site_id, @@ -328,7 +331,7 @@ describe('SampleLocationService', () => { method_lookup_id: 3, method_response_metric_id: 1, description: 'Cool method', - periods: [] + sample_periods: [] }); }); }); diff --git a/api/src/services/sample-location-service.ts b/api/src/services/sample-location-service.ts index f7c969f70d..dc6ecabdf9 100644 --- a/api/src/services/sample-location-service.ts +++ b/api/src/services/sample-location-service.ts @@ -20,7 +20,7 @@ export interface PostSampleLocations { survey_sample_site_id: number | null; survey_id: number; survey_sample_sites: InsertSampleSiteRecord[]; - methods: InsertSampleMethodRecord[]; + sample_methods: InsertSampleMethodRecord[]; blocks: InsertSampleBlockRecord[]; stratums: InsertSampleStratumRecord[]; } @@ -80,6 +80,18 @@ export class SampleLocationService extends DBService { return this.sampleLocationRepository.getSurveySampleSiteById(surveyId, surveySampleSiteId); } + /** + * Gets a sample location by sample site ID. + * + * @param {number} surveyId + * @param {number} surveySampleSiteId + * @return {*} {Promise} + * @memberof SampleLocationService + */ + async getSurveySampleLocationBySiteId(surveyId: number, surveySampleSiteId: number): Promise { + return this.sampleLocationRepository.getSurveySampleLocationBySiteId(surveyId, surveySampleSiteId); + } + /** * Deletes a survey Sample Location. * @@ -152,12 +164,12 @@ export class SampleLocationService extends DBService { // Loop through all newly created sample sites // For reach sample site, create associated sample methods const methodPromises = sampleSiteRecords.map((sampleSiteRecord) => - sampleLocations.methods.map((item) => { + sampleLocations.sample_methods.map((item) => { const sampleMethod = { survey_sample_site_id: sampleSiteRecord.survey_sample_site_id, method_lookup_id: item.method_lookup_id, description: item.description, - periods: item.periods, + sample_periods: item.sample_periods, method_response_metric_id: item.method_response_metric_id }; return methodService.insertSampleMethod(sampleMethod); @@ -267,7 +279,7 @@ export class SampleLocationService extends DBService { method_lookup_id: item.method_lookup_id, method_response_metric_id: item.method_response_metric_id, description: item.description, - periods: item.periods + sample_periods: item.sample_periods }; await methodService.updateSampleMethod(surveyId, sampleMethod); } else { @@ -276,7 +288,7 @@ export class SampleLocationService extends DBService { method_lookup_id: item.method_lookup_id, method_response_metric_id: item.method_response_metric_id, description: item.description, - periods: item.periods + sample_periods: item.sample_periods }; await methodService.insertSampleMethod(sampleMethod); } diff --git a/api/src/services/sample-method-service.test.ts b/api/src/services/sample-method-service.test.ts index 5cc5296ab2..dfb9d9357a 100644 --- a/api/src/services/sample-method-service.test.ts +++ b/api/src/services/sample-method-service.test.ts @@ -154,7 +154,7 @@ describe('SampleMethodService', () => { method_lookup_id: 3, method_response_metric_id: 1, description: 'description', - periods: [ + sample_periods: [ { end_date: '2023-01-02', start_date: '2023-10-02', @@ -177,17 +177,17 @@ describe('SampleMethodService', () => { expect(insertSampleMethodStub).to.be.calledOnceWith(sampleMethod); expect(insertSamplePeriodStub).to.be.calledWith({ survey_sample_method_id: mockSampleMethodRecord.survey_sample_method_id, - start_date: sampleMethod.periods[0].start_date, - end_date: sampleMethod.periods[0].end_date, - start_time: sampleMethod.periods[0].start_time, - end_time: sampleMethod.periods[0].end_time + start_date: sampleMethod.sample_periods[0].start_date, + end_date: sampleMethod.sample_periods[0].end_date, + start_time: sampleMethod.sample_periods[0].start_time, + end_time: sampleMethod.sample_periods[0].end_time }); expect(insertSamplePeriodStub).to.be.calledWith({ survey_sample_method_id: mockSampleMethodRecord.survey_sample_method_id, - start_date: sampleMethod.periods[1].start_date, - end_date: sampleMethod.periods[1].end_date, - start_time: sampleMethod.periods[1].start_time, - end_time: sampleMethod.periods[1].end_time + start_date: sampleMethod.sample_periods[1].start_date, + end_date: sampleMethod.sample_periods[1].end_date, + start_time: sampleMethod.sample_periods[1].start_time, + end_time: sampleMethod.sample_periods[1].end_time }); expect(response).to.eql(mockSampleMethodRecord); }); @@ -228,7 +228,7 @@ describe('SampleMethodService', () => { method_lookup_id: 3, method_response_metric_id: 1, description: 'description', - periods: [ + sample_periods: [ { end_date: '2023-01-02', start_date: '2023-10-02', diff --git a/api/src/services/sample-method-service.ts b/api/src/services/sample-method-service.ts index 690f294d58..fc98ba38aa 100644 --- a/api/src/services/sample-method-service.ts +++ b/api/src/services/sample-method-service.ts @@ -77,7 +77,7 @@ export class SampleMethodService extends DBService { const samplePeriodService = new SamplePeriodService(this.connection); // Loop through and create associated sample periods - const promises = sampleMethod.periods.map((item) => { + const promises = sampleMethod.sample_periods.map((item) => { const samplePeriod = { survey_sample_method_id: sampleMethodRecord.survey_sample_method_id, start_date: item.start_date, @@ -154,13 +154,13 @@ export class SampleMethodService extends DBService { await samplePeriodService.deleteSamplePeriodsNotInArray( surveyId, sampleMethod.survey_sample_method_id, - sampleMethod.periods + sampleMethod.sample_periods ); // Loop through all new sample periods // For each sample period, check if it exists in the existing list // If it does, update it, otherwise create it - for (const samplePeriod of sampleMethod.periods) { + for (const samplePeriod of sampleMethod.sample_periods) { if (samplePeriod.survey_sample_period_id) { await samplePeriodService.updateSamplePeriod(surveyId, samplePeriod); } else { diff --git a/api/src/services/site-selection-strategy-service.ts b/api/src/services/site-selection-strategy-service.ts index a8ff948b59..d3b82cd669 100644 --- a/api/src/services/site-selection-strategy-service.ts +++ b/api/src/services/site-selection-strategy-service.ts @@ -90,8 +90,8 @@ export class SiteSelectionStrategyService extends DBService { ); stratums.forEach((stratum) => { - if ('survey_stratum_id' in stratum) { - updateStratums.push(stratum); + if ((stratum as SurveyStratumRecord).survey_stratum_id) { + updateStratums.push(stratum as SurveyStratumRecord); } else { insertStratums.push(stratum); } @@ -100,9 +100,10 @@ export class SiteSelectionStrategyService extends DBService { const removeStratums = existingSiteSelectionStrategies.stratums .filter( (stratum) => + stratum.survey_stratum_id !== null && !updateStratums.some((updateStratum) => updateStratum.survey_stratum_id === stratum.survey_stratum_id) ) - .map((stratum) => stratum.survey_stratum_id); + .map((stratum) => stratum.survey_stratum_id) as number[]; if (removeStratums.length) { await this.deleteSurveyStratums(removeStratums); diff --git a/api/src/services/survey-block-service.test.ts b/api/src/services/survey-block-service.test.ts index 75aec62a92..a2e1017c06 100644 --- a/api/src/services/survey-block-service.test.ts +++ b/api/src/services/survey-block-service.test.ts @@ -24,10 +24,6 @@ describe('SurveyBlockService', () => { survey_id: 1, name: '', description: '', - create_date: '', - create_user: 1, - update_date: '', - update_user: 1, revision_count: 1, sample_block_count: 1 } @@ -94,10 +90,6 @@ describe('SurveyBlockService', () => { survey_id: 1, name: 'Old Block', description: 'Updated', - create_date: '', - create_user: 1, - update_date: '', - update_user: 1, revision_count: 1 }, { @@ -106,10 +98,6 @@ describe('SurveyBlockService', () => { survey_id: 1, name: 'Old Block', description: 'Going to be deleted', - create_date: '', - create_user: 1, - update_date: '', - update_user: 1, revision_count: 1 } ]); @@ -137,10 +125,6 @@ describe('SurveyBlockService', () => { survey_id: 1, name: 'Deleted record', description: '', - create_date: '', - create_user: 1, - update_date: '', - update_user: 1, revision_count: 1 }; diff --git a/api/src/services/survey-block-service.ts b/api/src/services/survey-block-service.ts index 795b34c366..ed891db664 100644 --- a/api/src/services/survey-block-service.ts +++ b/api/src/services/survey-block-service.ts @@ -69,6 +69,10 @@ export class SurveyBlockService extends DBService { promises.push(this.deleteSurveyBlock(block.survey_block_id)); }); + // Delete blocks before upserting in case blocks to upsert have the same name as a block to delete + // ie. if you delete block 'abc' and add a new block 'abc' in the same request, there will be a duplicate name conflict + await Promise.all(promises); + // update or insert block data blocks.forEach((item: PostSurveyBlock) => { item.survey_id = surveyId; diff --git a/api/src/zod-schema/geoJsonZodSchema.ts b/api/src/zod-schema/geoJsonZodSchema.ts index 101f9ba865..c25cf9f45f 100644 --- a/api/src/zod-schema/geoJsonZodSchema.ts +++ b/api/src/zod-schema/geoJsonZodSchema.ts @@ -2,7 +2,7 @@ import { z } from 'zod'; export const GeoJSONPointZodSchema = z.object({ type: z.enum(['Point']), - coordinates: z.array(z.number()).min(2), + coordinates: z.array(z.number()).min(2).max(2), bbox: z.array(z.number()).min(4).optional() }); diff --git a/app/package-lock.json b/app/package-lock.json index 7078adb39f..6fccc6696f 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -4963,9 +4963,9 @@ } }, "node_modules/@testing-library/dom": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.0.0.tgz", - "integrity": "sha512-PmJPnogldqoVFf+EwbHvbBJ98MmqASV8kLrBYgsDNxQcFMeIS7JFL48sfyXvuMtgmWO/wMhh25odr+8VhDmn4g==", + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.1.0.tgz", + "integrity": "sha512-wdsYKy5zupPyLCW2Je5DLHSxSfbIp6h80WoHOQc+RPtmPGA52O9x5MJEkv92Sjonpq+poOAtUKhh1kBGAXBrNA==", "dev": true, "peer": true, "dependencies": { diff --git a/app/src/components/dialog/EditDialog.tsx b/app/src/components/dialog/EditDialog.tsx index 895f7c8984..f7ebf3ea0c 100644 --- a/app/src/components/dialog/EditDialog.tsx +++ b/app/src/components/dialog/EditDialog.tsx @@ -129,39 +129,41 @@ export const EditDialog = (props: PropsWithChildren { props.onSave(values); }}> - {(formikProps) => ( - - {props.dialogTitle} - - {props.dialogText && {props.dialogText}} - {props.component.element} - - - - {props.dialogSaveButtonLabel || 'Save Changes'} - - - - {props.dialogError && {props.dialogError}} - {props.debug ? : null} - - )} + {(formikProps) => { + return ( + + {props.dialogTitle} + + {props.dialogText && {props.dialogText}} + {props.component.element} + + + + {props.dialogSaveButtonLabel || 'Save Changes'} + + + + {props.dialogError && {props.dialogError}} + {props.debug ? : null} + + ); + }} ); }; diff --git a/app/src/components/fields/DateTimeFields.tsx b/app/src/components/fields/DateTimeFields.tsx index 298b1635d1..b6e6556605 100644 --- a/app/src/components/fields/DateTimeFields.tsx +++ b/app/src/components/fields/DateTimeFields.tsx @@ -5,7 +5,7 @@ import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider'; import { DATE_FORMAT, DATE_LIMIT, TIME_FORMAT } from 'constants/dateTimeFormats'; import { default as dayjs } from 'dayjs'; -import { ISurveySampleMethodData } from 'features/surveys/components/MethodForm'; +import { ISurveySampleMethodData } from 'features/surveys/observations/sampling-sites/create/form/MethodForm'; import { FormikContextType } from 'formik'; import get from 'lodash-es/get'; import React from 'react'; diff --git a/app/src/contexts/surveyContext.tsx b/app/src/contexts/surveyContext.tsx index beac9fbdee..b5e4b568f2 100644 --- a/app/src/contexts/surveyContext.tsx +++ b/app/src/contexts/surveyContext.tsx @@ -1,8 +1,8 @@ import { IAnimalDeployment } from 'features/surveys/view/survey-animals/telemetry-device/device'; import { useBiohubApi } from 'hooks/useBioHubApi'; import useDataLoader, { DataLoader } from 'hooks/useDataLoader'; +import { IGetSampleSiteResponse } from 'interfaces/useSamplingSiteApi.interface'; import { - IGetSampleSiteResponse, IGetSurveyAttachmentsResponse, IGetSurveyForViewResponse, ISimpleCritterWithInternalId diff --git a/app/src/features/surveys/CreateSurveyPage.tsx b/app/src/features/surveys/CreateSurveyPage.tsx index e001044c10..d1ddb40e4e 100644 --- a/app/src/features/surveys/CreateSurveyPage.tsx +++ b/app/src/features/surveys/CreateSurveyPage.tsx @@ -22,15 +22,15 @@ import { ICreateSurveyRequest, IEditSurveyRequest } from 'interfaces/useSurveyAp import { useContext, useEffect, useRef, useState } from 'react'; import { Prompt, useHistory } from 'react-router'; import { Link as RouterLink } from 'react-router-dom'; -import { AgreementsInitialValues } from './components/AgreementsForm'; -import { GeneralInformationInitialValues } from './components/GeneralInformationForm'; -import { ProprietaryDataInitialValues } from './components/ProprietaryDataForm'; -import { PurposeAndMethodologyInitialValues } from './components/PurposeAndMethodologyForm'; -import { SurveyLocationInitialValues } from './components/StudyAreaForm'; -import { SurveyBlockInitialValues } from './components/SurveyBlockForm'; -import { SurveyFundingSourceFormInitialValues } from './components/SurveyFundingSourceForm'; -import { SurveySiteSelectionInitialValues } from './components/SurveySiteSelectionForm'; -import { SurveyUserJobFormInitialValues } from './components/SurveyUserForm'; +import { AgreementsInitialValues } from './components/agreements/AgreementsForm'; +import { ProprietaryDataInitialValues } from './components/agreements/ProprietaryDataForm'; +import { SurveyFundingSourceFormInitialValues } from './components/funding/SurveyFundingSourceForm'; +import { GeneralInformationInitialValues } from './components/general-information/GeneralInformationForm'; +import { SurveyLocationInitialValues } from './components/locations/StudyAreaForm'; +import { PurposeAndMethodologyInitialValues } from './components/methodology/PurposeAndMethodologyForm'; +import { SurveyUserJobFormInitialValues } from './components/participants/SurveyUserForm'; +import { SurveyBlockInitialValues } from './components/sampling-strategy/blocks/SurveyBlockForm'; +import { SurveySiteSelectionInitialValues } from './components/sampling-strategy/SurveySiteSelectionForm'; import EditSurveyForm from './edit/EditSurveyForm'; export const defaultSurveyDataFormValues: ICreateSurveyRequest = { diff --git a/app/src/features/surveys/SurveyRouter.tsx b/app/src/features/surveys/SurveyRouter.tsx index cd6f963f49..aae06d4e98 100644 --- a/app/src/features/surveys/SurveyRouter.tsx +++ b/app/src/features/surveys/SurveyRouter.tsx @@ -6,10 +6,9 @@ import React from 'react'; import { Redirect, Switch } from 'react-router'; import RouteWithTitle from 'utils/RouteWithTitle'; import { getTitle } from 'utils/Utils'; -import { SurveyLocationPage } from './components/locations/SurveyLocationPage'; import EditSurveyPage from './edit/EditSurveyPage'; +import SamplingSitePage from './observations/sampling-sites/create/SamplingSitePage'; import SamplingSiteEditPage from './observations/sampling-sites/edit/SamplingSiteEditPage'; -import SamplingSitePage from './observations/sampling-sites/SamplingSitePage'; import { SurveyObservationPage } from './observations/SurveyObservationPage'; import ManualTelemetryPage from './telemetry/ManualTelemetryPage'; import { SurveyAnimalsPage } from './view/survey-animals/SurveyAnimalsPage'; @@ -97,13 +96,6 @@ const SurveyRouter: React.FC = () => { - {/* Survey Locations TODO: Remove unused path and page */} - - - - - - { diff --git a/app/src/features/surveys/components/StudyAreaForm.tsx b/app/src/features/surveys/components/locations/StudyAreaForm.tsx similarity index 96% rename from app/src/features/surveys/components/StudyAreaForm.tsx rename to app/src/features/surveys/components/locations/StudyAreaForm.tsx index ebf5f57d11..334f00e810 100644 --- a/app/src/features/surveys/components/StudyAreaForm.tsx +++ b/app/src/features/surveys/components/locations/StudyAreaForm.tsx @@ -10,9 +10,9 @@ import { useFormikContext } from 'formik'; import { Feature } from 'geojson'; import { createRef, useMemo, useState } from 'react'; import yup from 'utils/YupSchema'; -import { SurveyAreaList } from './locations/SurveyAreaList'; -import SurveyAreaLocationForm from './locations/SurveyAreaLocationForm'; -import { SurveyAreaMapControl } from './locations/SurveyAreaMapControl'; +import { SurveyAreaList } from './SurveyAreaList'; +import SurveyAreaLocationForm from './SurveyAreaLocationForm'; +import { SurveyAreaMapControl } from './SurveyAreaMapControl'; export interface ISurveyLocation { survey_location_id?: number; diff --git a/app/src/features/surveys/components/locations/SurveyAreaList.tsx b/app/src/features/surveys/components/locations/SurveyAreaList.tsx index a3ff17fad7..d0737f95d0 100644 --- a/app/src/features/surveys/components/locations/SurveyAreaList.tsx +++ b/app/src/features/surveys/components/locations/SurveyAreaList.tsx @@ -13,7 +13,7 @@ import Menu, { MenuProps } from '@mui/material/Menu'; import MenuItem from '@mui/material/MenuItem'; import { useState } from 'react'; import TransitionGroup from 'react-transition-group/TransitionGroup'; -import { ISurveyLocation } from '../StudyAreaForm'; +import { ISurveyLocation } from './StudyAreaForm'; export interface ISurveyAreaListProps { data: ISurveyLocation[]; diff --git a/app/src/features/surveys/components/locations/SurveyAreaMapControl.tsx b/app/src/features/surveys/components/locations/SurveyAreaMapControl.tsx index 6998375b66..d5f91eaf27 100644 --- a/app/src/features/surveys/components/locations/SurveyAreaMapControl.tsx +++ b/app/src/features/surveys/components/locations/SurveyAreaMapControl.tsx @@ -23,7 +23,7 @@ import { FeatureGroup, LayersControl, MapContainer as LeafletMapContainer } from import { calculateUpdatedMapBounds } from 'utils/mapBoundaryUploadHelpers'; import { shapeFileFeatureDesc, shapeFileFeatureName } from 'utils/Utils'; import { v4 } from 'uuid'; -import { ISurveyLocation, ISurveyLocationForm } from '../StudyAreaForm'; +import { ISurveyLocation, ISurveyLocationForm } from './StudyAreaForm'; export interface ISurveyAreMapControlProps { map_id: string; diff --git a/app/src/features/surveys/components/locations/SurveyLocationPage.tsx b/app/src/features/surveys/components/locations/SurveyLocationPage.tsx deleted file mode 100644 index 068d845b10..0000000000 --- a/app/src/features/surveys/components/locations/SurveyLocationPage.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import Box from '@mui/material/Box'; -import Button from '@mui/material/Button'; -import CircularProgress from '@mui/material/CircularProgress'; -import { grey } from '@mui/material/colors'; -import Paper from '@mui/material/Paper'; -import Typography from '@mui/material/Typography'; -import { SurveyContext } from 'contexts/surveyContext'; -import { useContext } from 'react'; - -export const SurveyLocationPage = () => { - const surveyContext = useContext(SurveyContext); - - if (!surveyContext.surveyDataLoader.data) { - return ; - } - - return ( - - - - - Survey Area Boundaries - - - - - - - - - - - ); -}; diff --git a/app/src/features/surveys/components/SurveySamplingSiteImportForm.tsx b/app/src/features/surveys/components/locations/SurveySamplingSiteImportForm.tsx similarity index 88% rename from app/src/features/surveys/components/SurveySamplingSiteImportForm.tsx rename to app/src/features/surveys/components/locations/SurveySamplingSiteImportForm.tsx index 9fcfe6abb4..db23b95c17 100644 --- a/app/src/features/surveys/components/SurveySamplingSiteImportForm.tsx +++ b/app/src/features/surveys/components/locations/SurveySamplingSiteImportForm.tsx @@ -1,8 +1,8 @@ import Box from '@mui/material/Box'; import Typography from '@mui/material/Typography'; -import SamplingSiteMapControl from 'features/surveys/observations/sampling-sites/components/SamplingSiteMapControl'; +import SamplingSiteMapControl from 'features/surveys/observations/sampling-sites/components/map/SamplingSiteMapControl'; import { useFormikContext } from 'formik'; -import { ICreateSamplingSiteRequest } from '../observations/sampling-sites/SamplingSitePage'; +import { ICreateSamplingSiteRequest } from 'interfaces/useSamplingSiteApi.interface'; const SurveySamplingSiteImportForm = () => { const formikProps = useFormikContext(); diff --git a/app/src/features/surveys/components/PurposeAndMethodologyForm.tsx b/app/src/features/surveys/components/methodology/PurposeAndMethodologyForm.tsx similarity index 97% rename from app/src/features/surveys/components/PurposeAndMethodologyForm.tsx rename to app/src/features/surveys/components/methodology/PurposeAndMethodologyForm.tsx index dc57b81888..d322138e0f 100644 --- a/app/src/features/surveys/components/PurposeAndMethodologyForm.tsx +++ b/app/src/features/surveys/components/methodology/PurposeAndMethodologyForm.tsx @@ -15,6 +15,7 @@ export interface IPurposeAndMethodologyForm { intended_outcome_ids: number[]; additional_details: string; vantage_code_ids: number[]; + revision_count: number; }; } @@ -22,7 +23,8 @@ export const PurposeAndMethodologyInitialValues: IPurposeAndMethodologyForm = { purpose_and_methodology: { intended_outcome_ids: [], additional_details: '', - vantage_code_ids: [] + vantage_code_ids: [], + revision_count: 0 } }; diff --git a/app/src/features/surveys/components/SurveyUserForm.test.tsx b/app/src/features/surveys/components/participants/SurveyUserForm.test.tsx similarity index 98% rename from app/src/features/surveys/components/SurveyUserForm.test.tsx rename to app/src/features/surveys/components/participants/SurveyUserForm.test.tsx index 2c90a5ca93..6fcb9776b8 100644 --- a/app/src/features/surveys/components/SurveyUserForm.test.tsx +++ b/app/src/features/surveys/components/participants/SurveyUserForm.test.tsx @@ -18,7 +18,7 @@ const mockJobs: ICode[] = [ } ]; -jest.mock('../../../hooks/useBioHubApi'); +jest.mock('../../../../hooks/useBioHubApi'); const mockBiohubApi = useBiohubApi as jest.Mock; const mockUseApi = { diff --git a/app/src/features/surveys/components/SurveyUserForm.tsx b/app/src/features/surveys/components/participants/SurveyUserForm.tsx similarity index 100% rename from app/src/features/surveys/components/SurveyUserForm.tsx rename to app/src/features/surveys/components/participants/SurveyUserForm.tsx diff --git a/app/src/features/surveys/components/SamplingStrategyForm.tsx b/app/src/features/surveys/components/sampling-strategy/SamplingStrategyForm.tsx similarity index 92% rename from app/src/features/surveys/components/SamplingStrategyForm.tsx rename to app/src/features/surveys/components/sampling-strategy/SamplingStrategyForm.tsx index a6e2bad788..d278a50c1e 100644 --- a/app/src/features/surveys/components/SamplingStrategyForm.tsx +++ b/app/src/features/surveys/components/sampling-strategy/SamplingStrategyForm.tsx @@ -2,9 +2,9 @@ import Box from '@mui/material/Box'; import Collapse from '@mui/material/Collapse'; import Typography from '@mui/material/Typography'; import { useState } from 'react'; -import SurveyBlockSection from './SurveyBlockForm'; +import SurveyBlockSection from './blocks/SurveyBlockForm'; +import SurveyStratumForm from './stratums/SurveyStratumForm'; import SurveySiteSelectionForm from './SurveySiteSelectionForm'; -import SurveyStratumForm from './SurveyStratumForm'; const SamplingStrategyForm = () => { const [showStratumForm, setShowStratumForm] = useState(false); diff --git a/app/src/features/surveys/components/SurveySiteSelectionForm.tsx b/app/src/features/surveys/components/sampling-strategy/SurveySiteSelectionForm.tsx similarity index 93% rename from app/src/features/surveys/components/SurveySiteSelectionForm.tsx rename to app/src/features/surveys/components/sampling-strategy/SurveySiteSelectionForm.tsx index 39406ddada..add11ca6f1 100644 --- a/app/src/features/surveys/components/SurveySiteSelectionForm.tsx +++ b/app/src/features/surveys/components/sampling-strategy/SurveySiteSelectionForm.tsx @@ -3,17 +3,10 @@ import YesNoDialog from 'components/dialog/YesNoDialog'; import MultiAutocompleteField from 'components/fields/MultiAutocompleteField'; import { CodesContext } from 'contexts/codesContext'; import { useFormikContext } from 'formik'; -import { IEditSurveyRequest, IGetSurveyStratum } from 'interfaces/useSurveyApi.interface'; +import { IEditSurveyRequest, ISurveySiteSelectionForm } from 'interfaces/useSurveyApi.interface'; import { useContext, useEffect, useState } from 'react'; import yup from 'utils/YupSchema'; -export interface ISurveySiteSelectionForm { - site_selection: { - strategies: string[]; - stratums: IGetSurveyStratum[]; - }; -} - export const SurveySiteSelectionInitialValues: ISurveySiteSelectionForm = { site_selection: { strategies: [], @@ -41,9 +34,9 @@ export const SurveySiteSelectionYupSchema = yup.object().shape({ .array() .of( yup.object({ - survey_stratum_id: yup.number().optional(), + survey_stratum_id: yup.number().nullable(), name: yup.string().required('Must provide a name for stratum'), - description: yup.string().optional() + description: yup.string() }) ) .test('duplicateStratums', 'Stratums must have unique names.', (stratums) => { diff --git a/app/src/features/surveys/components/BlockForm.tsx b/app/src/features/surveys/components/sampling-strategy/blocks/BlockForm.tsx similarity index 100% rename from app/src/features/surveys/components/BlockForm.tsx rename to app/src/features/surveys/components/sampling-strategy/blocks/BlockForm.tsx diff --git a/app/src/features/surveys/components/CreateSurveyBlockDialog.tsx b/app/src/features/surveys/components/sampling-strategy/blocks/CreateSurveyBlockDialog.tsx similarity index 89% rename from app/src/features/surveys/components/CreateSurveyBlockDialog.tsx rename to app/src/features/surveys/components/sampling-strategy/blocks/CreateSurveyBlockDialog.tsx index fc3f673bbd..b6e0fa47ba 100644 --- a/app/src/features/surveys/components/CreateSurveyBlockDialog.tsx +++ b/app/src/features/surveys/components/sampling-strategy/blocks/CreateSurveyBlockDialog.tsx @@ -1,6 +1,6 @@ import EditDialog from 'components/dialog/EditDialog'; import BlockForm from './BlockForm'; -import { BlockYupSchema } from './SurveyBlockForm'; +import { BlockCreateYupSchema } from './SurveyBlockForm'; interface ICreateBlockProps { open: boolean; onSave: (data: any) => void; @@ -23,7 +23,7 @@ const CreateSurveyBlockDialog: React.FC = (props) => { description: '', sample_block_count: 0 }, - validationSchema: BlockYupSchema + validationSchema: BlockCreateYupSchema }} dialogSaveButtonLabel="Add Block" onCancel={() => onClose()} diff --git a/app/src/features/surveys/components/EditSurveyBlockDialog.tsx b/app/src/features/surveys/components/sampling-strategy/blocks/EditSurveyBlockDialog.tsx similarity index 79% rename from app/src/features/surveys/components/EditSurveyBlockDialog.tsx rename to app/src/features/surveys/components/sampling-strategy/blocks/EditSurveyBlockDialog.tsx index 7d1b0cdc2e..960e881d35 100644 --- a/app/src/features/surveys/components/EditSurveyBlockDialog.tsx +++ b/app/src/features/surveys/components/sampling-strategy/blocks/EditSurveyBlockDialog.tsx @@ -1,6 +1,6 @@ import EditDialog from 'components/dialog/EditDialog'; import BlockForm from './BlockForm'; -import { BlockYupSchema, ISurveyBlock } from './SurveyBlockForm'; +import { BlockEditYupSchema, ISurveyBlock } from './SurveyBlockForm'; interface IEditBlockProps { open: boolean; @@ -22,9 +22,10 @@ const EditSurveyBlockDialog: React.FC = (props) => { initialValues: { survey_block_id: initialData?.block.survey_block_id || null, name: initialData?.block.name || '', - description: initialData?.block.description || '' + description: initialData?.block.description || '', + sample_block_count: initialData?.block.sample_block_count }, - validationSchema: BlockYupSchema + validationSchema: BlockEditYupSchema }} dialogSaveButtonLabel="Save" onCancel={() => { diff --git a/app/src/features/surveys/components/SurveyBlockForm.tsx b/app/src/features/surveys/components/sampling-strategy/blocks/SurveyBlockForm.tsx similarity index 96% rename from app/src/features/surveys/components/SurveyBlockForm.tsx rename to app/src/features/surveys/components/sampling-strategy/blocks/SurveyBlockForm.tsx index f5363615ec..98779ebf7f 100644 --- a/app/src/features/surveys/components/SurveyBlockForm.tsx +++ b/app/src/features/surveys/components/sampling-strategy/blocks/SurveyBlockForm.tsx @@ -25,13 +25,15 @@ export const SurveyBlockInitialValues = { }; // Form validation for Block Item -export const BlockYupSchema = yup.object({ +export const BlockCreateYupSchema = yup.object({ name: yup.string().required('Name is required').max(50, 'Maximum 50 characters'), - description: yup.string().required('Description is required').max(250, 'Maximum 250 characters'), - sample_block_count: yup.number().required('Sample block count is required').min(0) + description: yup.string().required('Description is required').max(250, 'Maximum 250 characters') }); -export const SurveyBlockYupSchema = yup.array(BlockYupSchema); +// Form validation for Block Item +export const BlockEditYupSchema = BlockCreateYupSchema.shape({ + sample_block_count: yup.number().required('Sample block count is required.') +}); export interface ISurveyBlock { index: number; @@ -43,7 +45,7 @@ export interface ISurveyBlock { }; } -const SurveyBlockSection: React.FC = () => { +const SurveyBlockForm: React.FC = () => { const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); const [isEditModalOpen, setIsEditModalOpen] = useState(false); const [isYesNoDialogOpen, setIsYesNoDialogOpen] = useState(false); @@ -206,4 +208,4 @@ const SurveyBlockSection: React.FC = () => { ); }; -export default SurveyBlockSection; +export default SurveyBlockForm; diff --git a/app/src/features/surveys/components/StratumCreateOrEditDialog.tsx b/app/src/features/surveys/components/sampling-strategy/stratums/StratumCreateOrEditDialog.tsx similarity index 95% rename from app/src/features/surveys/components/StratumCreateOrEditDialog.tsx rename to app/src/features/surveys/components/sampling-strategy/stratums/StratumCreateOrEditDialog.tsx index dbda872468..c7801316ef 100644 --- a/app/src/features/surveys/components/StratumCreateOrEditDialog.tsx +++ b/app/src/features/surveys/components/sampling-strategy/stratums/StratumCreateOrEditDialog.tsx @@ -7,8 +7,9 @@ import useTheme from '@mui/material/styles/useTheme'; import useMediaQuery from '@mui/material/useMediaQuery'; import CustomTextField from 'components/fields/CustomTextField'; import { Formik, FormikProps } from 'formik'; +import { IGetSurveyStratumForm } from 'interfaces/useSurveyApi.interface'; import { useRef } from 'react'; -import { IGetSurveyStratumForm, StratumFormYupSchema } from './SurveyStratumForm'; +import { StratumFormYupSchema } from './SurveyStratumForm'; interface IGetSurveyStratumDialogProps { open: boolean; diff --git a/app/src/features/surveys/components/SurveyStratumForm.tsx b/app/src/features/surveys/components/sampling-strategy/stratums/SurveyStratumForm.tsx similarity index 78% rename from app/src/features/surveys/components/SurveyStratumForm.tsx rename to app/src/features/surveys/components/sampling-strategy/stratums/SurveyStratumForm.tsx index 7d14cd9410..1607e8a505 100644 --- a/app/src/features/surveys/components/SurveyStratumForm.tsx +++ b/app/src/features/surveys/components/sampling-strategy/stratums/SurveyStratumForm.tsx @@ -13,7 +13,7 @@ import MenuItem from '@mui/material/MenuItem'; import Typography from '@mui/material/Typography'; import YesNoDialog from 'components/dialog/YesNoDialog'; import { FormikProps, useFormikContext } from 'formik'; -import { IEditSurveyRequest, IGetSurveyStratum } from 'interfaces/useSurveyApi.interface'; +import { IEditSurveyRequest, IGetSurveyStratum, IGetSurveyStratumForm } from 'interfaces/useSurveyApi.interface'; import get from 'lodash-es/get'; import { useState } from 'react'; import { TransitionGroup } from 'react-transition-group'; @@ -21,38 +21,26 @@ import { pluralize as p } from 'utils/Utils'; import yup from 'utils/YupSchema'; import StratumCreateOrEditDialog from './StratumCreateOrEditDialog'; -export interface IGetSurveyStratumForm { - index: number | null; - stratum: IPostSurveyStratum | IGetSurveyStratum; -} - -export interface IPostSurveyStratum { - survey_stratum_id: number; - name: string; - description?: string; - sample_stratum_count: number; -} - export const StratumFormInitialValues: IGetSurveyStratumForm = { index: null, stratum: { - survey_stratum_id: 0, + survey_stratum_id: null, name: '', description: '', - sample_stratum_count: 0 + sample_stratum_count: 0, + revision_count: 0 } }; export const StratumFormYupSchema = yup.object().shape({ index: yup.number().nullable(true), stratum: yup.object().shape({ - survey_stratum_id: yup.number(), + survey_stratum_id: yup.number().nullable(), name: yup.string().required('Must provide a Stratum name').max(300, 'Name cannot exceed 300 characters'), description: yup .string() .required('Must provide a Stratum description') - .max(3000, 'Description cannot exceed 3000 characters'), - sample_stratum_count: yup.number().required('Sample stratum count is required.').min(0) + .max(3000, 'Description cannot exceed 3000 characters') }) }); @@ -82,17 +70,17 @@ const SurveyStratumForm = () => { setFieldValue('site_selection.stratums', [ ...values.site_selection.stratums, { + survey_stratum_id: stratumForm.stratum.survey_stratum_id, name: stratumForm.stratum.name, - description: stratumForm.stratum.description, - sample_stratum_count: stratumForm.stratum.sample_stratum_count + description: stratumForm.stratum.description } ]); } else { // Edit existing stratum setFieldValue(`site_selection.stratums[${stratumForm.index}`, { + survey_stratum_id: stratumForm.stratum.survey_stratum_id, name: stratumForm.stratum.name, - description: stratumForm.stratum.description, - sample_stratum_count: stratumForm.stratum.sample_stratum_count + description: stratumForm.stratum.description }); } @@ -141,25 +129,27 @@ const SurveyStratumForm = () => { /> {/* DELETE BLOCK ASSIGNED TO SAMPLE SITES CONFIRMATION DIALOG */} - 1 ? 'reference' : 'references' - } it.`} - yesButtonProps={{ color: 'error' }} - yesButtonLabel={'Remove'} - noButtonProps={{ color: 'primary', variant: 'outlined' }} - noButtonLabel={'Cancel'} - open={isYesNoDialogOpen} - onYes={() => { - setIsYesNoDialogOpen(false); - handleDelete(); - }} - onClose={() => setIsYesNoDialogOpen(false)} - onNo={() => setIsYesNoDialogOpen(false)} - /> + {'sample_stratum_count' in currentStratumForm.stratum && ( + 1 ? 'reference' : 'references' + } it.`} + yesButtonProps={{ color: 'error' }} + yesButtonLabel={'Remove'} + noButtonProps={{ color: 'primary', variant: 'outlined' }} + noButtonLabel={'Cancel'} + open={isYesNoDialogOpen} + onYes={() => { + setIsYesNoDialogOpen(false); + handleDelete(); + }} + onClose={() => setIsYesNoDialogOpen(false)} + onNo={() => setIsYesNoDialogOpen(false)} + /> + )} { - currentStratumForm?.stratum.sample_stratum_count === 0 ? handleDelete() : setIsYesNoDialogOpen(true) + 'sample_stratum_count' in currentStratumForm.stratum && + currentStratumForm.stratum.sample_stratum_count === 0 + ? handleDelete() + : setIsYesNoDialogOpen(true) }> @@ -194,7 +187,7 @@ const SurveyStratumForm = () => { // Show array level error, if any - {get(errors, 'site_selection.stratums') as string} + {/* {get(errors, 'site_selection.stratums') as string} */} )} diff --git a/app/src/features/surveys/edit/EditSurveyForm.tsx b/app/src/features/surveys/edit/EditSurveyForm.tsx index 3fd5315d93..ae7287d58e 100644 --- a/app/src/features/surveys/edit/EditSurveyForm.tsx +++ b/app/src/features/surveys/edit/EditSurveyForm.tsx @@ -6,21 +6,27 @@ import FormikErrorSnackbar from 'components/alert/FormikErrorSnackbar'; import HorizontalSplitFormComponent from 'components/fields/HorizontalSplitFormComponent'; import { CodesContext } from 'contexts/codesContext'; import { ProjectContext } from 'contexts/projectContext'; -import SamplingStrategyForm from 'features/surveys/components/SamplingStrategyForm'; +import SamplingStrategyForm from 'features/surveys/components/sampling-strategy/SamplingStrategyForm'; import SurveyPartnershipsForm, { SurveyPartnershipsFormYupSchema } from 'features/surveys/view/components/SurveyPartnershipsForm'; import { Formik, FormikProps } from 'formik'; import { ICreateSurveyRequest, IEditSurveyRequest, SurveyUpdateObject } from 'interfaces/useSurveyApi.interface'; import React, { useContext } from 'react'; -import AgreementsForm, { AgreementsYupSchema } from '../components/AgreementsForm'; -import GeneralInformationForm, { GeneralInformationYupSchema } from '../components/GeneralInformationForm'; -import ProprietaryDataForm, { ProprietaryDataYupSchema } from '../components/ProprietaryDataForm'; -import PurposeAndMethodologyForm, { PurposeAndMethodologyYupSchema } from '../components/PurposeAndMethodologyForm'; -import StudyAreaForm, { SurveyLocationYupSchema } from '../components/StudyAreaForm'; -import SurveyFundingSourceForm, { SurveyFundingSourceFormYupSchema } from '../components/SurveyFundingSourceForm'; -import { SurveySiteSelectionYupSchema } from '../components/SurveySiteSelectionForm'; -import SurveyUserForm, { SurveyUserJobYupSchema } from '../components/SurveyUserForm'; +import AgreementsForm, { AgreementsYupSchema } from '../components/agreements/AgreementsForm'; +import ProprietaryDataForm, { ProprietaryDataYupSchema } from '../components/agreements/ProprietaryDataForm'; +import SurveyFundingSourceForm, { + SurveyFundingSourceFormYupSchema +} from '../components/funding/SurveyFundingSourceForm'; +import GeneralInformationForm, { + GeneralInformationYupSchema +} from '../components/general-information/GeneralInformationForm'; +import StudyAreaForm, { SurveyLocationYupSchema } from '../components/locations/StudyAreaForm'; +import PurposeAndMethodologyForm, { + PurposeAndMethodologyYupSchema +} from '../components/methodology/PurposeAndMethodologyForm'; +import SurveyUserForm, { SurveyUserJobYupSchema } from '../components/participants/SurveyUserForm'; +import { SurveySiteSelectionYupSchema } from '../components/sampling-strategy/SurveySiteSelectionForm'; export interface IEditSurveyForm { initialSurveyData: SurveyUpdateObject | ICreateSurveyRequest; @@ -57,7 +63,7 @@ const EditSurveyForm = (props: IEditSurveyForm) => { return ( { */ const handleSubmit = async (values: IEditSurveyRequest) => { setIsSaving(true); + try { - const response = await biohubApi.survey.updateSurvey( - projectContext.projectId, - surveyId, - values as unknown as SurveyUpdateObject - ); + const response = await biohubApi.survey.updateSurvey(projectContext.projectId, surveyId, { + blocks: values.blocks, + funding_sources: values.funding_sources, + locations: values.locations.map((location) => ({ + survey_location_id: location.survey_location_id, + geojson: location.geojson, + name: location.name, + description: location.description, + revision_count: location.revision_count + })), + participants: values.participants, + partnerships: values.partnerships, + permit: values.permit, + proprietor: values.proprietor, + purpose_and_methodology: values.purpose_and_methodology, + site_selection: { + stratums: values.site_selection.stratums.map((stratum) => ({ + survey_stratum_id: stratum.survey_stratum_id, + name: stratum.name, + description: stratum.description + })), + strategies: values.site_selection.strategies + }, + species: values.species, + survey_details: values.survey_details + }); if (!response?.id) { showEditErrorDialog({ diff --git a/app/src/features/surveys/observations/observations-table/ObservationsTableContainer.tsx b/app/src/features/surveys/observations/observations-table/ObservationsTableContainer.tsx index 02b7b0f7b9..6d863ca541 100644 --- a/app/src/features/surveys/observations/observations-table/ObservationsTableContainer.tsx +++ b/app/src/features/surveys/observations/observations-table/ObservationsTableContainer.tsx @@ -14,7 +14,6 @@ import DataGridValidationAlert from 'components/data-grid/DataGridValidationAler import { IObservationTableRow } from 'contexts/observationsTableContext'; import { SurveyContext } from 'contexts/surveyContext'; import { BulkActionsButton } from 'features/surveys/observations/observations-table/bulk-actions/BulkActionsButton'; -import { ConfigureColumnsContainer } from 'features/surveys/observations/observations-table/configure-table/ConfigureColumnsContainer'; import { DiscardChangesButton } from 'features/surveys/observations/observations-table/discard-changes/DiscardChangesButton'; import { ISampleMethodOption, @@ -38,9 +37,10 @@ import { IGetSampleLocationDetails, IGetSampleMethodRecord, IGetSamplePeriodRecord -} from 'interfaces/useSurveyApi.interface'; +} from 'interfaces/useSamplingSiteApi.interface'; import { useContext } from 'react'; import { getCodesName } from 'utils/Utils'; +import { ConfigureColumnsContainer } from './configure-table/ConfigureColumnsContainer'; import ExportHeadersButton from './export-button/ExportHeadersButton'; import { getMeasurementColumnDefinitions } from './grid-column-definitions/GridColumnDefinitionsUtils'; diff --git a/app/src/features/surveys/observations/observations-table/configure-table/ConfigureColumns.tsx b/app/src/features/surveys/observations/observations-table/configure-table/ConfigureColumns.tsx index 8225127cfb..5fa3bb30d6 100644 --- a/app/src/features/surveys/observations/observations-table/configure-table/ConfigureColumns.tsx +++ b/app/src/features/surveys/observations/observations-table/configure-table/ConfigureColumns.tsx @@ -4,9 +4,9 @@ import IconButton from '@mui/material/IconButton'; import Popover from '@mui/material/Popover'; import { GridColDef } from '@mui/x-data-grid'; import { IObservationTableRow } from 'contexts/observationsTableContext'; -import { ConfigureColumnsPopoverContent } from 'features/surveys/observations/observations-table/configure-table/ConfigureColumnsPopoverContent'; import { CBMeasurementType } from 'interfaces/useCritterApi.interface'; import { useState } from 'react'; +import { ConfigureColumnsPopoverContent } from './ConfigureColumnsPopoverContent'; export interface IConfigureColumnsProps { /** diff --git a/app/src/features/surveys/observations/observations-table/configure-table/ConfigureColumnsPopoverContent.tsx b/app/src/features/surveys/observations/observations-table/configure-table/ConfigureColumnsPopoverContent.tsx index de418c6951..94c63c570f 100644 --- a/app/src/features/surveys/observations/observations-table/configure-table/ConfigureColumnsPopoverContent.tsx +++ b/app/src/features/surveys/observations/observations-table/configure-table/ConfigureColumnsPopoverContent.tsx @@ -15,8 +15,8 @@ import Stack from '@mui/material/Stack'; import Typography from '@mui/material/Typography'; import { GridColDef } from '@mui/x-data-grid'; import { IObservationTableRow } from 'contexts/observationsTableContext'; -import { MeasurementsButton } from 'features/surveys/observations/observations-table/configure-table/measurements/dialog/MeasurementsButton'; import { CBMeasurementType } from 'interfaces/useCritterApi.interface'; +import { MeasurementsButton } from './measurements/dialog/MeasurementsButton'; export interface IConfigureColumnsPopoverContentProps { hideableColumns: GridColDef[]; diff --git a/app/src/features/surveys/observations/sampling-sites/edit/components/BlockStratumCard.tsx b/app/src/features/surveys/observations/sampling-sites/components/BlockStratumCard.tsx similarity index 100% rename from app/src/features/surveys/observations/sampling-sites/edit/components/BlockStratumCard.tsx rename to app/src/features/surveys/observations/sampling-sites/components/BlockStratumCard.tsx diff --git a/app/src/features/surveys/observations/sampling-sites/components/SamplingSiteGroupingsForm.tsx b/app/src/features/surveys/observations/sampling-sites/components/SamplingSiteGroupingsForm.tsx index 9cbdfe7af0..1def301da1 100644 --- a/app/src/features/surveys/observations/sampling-sites/components/SamplingSiteGroupingsForm.tsx +++ b/app/src/features/surveys/observations/sampling-sites/components/SamplingSiteGroupingsForm.tsx @@ -1,18 +1,14 @@ import Box from '@mui/material/Box'; -import { useParams } from 'react-router'; -import SamplingBlockEditForm from '../edit/components/SamplingBlockEditForm'; -import SamplingStratumEditForm from '../edit/components/SamplingStratumEditForm'; -import SamplingBlockForm from './SamplingBlockForm'; -import SamplingStratumForm from './SamplingStratumForm'; +import SamplingBlockForm from '../edit/form/SamplingBlockForm'; +import SamplingStratumForm from '../edit/form/SamplingStratumForm'; const SamplingSiteGroupingsForm = () => { - const urlParams: Record = useParams(); - const surveySampleSiteId: number | null = Number(urlParams['survey_sample_site_id']) || null; - return ( <> - {surveySampleSiteId ? : } - {surveySampleSiteId ? : } + + + + ); }; diff --git a/app/src/features/surveys/observations/sampling-sites/SamplingSiteHeader.tsx b/app/src/features/surveys/observations/sampling-sites/components/SamplingSiteHeader.tsx similarity index 96% rename from app/src/features/surveys/observations/sampling-sites/SamplingSiteHeader.tsx rename to app/src/features/surveys/observations/sampling-sites/components/SamplingSiteHeader.tsx index d159c0b59f..4b7b314e36 100644 --- a/app/src/features/surveys/observations/sampling-sites/SamplingSiteHeader.tsx +++ b/app/src/features/surveys/observations/sampling-sites/components/SamplingSiteHeader.tsx @@ -8,9 +8,9 @@ import Paper from '@mui/material/Paper'; import Stack from '@mui/material/Stack'; import Typography from '@mui/material/Typography'; import { useFormikContext } from 'formik'; +import { ICreateSamplingSiteRequest } from 'interfaces/useSamplingSiteApi.interface'; import { useHistory } from 'react-router'; import { Link as RouterLink } from 'react-router-dom'; -import { ICreateSamplingSiteRequest } from './SamplingSitePage'; export interface ISamplingSiteHeaderProps { project_id: number; @@ -32,6 +32,7 @@ export const SamplingSiteHeader: React.FC = (props) => const formikProps = useFormikContext(); const { project_id, survey_id, survey_name, is_submitting, title, breadcrumb } = props; + return ( <> = (props) => diff --git a/app/src/features/surveys/observations/sampling-sites/edit/components/SamplingSiteEditMapControl.tsx b/app/src/features/surveys/observations/sampling-sites/components/map/SamplingSiteEditMapControl.tsx similarity index 78% rename from app/src/features/surveys/observations/sampling-sites/edit/components/SamplingSiteEditMapControl.tsx rename to app/src/features/surveys/observations/sampling-sites/components/map/SamplingSiteEditMapControl.tsx index ef2ad8b94f..55c51ce349 100644 --- a/app/src/features/surveys/observations/sampling-sites/edit/components/SamplingSiteEditMapControl.tsx +++ b/app/src/features/surveys/observations/sampling-sites/components/map/SamplingSiteEditMapControl.tsx @@ -16,15 +16,17 @@ import FullScreenScrollingEventHandler from 'components/map/components/FullScree import StaticLayers, { IStaticLayer } from 'components/map/components/StaticLayers'; import { MapBaseCss } from 'components/map/styles/MapBaseCss'; import { MAP_DEFAULT_CENTER, MAP_DEFAULT_ZOOM } from 'constants/spatial'; -import { SurveyContext } from 'contexts/surveyContext'; -import SampleSiteFileUploadItemActionButton from 'features/surveys/observations/sampling-sites/components/SampleSiteFileUploadItemActionButton'; -import SampleSiteFileUploadItemProgressBar from 'features/surveys/observations/sampling-sites/components/SampleSiteFileUploadItemProgressBar'; -import SampleSiteFileUploadItemSubtext from 'features/surveys/observations/sampling-sites/components/SampleSiteFileUploadItemSubtext'; +import SampleSiteFileUploadItemActionButton from 'features/surveys/observations/sampling-sites/components/map/file-upload/SampleSiteFileUploadItemActionButton'; +import SampleSiteFileUploadItemProgressBar from 'features/surveys/observations/sampling-sites/components/map/file-upload/SampleSiteFileUploadItemProgressBar'; +import SampleSiteFileUploadItemSubtext from 'features/surveys/observations/sampling-sites/components/map/file-upload/SampleSiteFileUploadItemSubtext'; import { FormikContextType } from 'formik'; import { Feature } from 'geojson'; +import { useBiohubApi } from 'hooks/useBioHubApi'; +import { useSurveyContext } from 'hooks/useContext'; +import useDataLoader from 'hooks/useDataLoader'; import { DrawEvents, LatLngBoundsExpression } from 'leaflet'; import get from 'lodash-es/get'; -import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { FeatureGroup, LayersControl, MapContainer as LeafletMapContainer } from 'react-leaflet'; import { useParams } from 'react-router'; import { boundaryUploadHelper, calculateUpdatedMapBounds } from 'utils/mapBoundaryUploadHelpers'; @@ -60,13 +62,22 @@ export interface ISamplingSiteEditMapControlProps { */ const SamplingSiteEditMapControl = (props: ISamplingSiteEditMapControlProps) => { const classes = useStyles(); - const surveyContext = useContext(SurveyContext); + const surveyContext = useSurveyContext(); const urlParams: Record = useParams(); - const surveySampleSiteId: number | null = Number(urlParams['survey_sample_site_id']) || null; + const surveySampleSiteId = Number(urlParams['survey_sample_site_id']); - const sampleSiteData = surveyContext.sampleSiteDataLoader.data - ? surveyContext.sampleSiteDataLoader.data.sampleSites.find((x) => x.survey_sample_site_id === surveySampleSiteId) - : undefined; + const biohubApi = useBiohubApi(); + + const projectId = surveyContext.projectId; + const surveyId = surveyContext.surveyId; + + const samplingSiteDataLoader = useDataLoader(() => { + return biohubApi.samplingSite.getSampleSiteById(projectId, surveyId, surveySampleSiteId); + }); + + if (!samplingSiteDataLoader.data) { + samplingSiteDataLoader.load(); + } const drawControlsRef = useRef(); @@ -80,25 +91,27 @@ const SamplingSiteEditMapControl = (props: ISamplingSiteEditMapControlProps) => const [staticLayers, setStaticLayers] = useState([]); const removeFile = () => { - setFieldValue(name, sampleSiteData?.geojson ? [sampleSiteData?.geojson] : []); + setFieldValue(name, samplingSiteDataLoader.data?.geojson); setFieldError(name, undefined); }; // Array of sampling site features - const samplingSiteGeoJsonFeatures: Feature[] = useMemo(() => get(values, name), [values, name]); + const samplingSiteGeoJsonFeatures: Feature[] = useMemo(() => [values.geojson], [values]); const updateStaticLayers = useCallback( (geoJsonFeatures: Feature[]) => { - setUpdatedBounds(calculateUpdatedMapBounds(geoJsonFeatures)); + if (samplingSiteGeoJsonFeatures.length) { + setUpdatedBounds(calculateUpdatedMapBounds(geoJsonFeatures)); - const staticLayers: IStaticLayer[] = [ - { - layerName: 'Sampling Sites', - features: samplingSiteGeoJsonFeatures.map((feature: Feature, index) => ({ geoJSON: feature, key: index })) - } - ]; + const staticLayers: IStaticLayer[] = [ + { + layerName: 'Sampling Sites', + features: samplingSiteGeoJsonFeatures.map((feature: Feature, index) => ({ geoJSON: feature, key: index })) + } + ]; - setStaticLayers(staticLayers); + setStaticLayers(staticLayers); + } }, [samplingSiteGeoJsonFeatures] ); @@ -125,7 +138,11 @@ const SamplingSiteEditMapControl = (props: ISamplingSiteEditMapControlProps) => { - setFieldValue(name, [...features]); + if (features.length > 1) { + setFieldError(name, 'Multiple locations detected in file'); + } else { + setFieldValue(name, features[0]); + } }, onFailure: (message: string) => { setFieldError(name, message); @@ -135,7 +152,7 @@ const SamplingSiteEditMapControl = (props: ISamplingSiteEditMapControlProps) => dropZoneProps={{ maxNumFiles: 1, multiple: false, - acceptedFileExtensions: '.zip' + acceptedFileExtensions: '.zip, .kml' }} hideDropZoneOnMaxFiles={true} FileUploadItemComponent={FileUploadItem} @@ -164,7 +181,7 @@ const SamplingSiteEditMapControl = (props: ISamplingSiteEditMapControlProps) => mb: 2 }} severity="error"> - Multiple boundaries detected + Oops, something went wrong {get(errors, name) as string} )} @@ -193,9 +210,9 @@ const SamplingSiteEditMapControl = (props: ISamplingSiteEditMapControlProps) => drawControlsRef?.current?.deleteLayer(lastDrawn); } - const feature = event.layer.toGeoJSON(); + const feature = event.layer.toGeoJSON() as Feature; - setFieldValue(name, [feature]); + setFieldValue(name, feature); setEditedGeometry([feature]); setLastDrawn(id); @@ -203,12 +220,12 @@ const SamplingSiteEditMapControl = (props: ISamplingSiteEditMapControlProps) => onLayerEdit={(event: DrawEvents.Edited) => { event.layers.getLayers().forEach((layer: any) => { const feature = layer.toGeoJSON() as Feature; - setFieldValue(name, [feature]); + setFieldValue(name, feature); setEditedGeometry([feature]); }); }} onLayerDelete={() => { - setFieldValue(name, sampleSiteData?.geojson ? [sampleSiteData?.geojson] : []); + setFieldValue(name, samplingSiteDataLoader.data?.geojson); }} /> diff --git a/app/src/features/surveys/observations/sampling-sites/components/SamplingSiteMapControl.tsx b/app/src/features/surveys/observations/sampling-sites/components/map/SamplingSiteMapControl.tsx similarity index 94% rename from app/src/features/surveys/observations/sampling-sites/components/SamplingSiteMapControl.tsx rename to app/src/features/surveys/observations/sampling-sites/components/map/SamplingSiteMapControl.tsx index ed319b1f20..567cef1596 100644 --- a/app/src/features/surveys/observations/sampling-sites/components/SamplingSiteMapControl.tsx +++ b/app/src/features/surveys/observations/sampling-sites/components/map/SamplingSiteMapControl.tsx @@ -17,9 +17,9 @@ import StaticLayers from 'components/map/components/StaticLayers'; import { MapBaseCss } from 'components/map/styles/MapBaseCss'; import { MAP_DEFAULT_CENTER, MAP_DEFAULT_ZOOM } from 'constants/spatial'; import { SurveyContext } from 'contexts/surveyContext'; -import SampleSiteFileUploadItemActionButton from 'features/surveys/observations/sampling-sites/components/SampleSiteFileUploadItemActionButton'; -import SampleSiteFileUploadItemProgressBar from 'features/surveys/observations/sampling-sites/components/SampleSiteFileUploadItemProgressBar'; -import SampleSiteFileUploadItemSubtext from 'features/surveys/observations/sampling-sites/components/SampleSiteFileUploadItemSubtext'; +import SampleSiteFileUploadItemActionButton from 'features/surveys/observations/sampling-sites/components/map/file-upload/SampleSiteFileUploadItemActionButton'; +import SampleSiteFileUploadItemProgressBar from 'features/surveys/observations/sampling-sites/components/map/file-upload/SampleSiteFileUploadItemProgressBar'; +import SampleSiteFileUploadItemSubtext from 'features/surveys/observations/sampling-sites/components/map/file-upload/SampleSiteFileUploadItemSubtext'; import { FormikContextType } from 'formik'; import { Feature } from 'geojson'; import { DrawEvents, LatLngBoundsExpression } from 'leaflet'; @@ -31,7 +31,7 @@ import { useContext, useEffect, useMemo, useRef, useState } from 'react'; import { FeatureGroup, LayersControl, MapContainer as LeafletMapContainer } from 'react-leaflet'; import { boundaryUploadHelper, calculateUpdatedMapBounds } from 'utils/mapBoundaryUploadHelpers'; import { pluralize, shapeFileFeatureDesc, shapeFileFeatureName } from 'utils/Utils'; -import { ISurveySampleSite } from '../SamplingSitePage'; +import { ISurveySampleSite } from '../../create/SamplingSitePage'; const useStyles = () => { return { @@ -89,7 +89,9 @@ const SamplingSiteMapControl = (props: ISamplingSiteMapControlProps) => { ); useEffect(() => { - setUpdatedBounds(calculateUpdatedMapBounds(samplingSiteGeoJsonFeatures)); + if (samplingSiteGeoJsonFeatures) { + setUpdatedBounds(calculateUpdatedMapBounds(samplingSiteGeoJsonFeatures)); + } }, [samplingSiteGeoJsonFeatures]); return ( @@ -126,7 +128,7 @@ const SamplingSiteMapControl = (props: ISamplingSiteMapControlProps) => { dropZoneProps={{ maxNumFiles: 1, multiple: false, - acceptedFileExtensions: '.zip' + acceptedFileExtensions: '.zip, .kml' }} hideDropZoneOnMaxFiles={true} FileUploadItemComponent={FileUploadItem} diff --git a/app/src/features/surveys/observations/sampling-sites/edit/components/SurveySampleSiteEditForm.tsx b/app/src/features/surveys/observations/sampling-sites/components/map/SurveySampleSiteEditForm.tsx similarity index 67% rename from app/src/features/surveys/observations/sampling-sites/edit/components/SurveySampleSiteEditForm.tsx rename to app/src/features/surveys/observations/sampling-sites/components/map/SurveySampleSiteEditForm.tsx index 4691954113..4503bef99a 100644 --- a/app/src/features/surveys/observations/sampling-sites/edit/components/SurveySampleSiteEditForm.tsx +++ b/app/src/features/surveys/observations/sampling-sites/components/map/SurveySampleSiteEditForm.tsx @@ -2,13 +2,19 @@ import Box from '@mui/material/Box'; import CircularProgress from '@mui/material/CircularProgress'; import Typography from '@mui/material/Typography'; import { useFormikContext } from 'formik'; -import { IEditSamplingSiteRequest } from './SampleSiteEditForm'; +import { IGetSampleLocationDetails } from 'interfaces/useSamplingSiteApi.interface'; import SamplingSiteEditMapControl from './SamplingSiteEditMapControl'; +/** + * Returns a form for editing the location of a sample site + * + * @param props + * @returns + */ const SurveySamplingSiteEditForm = () => { - const formikProps = useFormikContext(); + const formikProps = useFormikContext(); - if (!formikProps.values.sampleSite.survey_sample_sites.length) { + if (!formikProps.values) { return ; } @@ -23,11 +29,7 @@ const SurveySamplingSiteEditForm = () => { }}> Shapefiles must be compressed into a single zip file. They can include one or more sampling site locations. - + ); }; diff --git a/app/src/features/surveys/observations/sampling-sites/components/SampleSiteFileUploadItemActionButton.tsx b/app/src/features/surveys/observations/sampling-sites/components/map/file-upload/SampleSiteFileUploadItemActionButton.tsx similarity index 100% rename from app/src/features/surveys/observations/sampling-sites/components/SampleSiteFileUploadItemActionButton.tsx rename to app/src/features/surveys/observations/sampling-sites/components/map/file-upload/SampleSiteFileUploadItemActionButton.tsx diff --git a/app/src/features/surveys/observations/sampling-sites/components/SampleSiteFileUploadItemProgressBar.tsx b/app/src/features/surveys/observations/sampling-sites/components/map/file-upload/SampleSiteFileUploadItemProgressBar.tsx similarity index 100% rename from app/src/features/surveys/observations/sampling-sites/components/SampleSiteFileUploadItemProgressBar.tsx rename to app/src/features/surveys/observations/sampling-sites/components/map/file-upload/SampleSiteFileUploadItemProgressBar.tsx diff --git a/app/src/features/surveys/observations/sampling-sites/components/SampleSiteFileUploadItemSubtext.tsx b/app/src/features/surveys/observations/sampling-sites/components/map/file-upload/SampleSiteFileUploadItemSubtext.tsx similarity index 100% rename from app/src/features/surveys/observations/sampling-sites/components/SampleSiteFileUploadItemSubtext.tsx rename to app/src/features/surveys/observations/sampling-sites/components/map/file-upload/SampleSiteFileUploadItemSubtext.tsx diff --git a/app/src/features/surveys/observations/sampling-sites/SamplingSitePage.tsx b/app/src/features/surveys/observations/sampling-sites/create/SamplingSitePage.tsx similarity index 59% rename from app/src/features/surveys/observations/sampling-sites/SamplingSitePage.tsx rename to app/src/features/surveys/observations/sampling-sites/create/SamplingSitePage.tsx index 89ae40e90a..ee0261fc1f 100644 --- a/app/src/features/surveys/observations/sampling-sites/SamplingSitePage.tsx +++ b/app/src/features/surveys/observations/sampling-sites/create/SamplingSitePage.tsx @@ -1,29 +1,20 @@ -import { LoadingButton } from '@mui/lab'; import Box from '@mui/material/Box'; -import Button from '@mui/material/Button'; import CircularProgress from '@mui/material/CircularProgress'; -import Container from '@mui/material/Container'; -import Divider from '@mui/material/Divider'; -import Paper from '@mui/material/Paper'; -import Stack from '@mui/material/Stack'; import { IErrorDialogProps } from 'components/dialog/ErrorDialog'; -import HorizontalSplitFormComponent from 'components/fields/HorizontalSplitFormComponent'; import { CreateSamplingSiteI18N } from 'constants/i18n'; -import { ISurveySampleMethodData, SamplingSiteMethodYupSchema } from 'features/surveys/components/MethodForm'; -import SamplingMethodForm from 'features/surveys/components/SamplingMethodForm'; -import SurveySamplingSiteImportForm from 'features/surveys/components/SurveySamplingSiteImportForm'; +import { SamplingSiteMethodYupSchema } from 'features/surveys/observations/sampling-sites/create/form/MethodForm'; import { Formik, FormikProps } from 'formik'; import { Feature } from 'geojson'; import History from 'history'; import { APIError } from 'hooks/api/useAxios'; import { useBiohubApi } from 'hooks/useBioHubApi'; import { useDialogContext, useSurveyContext } from 'hooks/useContext'; -import { IGetSurveyBlock, IGetSurveyStratum } from 'interfaces/useSurveyApi.interface'; +import { ICreateSamplingSiteRequest } from 'interfaces/useSamplingSiteApi.interface'; import { useRef, useState } from 'react'; import { Prompt, useHistory } from 'react-router'; import yup from 'utils/YupSchema'; -import SamplingSiteGroupingsForm from './components/SamplingSiteGroupingsForm'; -import SamplingSiteHeader from './SamplingSiteHeader'; +import SamplingSiteHeader from '../components/SamplingSiteHeader'; +import SampleSiteCreateForm from './form/SampleSiteCreateForm'; export interface ISurveySampleSite { name: string; @@ -31,14 +22,6 @@ export interface ISurveySampleSite { geojson: Feature; } -export interface ICreateSamplingSiteRequest { - survey_id: number; - survey_sample_sites: ISurveySampleSite[]; // extracted list from shape files - methods: ISurveySampleMethodData[]; - blocks: IGetSurveyBlock[]; - stratums: IGetSurveyStratum[]; -} - /** * Renders the body content of the Sampling Site page. * @@ -51,7 +34,7 @@ const SamplingSitePage = () => { const surveyContext = useSurveyContext(); const dialogContext = useDialogContext(); - const formikRef = useRef>(null); + const formikRef = useRef>(null); const [isSubmitting, setIsSubmitting] = useState(false); const [enableCancelCheck, setEnableCancelCheck] = useState(true); @@ -63,10 +46,14 @@ const SamplingSitePage = () => { const samplingSiteYupSchema = yup.object({ survey_sample_sites: yup .array( - yup.object({ name: yup.string().default(''), description: yup.string().default(''), geojson: yup.object({}) }) + yup.object({ + name: yup.string().default(''), + description: yup.string().default(''), + geojson: yup.object({}) + }) ) .min(1, 'At least one sampling site location is required'), - methods: yup + sample_methods: yup .array(yup.object().concat(SamplingSiteMethodYupSchema)) .min(1, 'At least one sampling method is required') }); @@ -154,7 +141,7 @@ const SamplingSitePage = () => { name: '', description: '', survey_sample_sites: [], - methods: [], + sample_methods: [], blocks: [], stratums: [] }} @@ -172,56 +159,7 @@ const SamplingSitePage = () => { breadcrumb="Add Sampling Sites" /> - - - - }> - - - - }> - - - - }> - - - - - { - formikRef.current?.submitForm(); - }}> - Save and Exit - - - - - - + diff --git a/app/src/features/surveys/components/CreateSamplingMethod.tsx b/app/src/features/surveys/observations/sampling-sites/create/form/CreateSamplingMethod.tsx similarity index 87% rename from app/src/features/surveys/components/CreateSamplingMethod.tsx rename to app/src/features/surveys/observations/sampling-sites/create/form/CreateSamplingMethod.tsx index 8fdb0480bc..519a0ddc23 100644 --- a/app/src/features/surveys/components/CreateSamplingMethod.tsx +++ b/app/src/features/surveys/observations/sampling-sites/create/form/CreateSamplingMethod.tsx @@ -11,7 +11,12 @@ interface ISamplingMethodProps { onClose: () => void; } -const CreateSamplingMethod: React.FC = (props) => { +/** + * Returns a form for creating a sampling method + * + * @returns + */ +const CreateSamplingMethod = (props: ISamplingMethodProps) => { const handleSubmit = (values: ISurveySampleMethodData) => { props.onSubmit(values); }; diff --git a/app/src/features/surveys/components/MethodForm.tsx b/app/src/features/surveys/observations/sampling-sites/create/form/MethodForm.tsx similarity index 86% rename from app/src/features/surveys/components/MethodForm.tsx rename to app/src/features/surveys/observations/sampling-sites/create/form/MethodForm.tsx index 9da7dac1f5..13bc1a7b00 100644 --- a/app/src/features/surveys/components/MethodForm.tsx +++ b/app/src/features/surveys/observations/sampling-sites/create/form/MethodForm.tsx @@ -32,7 +32,7 @@ export interface ISurveySampleMethodData { survey_sample_site_id: number | null; method_lookup_id: number | null; description: string; - periods: ISurveySampleMethodPeriodData[]; + sample_periods: ISurveySampleMethodPeriodData[]; method_response_metric_id: number | null; } @@ -51,7 +51,7 @@ export const SurveySampleMethodDataInitialValues = { survey_sample_site_id: null, method_lookup_id: null, description: '', - periods: [SurveySampleMethodPeriodArrayItemInitialValues], + sample_periods: [SurveySampleMethodPeriodArrayItemInitialValues], method_response_metric_id: '' as unknown as null }; @@ -62,7 +62,7 @@ export const SamplingSiteMethodYupSchema = yup.object({ .typeError('Response Metric is required') .required('Response Metric is required'), description: yup.string().max(250, 'Maximum 250 characters'), - periods: yup + sample_periods: yup .array( yup .object({ @@ -84,7 +84,7 @@ export const SamplingSiteMethodYupSchema = yup.object({ }), end_time: yup.string().nullable() }) - .test('checkDatesAreSameAndEndTimeIsAfterStart', 'Start and End dates must be different', function (value) { + .test('checkDatesAreSameAndEndTimeIsAfterStart', 'End date must be after start date', function (value) { const { start_date, end_date, start_time, end_time } = value; if (start_date === end_date && start_time && end_time) { @@ -170,12 +170,12 @@ const MethodForm = () => { ( - {errors.periods && typeof errors.periods === 'string' && ( + {errors.sample_periods && typeof errors.sample_periods === 'string' && ( - {String(errors.periods)} + {String(errors.sample_periods)} )} @@ -186,7 +186,7 @@ const MethodForm = () => { mt: -1, p: 0 }}> - {values.periods.map((period, index) => { + {values.sample_periods?.map((period, index) => { return ( { - {errors.periods && - typeof errors.periods !== 'string' && - errors.periods[index] && - typeof errors.periods[index] === 'string' && ( + {errors.sample_periods && + typeof errors.sample_periods !== 'string' && + errors.sample_periods[index] && + typeof errors.sample_periods[index] === 'string' && ( { mt: '3px', ml: '14px' }}> - {String(errors.periods[index])} + {String(errors.sample_periods[index])} )} @@ -248,25 +248,25 @@ const MethodForm = () => { - {errors.periods && - typeof errors.periods !== 'string' && - errors.periods[index] && - typeof errors.periods[index] === 'string' && ( + {errors.sample_periods && + typeof errors.sample_periods !== 'string' && + errors.sample_periods[index] && + typeof errors.sample_periods[index] === 'string' && ( { mt: '3px', ml: '14px' }}> - {String(errors.periods[index])} + {String(errors.sample_periods[index])} )} diff --git a/app/src/features/surveys/observations/sampling-sites/create/form/SampleSiteCreateForm.tsx b/app/src/features/surveys/observations/sampling-sites/create/form/SampleSiteCreateForm.tsx new file mode 100644 index 0000000000..09e0e9029f --- /dev/null +++ b/app/src/features/surveys/observations/sampling-sites/create/form/SampleSiteCreateForm.tsx @@ -0,0 +1,82 @@ +import LoadingButton from '@mui/lab/LoadingButton/LoadingButton'; +import Button from '@mui/material/Button'; +import Container from '@mui/material/Container'; +import Divider from '@mui/material/Divider'; +import Paper from '@mui/material/Paper'; +import Stack from '@mui/material/Stack'; +import HorizontalSplitFormComponent from 'components/fields/HorizontalSplitFormComponent'; +import SurveySamplingSiteImportForm from 'features/surveys/components/locations/SurveySamplingSiteImportForm'; +import SamplingMethodForm from 'features/surveys/observations/sampling-sites/create/form/SamplingMethodForm'; +import { useFormikContext } from 'formik'; +import { useSurveyContext } from 'hooks/useContext'; +import { ICreateSamplingSiteRequest } from 'interfaces/useSamplingSiteApi.interface'; +import { useHistory } from 'react-router'; +import SamplingSiteGroupingsForm from '../../components/SamplingSiteGroupingsForm'; + +interface ISampleSiteCreateFormProps { + isSubmitting: boolean; +} + +const SampleSiteCreateForm = (props: ISampleSiteCreateFormProps) => { + const { isSubmitting } = props; + + const history = useHistory(); + const { submitForm } = useFormikContext(); + + const surveyContext = useSurveyContext(); + + return ( + + + + }> + + + + }> + + + + }> + + + + + { + submitForm(); + }}> + Save and Exit + + + + + + + ); +}; + +export default SampleSiteCreateForm; diff --git a/app/src/features/surveys/components/SamplingMethodForm.tsx b/app/src/features/surveys/observations/sampling-sites/create/form/SamplingMethodForm.tsx similarity index 84% rename from app/src/features/surveys/components/SamplingMethodForm.tsx rename to app/src/features/surveys/observations/sampling-sites/create/form/SamplingMethodForm.tsx index 7bf949e947..9eca9aec1b 100644 --- a/app/src/features/surveys/components/SamplingMethodForm.tsx +++ b/app/src/features/surveys/observations/sampling-sites/create/form/SamplingMethodForm.tsx @@ -18,18 +18,18 @@ import MenuItem from '@mui/material/MenuItem'; import Stack from '@mui/material/Stack'; import Typography from '@mui/material/Typography'; import { CodesContext } from 'contexts/codesContext'; -import { ISurveySampleMethodData } from 'features/surveys/components/MethodForm'; +import { ISurveySampleMethodData } from 'features/surveys/observations/sampling-sites/create/form/MethodForm'; import { useFormikContext } from 'formik'; +import { ICreateSamplingSiteRequest } from 'interfaces/useSamplingSiteApi.interface'; import { useContext, useEffect, useState } from 'react'; import { TransitionGroup } from 'react-transition-group'; import { getCodesName } from 'utils/Utils'; -import SamplingSiteListPeriod from '../observations/sampling-sites/list/SamplingSiteListPeriod'; -import { ICreateSamplingSiteRequest } from '../observations/sampling-sites/SamplingSitePage'; +import EditSamplingMethod from '../../edit/form/EditSamplingMethod'; +import SamplingSiteListPeriod from '../../list/SamplingSiteListPeriod'; import CreateSamplingMethod from './CreateSamplingMethod'; -import EditSamplingMethod from './EditSamplingMethod'; /** - * Renders a form for creating sampling methods + * Returns a form for creating and editing a sampling method * * @returns */ @@ -47,14 +47,14 @@ const SamplingMethodForm = () => { const handleMenuClick = (event: React.MouseEvent, index: number) => { setAnchorEl(event.currentTarget); - setEditData({ data: values.methods[index], index }); + setEditData({ data: values.sample_methods[index], index }); }; const handleDelete = () => { if (editData) { - const data = values.methods; + const data = values.sample_methods; data.splice(editData.index, 1); - setFieldValue('methods', data); + setFieldValue('sample_methods', data); } setAnchorEl(null); }; @@ -65,8 +65,8 @@ const SamplingMethodForm = () => { { - setFieldValue(`methods[${values.methods.length}]`, data); - validateField('methods'); + setFieldValue(`sample_methods[${values.sample_methods.length}]`, data); + validateField('sample_methods'); setAnchorEl(null); setIsCreateModalOpen(false); }} @@ -77,19 +77,22 @@ const SamplingMethodForm = () => { /> {/* EDIT SAMPLE METHOD DIALOG */} - { - setFieldValue(`methods[${editData?.index}]`, data); - setAnchorEl(null); - setIsEditModalOpen(false); - }} - onClose={() => { - setAnchorEl(null); - setIsEditModalOpen(false); - }} - /> + {editData?.data && ( + { + setFieldValue(`sample_methods[${editData?.index}]`, data); + validateField('sample_methods'); + setAnchorEl(null); + setIsEditModalOpen(false); + }} + onClose={() => { + setAnchorEl(null); + setIsEditModalOpen(false); + }} + /> + )} { }}> Methods added here will be applied to ALL sampling locations. These can be modified later if required. - {errors.methods && !Array.isArray(errors.methods) && ( + {errors.sample_methods && !Array.isArray(errors.sample_methods) && ( Missing sampling method - {errors.methods} + {errors.sample_methods} )} - {values.methods.map((item, index) => ( - + {values.sample_methods.map((item, index) => ( + { - + diff --git a/app/src/features/surveys/observations/sampling-sites/edit/SamplingSiteEditPage.tsx b/app/src/features/surveys/observations/sampling-sites/edit/SamplingSiteEditPage.tsx index 6e9e5e7159..9ea387baa3 100644 --- a/app/src/features/surveys/observations/sampling-sites/edit/SamplingSiteEditPage.tsx +++ b/app/src/features/surveys/observations/sampling-sites/edit/SamplingSiteEditPage.tsx @@ -10,10 +10,12 @@ import { Feature } from 'geojson'; import History from 'history'; import { APIError } from 'hooks/api/useAxios'; import { useBiohubApi } from 'hooks/useBioHubApi'; +import useDataLoader from 'hooks/useDataLoader'; +import { IEditSamplingSiteRequest, IGetSampleLocationDetailsForUpdate } from 'interfaces/useSamplingSiteApi.interface'; import { useContext, useEffect, useRef, useState } from 'react'; import { Prompt, useHistory, useParams } from 'react-router'; -import SamplingSiteHeader from '../SamplingSiteHeader'; -import SampleSiteEditForm, { IEditSamplingSiteRequest, samplingSiteYupSchema } from './components/SampleSiteEditForm'; +import SamplingSiteHeader from '../components/SamplingSiteHeader'; +import SampleSiteEditForm, { samplingSiteYupSchema } from './form/SampleSiteEditForm'; /** * Page to edit a sampling site. @@ -26,61 +28,33 @@ const SamplingSiteEditPage = () => { const urlParams: Record = useParams(); const surveySampleSiteId = Number(urlParams['survey_sample_site_id']); + const [initialFormValues, setInitialFormValues] = useState(); + const surveyContext = useContext(SurveyContext); const dialogContext = useContext(DialogContext); - const formikRef = useRef>(null); + const formikRef = useRef>(null); const [isSubmitting, setIsSubmitting] = useState(false); const [enableCancelCheck, setEnableCancelCheck] = useState(true); - const [initialFormData, setInitialFormData] = useState({ - sampleSite: { - survey_id: surveyContext.surveyId, - name: '', - description: '', - survey_sample_sites: [], - methods: [], - blocks: [], - stratums: [] - } - }); - // Initial load of the sampling site data + const projectId = surveyContext.projectId; + const surveyId = surveyContext.surveyId; + + const samplingSiteDataLoader = useDataLoader(() => + biohubApi.samplingSite.getSampleSiteById(projectId, surveyId, surveySampleSiteId) + ); + + if (!samplingSiteDataLoader.data) { + samplingSiteDataLoader.load(); + } + useEffect(() => { - if (surveyContext.sampleSiteDataLoader.data) { - const data = surveyContext.sampleSiteDataLoader.data.sampleSites.find( - (sampleSite) => sampleSite.survey_sample_site_id === surveySampleSiteId - ); - - if (data !== undefined) { - const formInitialValues: IEditSamplingSiteRequest = { - sampleSite: { - name: data.name, - description: data.description, - survey_id: data.survey_id, - survey_sample_sites: [data.geojson as unknown as Feature], - methods: - data.sample_methods?.map((item) => { - return { - survey_sample_method_id: item.survey_sample_method_id, - survey_sample_site_id: item.survey_sample_site_id, - method_lookup_id: item.method_lookup_id, - method_response_metric_id: item.method_response_metric_id, - description: item.description, - periods: item.sample_periods || [] - }; - }) || [], - blocks: data.sample_blocks || [], - stratums: data.sample_stratums || [] - } - }; - setInitialFormData(formInitialValues); - formikRef.current?.setValues(formInitialValues); - } + if (samplingSiteDataLoader.data) { + setInitialFormValues(samplingSiteDataLoader.data); + formikRef.current?.setValues(samplingSiteDataLoader.data); } - - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [surveyContext.sampleSiteDataLoader]); + }, [samplingSiteDataLoader.data]); const showCreateErrorDialog = (textDialogProps?: Partial) => { dialogContext.setErrorDialog({ @@ -97,21 +71,21 @@ const SamplingSiteEditPage = () => { }); }; - const handleSubmit = async (values: IEditSamplingSiteRequest) => { + const handleSubmit = async (values: IGetSampleLocationDetailsForUpdate) => { try { setIsSubmitting(true); // create edit request const editSampleSite: IEditSamplingSiteRequest = { sampleSite: { - name: values.sampleSite.name, - description: values.sampleSite.description, - survey_id: values.sampleSite.survey_id, - survey_sample_sites: values.sampleSite.survey_sample_sites as unknown as Feature[], - geojson: values.sampleSite.survey_sample_sites[0], - methods: values.sampleSite.methods, - blocks: values.sampleSite.blocks, - stratums: values.sampleSite.stratums + name: values.name, + description: values.description, + survey_id: values.survey_id, + survey_sample_sites: [values.geojson as Feature], + geojson: values.geojson, + methods: values.sample_methods, + blocks: values.blocks.map((block) => ({ survey_block_id: block.survey_block_id })), + stratums: values.stratums.map((stratum) => ({ survey_stratum_id: stratum.survey_stratum_id })) } }; @@ -192,17 +166,16 @@ const SamplingSiteEditPage = () => { return true; }; - if (!surveyContext.surveyDataLoader.data || !surveyContext.sampleSiteDataLoader.data) { + if (!surveyContext.surveyDataLoader.data || !initialFormValues) { return ; } return ( <> - { title="Edit Sampling Site" breadcrumb="Edit Sampling Site" /> - diff --git a/app/src/features/surveys/observations/sampling-sites/edit/components/SamplingBlockEditForm.tsx b/app/src/features/surveys/observations/sampling-sites/edit/components/SamplingBlockEditForm.tsx deleted file mode 100644 index b02a74ac97..0000000000 --- a/app/src/features/surveys/observations/sampling-sites/edit/components/SamplingBlockEditForm.tsx +++ /dev/null @@ -1,150 +0,0 @@ -import { mdiClose, mdiMagnify } from '@mdi/js'; -import Icon from '@mdi/react'; -import Autocomplete, { createFilterOptions } from '@mui/material/Autocomplete'; -import Box from '@mui/material/Box'; -import Card from '@mui/material/Card'; -import CardHeader from '@mui/material/CardHeader'; -import Collapse from '@mui/material/Collapse'; -import { grey } from '@mui/material/colors'; -import IconButton from '@mui/material/IconButton'; -import TextField from '@mui/material/TextField'; -import Typography from '@mui/material/Typography'; -import { SurveyContext } from 'contexts/surveyContext'; -import { useFormikContext } from 'formik'; -import { IGetSampleBlockDetails, IGetSurveyBlock } from 'interfaces/useSurveyApi.interface'; -import { useContext, useState } from 'react'; -import { TransitionGroup } from 'react-transition-group'; -import BlockStratumCard from './BlockStratumCard'; -import { IEditSamplingSiteRequest } from './SampleSiteEditForm'; - -const SamplingBlockEditForm = () => { - const { values, setFieldValue } = useFormikContext(); - const surveyContext = useContext(SurveyContext); - - const options = surveyContext.surveyDataLoader?.data?.surveyData?.blocks || []; - - const [searchText, setSearchText] = useState(''); - - const handleAddBlock = (block: IGetSurveyBlock) => { - setFieldValue(`sampleSite.blocks[${values.sampleSite.blocks.length}]`, block); - }; - - const handleRemoveItem = (block: IGetSurveyBlock | IGetSampleBlockDetails) => { - setFieldValue( - `sampleSite.blocks`, - values.sampleSite.blocks.filter((existing) => existing.survey_block_id !== block.survey_block_id) - ); - }; - - return ( - <> - Assign to Block - - All sampling sites being imported together will be assigned to the selected groups - - { - const searchFilter = createFilterOptions({ ignoreCase: true }); - const unselectedOptions = options.filter((item) => - values.sampleSite.blocks.every((existing) => existing.survey_block_id !== item.survey_block_id) - ); - return searchFilter(unselectedOptions, state); - }} - getOptionLabel={(option) => option.name} - selectOnFocus - clearOnEscape - inputValue={searchText} - clearOnBlur={false} - value={null} - onInputChange={(_, value, reason) => { - if (reason === 'reset') { - setSearchText(''); - } else { - setSearchText(value); - } - }} - onChange={(_, option) => { - if (option) { - handleAddBlock(option); - setSearchText(''); - } - }} - onClose={() => { - setSearchText(''); - }} - renderInput={(params) => ( - - - - ) - }} - /> - )} - renderOption={(renderProps, renderOption) => { - return ( - - - - ); - }} - /> - - {values.sampleSite.blocks.map((item, index) => { - return ( - - - handleRemoveItem(item)} aria-label="settings"> - - - } - title={item.name} - subheader={item.description} - /> - - - ); - })} - - - ); -}; - -export default SamplingBlockEditForm; diff --git a/app/src/features/surveys/observations/sampling-sites/edit/components/SamplingStratumEditForm.tsx b/app/src/features/surveys/observations/sampling-sites/edit/components/SamplingStratumEditForm.tsx deleted file mode 100644 index 8987059acd..0000000000 --- a/app/src/features/surveys/observations/sampling-sites/edit/components/SamplingStratumEditForm.tsx +++ /dev/null @@ -1,149 +0,0 @@ -import { mdiClose, mdiMagnify } from '@mdi/js'; -import Icon from '@mdi/react'; -import Autocomplete, { createFilterOptions } from '@mui/material/Autocomplete'; -import Box from '@mui/material/Box'; -import Card from '@mui/material/Card'; -import CardHeader from '@mui/material/CardHeader'; -import Collapse from '@mui/material/Collapse'; -import { grey } from '@mui/material/colors'; -import IconButton from '@mui/material/IconButton'; -import TextField from '@mui/material/TextField'; -import Typography from '@mui/material/Typography'; -import { SurveyContext } from 'contexts/surveyContext'; -import { useFormikContext } from 'formik'; -import { IGetSampleStratumDetails, IGetSurveyStratum } from 'interfaces/useSurveyApi.interface'; -import { useContext, useState } from 'react'; -import { TransitionGroup } from 'react-transition-group'; -import BlockStratumCard from './BlockStratumCard'; -import { IEditSamplingSiteRequest } from './SampleSiteEditForm'; - -const SamplingStratumEditForm = () => { - const { values, setFieldValue } = useFormikContext(); - const surveyContext = useContext(SurveyContext); - - const options = surveyContext.surveyDataLoader?.data?.surveyData?.site_selection?.stratums || []; - - const [searchText, setSearchText] = useState(''); - - const handleAddStratum = (stratum: IGetSurveyStratum) => { - setFieldValue(`sampleSite.stratums[${values.sampleSite.stratums.length}]`, stratum); - }; - const handleRemoveItem = (stratum: IGetSurveyStratum | IGetSampleStratumDetails) => { - setFieldValue( - `sampleSite.stratums`, - values.sampleSite.stratums.filter((existing) => existing.survey_stratum_id !== stratum.survey_stratum_id) - ); - }; - - return ( - <> - Assign to Stratum - - All sampling sites being imported together will be assigned to the selected groups - - { - const searchFilter = createFilterOptions({ ignoreCase: true }); - const unselectedOptions = options.filter((item) => - values.sampleSite.stratums.every((existing) => existing.survey_stratum_id !== item.survey_stratum_id) - ); - return searchFilter(unselectedOptions, state); - }} - getOptionLabel={(option) => option.name} - selectOnFocus - clearOnEscape - inputValue={searchText} - clearOnBlur={false} - value={null} - onInputChange={(_, value, reason) => { - if (reason === 'reset') { - setSearchText(''); - } else { - setSearchText(value); - } - }} - onChange={(_, option) => { - if (option) { - handleAddStratum(option); - setSearchText(''); - } - }} - onClose={() => { - setSearchText(''); - }} - renderInput={(params) => ( - - - - ) - }} - /> - )} - renderOption={(renderProps, renderOption) => { - return ( - - - - ); - }} - /> - - {values.sampleSite.stratums.map((item, index) => { - return ( - - - handleRemoveItem(item)} aria-label="settings"> - - - } - title={item.name} - subheader={item.description} - /> - - - ); - })} - - - ); -}; - -export default SamplingStratumEditForm; diff --git a/app/src/features/surveys/components/EditSamplingMethod.tsx b/app/src/features/surveys/observations/sampling-sites/edit/form/EditSamplingMethod.tsx similarity index 52% rename from app/src/features/surveys/components/EditSamplingMethod.tsx rename to app/src/features/surveys/observations/sampling-sites/edit/form/EditSamplingMethod.tsx index d9566a119e..db09b4aec9 100644 --- a/app/src/features/surveys/components/EditSamplingMethod.tsx +++ b/app/src/features/surveys/observations/sampling-sites/edit/form/EditSamplingMethod.tsx @@ -1,18 +1,21 @@ import EditDialog from 'components/dialog/EditDialog'; -import MethodForm, { - ISurveySampleMethodData, - SamplingSiteMethodYupSchema, - SurveySampleMethodDataInitialValues -} from './MethodForm'; +import { IGetSampleMethodRecord } from 'interfaces/useSamplingSiteApi.interface'; +import MethodForm, { ISurveySampleMethodData, SamplingSiteMethodYupSchema } from '../../create/form/MethodForm'; interface IEditSamplingMethodProps { open: boolean; - initialData?: ISurveySampleMethodData; - onSubmit: (data: ISurveySampleMethodData, index?: number) => void; + initialData: IGetSampleMethodRecord | ISurveySampleMethodData; + onSubmit: (data: IGetSampleMethodRecord | ISurveySampleMethodData, index?: number) => void; onClose: () => void; } -const EditSamplingMethod: React.FC = (props) => { +/** + * Returns a form for editing a sampling method + * + * @param props + * @returns + */ +const EditSamplingMethod = (props: IEditSamplingMethodProps) => { const { open, initialData, onSubmit, onClose } = props; return ( @@ -22,7 +25,7 @@ const EditSamplingMethod: React.FC = (props) => { dialogLoading={false} component={{ element: , - initialValues: initialData || SurveySampleMethodDataInitialValues, + initialValues: initialData, validationSchema: SamplingSiteMethodYupSchema }} dialogSaveButtonLabel="Update" diff --git a/app/src/features/surveys/observations/sampling-sites/edit/components/SampleMethodEditForm.tsx b/app/src/features/surveys/observations/sampling-sites/edit/form/SampleMethodEditForm.tsx similarity index 84% rename from app/src/features/surveys/observations/sampling-sites/edit/components/SampleMethodEditForm.tsx rename to app/src/features/surveys/observations/sampling-sites/edit/form/SampleMethodEditForm.tsx index faae524f33..f730c72e75 100644 --- a/app/src/features/surveys/observations/sampling-sites/edit/components/SampleMethodEditForm.tsx +++ b/app/src/features/surveys/observations/sampling-sites/edit/form/SampleMethodEditForm.tsx @@ -18,15 +18,15 @@ import MenuItem from '@mui/material/MenuItem'; import Stack from '@mui/material/Stack'; import Typography from '@mui/material/Typography'; import { CodesContext } from 'contexts/codesContext'; -import CreateSamplingMethod from 'features/surveys/components/CreateSamplingMethod'; -import EditSamplingMethod from 'features/surveys/components/EditSamplingMethod'; -import { ISurveySampleMethodData } from 'features/surveys/components/MethodForm'; +import CreateSamplingMethod from 'features/surveys/observations/sampling-sites/create/form/CreateSamplingMethod'; +import EditSamplingMethod from 'features/surveys/observations/sampling-sites/edit/form/EditSamplingMethod'; import { useFormikContext } from 'formik'; +import { IGetSampleLocationDetailsForUpdate } from 'interfaces/useSamplingSiteApi.interface'; import { useContext, useEffect, useState } from 'react'; import { TransitionGroup } from 'react-transition-group'; import { getCodesName } from 'utils/Utils'; +import { ISurveySampleMethodData } from '../../create/form/MethodForm'; import SamplingSiteListPeriod from '../../list/SamplingSiteListPeriod'; -import { IEditSamplingSiteRequest } from './SampleSiteEditForm'; export interface SampleMethodEditFormProps { name: string; @@ -41,7 +41,7 @@ export interface SampleMethodEditFormProps { const SampleMethodEditForm = (props: SampleMethodEditFormProps) => { const { name } = props; - const { values, errors, setFieldValue, validateField } = useFormikContext(); + const { values, errors, setFieldValue } = useFormikContext(); const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); const [isEditModalOpen, setIsEditModalOpen] = useState(false); const [anchorEl, setAnchorEl] = useState(null); @@ -54,14 +54,18 @@ const SampleMethodEditForm = (props: SampleMethodEditFormProps) => { const handleMenuClick = (event: React.MouseEvent, index: number) => { setAnchorEl(event.currentTarget); - setEditData({ data: values.sampleSite.methods[index], index }); + + setEditData({ + data: values.sample_methods[index], + index + }); }; const handleDelete = () => { if (editData) { - const methods = values.sampleSite.methods; + const methods = values.sample_methods; methods.splice(editData.index, 1); - setFieldValue('methods', methods); + setFieldValue(name, methods); } setAnchorEl(null); }; @@ -72,8 +76,7 @@ const SampleMethodEditForm = (props: SampleMethodEditFormProps) => { { - setFieldValue(`${name}[${values.sampleSite.methods.length}]`, data); - validateField(`${name}`); + setFieldValue(`${name}[${values.sample_methods.length}]`, data); setAnchorEl(null); setIsCreateModalOpen(false); }} @@ -84,19 +87,21 @@ const SampleMethodEditForm = (props: SampleMethodEditFormProps) => { /> {/* EDIT SAMPLE METHOD DIALOG */} - { - setFieldValue(`${name}[${editData?.index}]`, data); - setAnchorEl(null); - setIsEditModalOpen(false); - }} - onClose={() => { - setAnchorEl(null); - setIsEditModalOpen(false); - }} - /> + {editData?.data && ( + { + setFieldValue(`${name}[${editData?.index}]`, data); + setAnchorEl(null); + setIsEditModalOpen(false); + }} + onClose={() => { + setAnchorEl(null); + setIsEditModalOpen(false); + }} + /> + )} { }}> Methods added here will be applied to ALL sampling locations. These can be modified later if required. - {errors?.sampleSite && errors.sampleSite.methods && !Array.isArray(errors.sampleSite.methods) && ( + {errors.sample_methods && !Array.isArray(errors.sample_methods) && ( Missing sampling method - {errors.sampleSite.methods} + {errors.sample_methods} )} - {values.sampleSite.methods.map((item, index) => ( - + {values.sample_methods.map((item, index) => ( + { - + diff --git a/app/src/features/surveys/observations/sampling-sites/edit/components/SampleSiteEditForm.tsx b/app/src/features/surveys/observations/sampling-sites/edit/form/SampleSiteEditForm.tsx similarity index 66% rename from app/src/features/surveys/observations/sampling-sites/edit/components/SampleSiteEditForm.tsx rename to app/src/features/surveys/observations/sampling-sites/edit/form/SampleSiteEditForm.tsx index bcc2bdb302..94dd1851d6 100644 --- a/app/src/features/surveys/observations/sampling-sites/edit/components/SampleSiteEditForm.tsx +++ b/app/src/features/surveys/observations/sampling-sites/edit/form/SampleSiteEditForm.tsx @@ -6,52 +6,42 @@ import Paper from '@mui/material/Paper'; import Stack from '@mui/material/Stack'; import HorizontalSplitFormComponent from 'components/fields/HorizontalSplitFormComponent'; import { SurveyContext } from 'contexts/surveyContext'; -import { ISurveySampleMethodData, SamplingSiteMethodYupSchema } from 'features/surveys/components/MethodForm'; +import { SamplingSiteMethodYupSchema } from 'features/surveys/observations/sampling-sites/create/form/MethodForm'; import { useFormikContext } from 'formik'; -import { Feature } from 'geojson'; -import { IGetSampleBlockDetails, IGetSampleStratumDetails } from 'interfaces/useSurveyApi.interface'; +import { IGetSampleLocationDetailsForUpdate } from 'interfaces/useSamplingSiteApi.interface'; import { useContext } from 'react'; import { Link as RouterLink } from 'react-router-dom'; import yup from 'utils/YupSchema'; +import SurveySamplingSiteEditForm from '../../components/map/SurveySampleSiteEditForm'; import SamplingSiteGroupingsForm from '../../components/SamplingSiteGroupingsForm'; import SampleMethodEditForm from './SampleMethodEditForm'; import SampleSiteGeneralInformationForm from './SampleSiteGeneralInformationForm'; -import SurveySamplingSiteEditForm from './SurveySampleSiteEditForm'; - -export interface IEditSamplingSiteRequest { - sampleSite: { - name: string; - description: string; - survey_id: number; - survey_sample_sites: Feature[]; // extracted list from shape files (used for formik loading) - geojson?: Feature; // geojson object from map (used for sending to api) - methods: ISurveySampleMethodData[]; - blocks: IGetSampleBlockDetails[]; - stratums: IGetSampleStratumDetails[]; - }; -} export interface ISampleSiteEditFormProps { isSubmitting: boolean; } export const samplingSiteYupSchema = yup.object({ - sampleSite: yup.object({ - name: yup.string().default('').max(50, 'Maximum 50 characters.'), - description: yup.string().default('').nullable(), - survey_sample_sites: yup - .array(yup.object()) - .min(1, 'At least one sampling site location is required') - .max(1, 'Only one location is permitted per sampling site'), - methods: yup - .array(yup.object().concat(SamplingSiteMethodYupSchema)) - .min(1, 'At least one sampling method is required') - }) + name: yup.string().default('').min(1, 'Minimum 1 character.').max(50, 'Maximum 50 characters.'), + description: yup.string().default('').nullable(), + survey_sample_sites: yup + .array(yup.object()) + .min(1, 'At least one sampling site location is required') + .max(1, 'Only one location is permitted per sampling site'), + sample_methods: yup + .array(yup.object().concat(SamplingSiteMethodYupSchema)) + .min(1, 'At least one sampling method is required') }); +/** + * Returns a form for editing a sampling site + * + * @param props + * @returns + */ const SampleSiteEditForm = (props: ISampleSiteEditFormProps) => { const surveyContext = useContext(SurveyContext); - const { submitForm } = useFormikContext(); + const { submitForm } = useFormikContext(); return ( @@ -74,7 +64,7 @@ const SampleSiteEditForm = (props: ISampleSiteEditFormProps) => { }> + component={}> @@ -91,7 +81,9 @@ const SampleSiteEditForm = (props: ISampleSiteEditFormProps) => { variant="contained" color="primary" loading={props.isSubmitting} - onClick={() => submitForm()}> + onClick={() => { + submitForm(); + }}> Save and Exit - + { - {hideableColumns.length > 0 && ( - <> - setColumnVisibilityMenuAnchorEl(event.currentTarget)} - disabled={telemetryTableContext.isSaving}> - - - - toggleShowHideAll()}> - - 0 && hiddenFields.length < hideableColumns.length} - checked={hiddenFields.length === 0} - /> - - Show/Hide All - - - - {hideableColumns.map((column) => { - return ( - toggleColumnVisibility(column.field)}> - - - - {column.headerName} - - ); - })} - - - - )} + setColumnVisibilityMenuAnchorEl(event.currentTarget)} + disabled={telemetryTableContext.isSaving}> + + + + telemetryTableContext.toggleColumnsVisibility()}> + + 0 && + telemetryTableContext.hiddenColumns.length < + telemetryTableContext.getColumns({ hideable: true }).length + } + checked={telemetryTableContext.hiddenColumns.length === 0} + /> + + Show/Hide All + + + + {telemetryTableContext.getColumns({ hideable: true }).map((column) => { + return ( + telemetryTableContext.toggleColumnsVisibility({ columns: [column.field] })}> + + + + {column.headerName} + + ); + })} + + ) => { @@ -337,7 +269,10 @@ const ManualTelemetryTableContainer = () => { - + diff --git a/app/src/features/surveys/telemetry/telemetry-table/utils/GridColumnDefinitions.tsx b/app/src/features/surveys/telemetry/telemetry-table/utils/GridColumnDefinitions.tsx new file mode 100644 index 0000000000..288ed17557 --- /dev/null +++ b/app/src/features/surveys/telemetry/telemetry-table/utils/GridColumnDefinitions.tsx @@ -0,0 +1,67 @@ +import { GridCellParams, GridColDef } from '@mui/x-data-grid'; +import AutocompleteDataGridEditCell from 'components/data-grid/autocomplete/AutocompleteDataGridEditCell'; +import AutocompleteDataGridViewCell from 'components/data-grid/autocomplete/AutocompleteDataGridViewCell'; +import { IManualTelemetryTableRow } from 'contexts/telemetryTableContext'; +import { capitalize } from 'lodash-es'; +import { ICritterDeployment } from '../../ManualTelemetryList'; + +export const TelemetryTypeColDef = (): GridColDef => { + return { + field: 'telemetry_type', + headerName: 'Vendor', + editable: false, + hideable: true, + minWidth: 120, + disableColumnMenu: true, + headerAlign: 'left', + align: 'left', + type: 'string', + valueGetter: (params) => capitalize(params.value) + }; +}; + +export const DeploymentColDef = (props: { + critterDeployments: ICritterDeployment[]; + hasError: (params: GridCellParams) => boolean; +}): GridColDef => { + return { + field: 'deployment_id', + headerName: 'Deployment', + editable: true, + hideable: true, + minWidth: 120, + disableColumnMenu: true, + headerAlign: 'left', + align: 'left', + type: 'string', + renderCell: (params) => { + const error = props.hasError(params); + return ( + + dataGridProps={params} + options={props.critterDeployments.map((item) => { + return { + label: `${item.critter.animal_id}: ${item.deployment.device_id}`, + value: item.deployment.deployment_id + }; + })} + error={error} + /> + ); + }, + renderEditCell: (params) => { + const error = props.hasError(params); + + return ( + + dataGridProps={params} + options={props.critterDeployments.map((item) => ({ + label: `${item.critter.animal_id}: ${item.deployment.device_id}`, + value: item.deployment.deployment_id + }))} + error={error} + /> + ); + } + }; +}; diff --git a/app/src/features/surveys/view/SurveyDetails.test.tsx b/app/src/features/surveys/view/SurveyDetails.test.tsx index 33af34c0b8..a3d52d50af 100644 --- a/app/src/features/surveys/view/SurveyDetails.test.tsx +++ b/app/src/features/surveys/view/SurveyDetails.test.tsx @@ -62,6 +62,7 @@ describe('SurveyDetails', () => { value={{ projectId: 1, surveyId: 1, + critterDeployments: [], surveyDataLoader: mockSurveyDataLoader, artifactDataLoader: mockArtifactDataLoader, sampleSiteDataLoader: mockSampleSiteDataLoader, diff --git a/app/src/features/surveys/view/SurveyHeader.test.tsx b/app/src/features/surveys/view/SurveyHeader.test.tsx index 51212583d1..b54f4d84b6 100644 --- a/app/src/features/surveys/view/SurveyHeader.test.tsx +++ b/app/src/features/surveys/view/SurveyHeader.test.tsx @@ -43,6 +43,7 @@ const mockSurveyContext: ISurveyContext = { deploymentDataLoader: { data: null } as DataLoader, + critterDeployments: [], surveyId: 1, projectId: 1 }; diff --git a/app/src/features/surveys/view/SurveyMap.tsx b/app/src/features/surveys/view/SurveyMap.tsx index 8594b35fdd..31b388f074 100644 --- a/app/src/features/surveys/view/SurveyMap.tsx +++ b/app/src/features/surveys/view/SurveyMap.tsx @@ -28,6 +28,7 @@ export interface ISurveyMapSupplementaryLayer { layerColors?: { color: string; fillColor: string; + opacity?: number; }; /** * The array of map points diff --git a/app/src/features/surveys/view/components/SurveyGeneralInformation.test.tsx b/app/src/features/surveys/view/components/SurveyGeneralInformation.test.tsx index 36448fe376..afe0e8ef1f 100644 --- a/app/src/features/surveys/view/components/SurveyGeneralInformation.test.tsx +++ b/app/src/features/surveys/view/components/SurveyGeneralInformation.test.tsx @@ -36,7 +36,8 @@ describe('SurveyGeneralInformation', () => { artifactDataLoader: mockArtifactDataLoader, sampleSiteDataLoader: mockSampleSiteDataLoader, critterDataLoader: mockCritterDataLoader, - deploymentDataLoader: mockDeploymentDataLoader + deploymentDataLoader: mockDeploymentDataLoader, + critterDeployments: [] }}> @@ -74,7 +75,8 @@ describe('SurveyGeneralInformation', () => { artifactDataLoader: mockArtifactDataLoader, sampleSiteDataLoader: mockSampleSiteDataLoader, critterDataLoader: mockCritterDataLoader, - deploymentDataLoader: mockDeploymentDataLoader + deploymentDataLoader: mockDeploymentDataLoader, + critterDeployments: [] }}> @@ -102,7 +104,8 @@ describe('SurveyGeneralInformation', () => { artifactDataLoader: mockArtifactDataLoader, sampleSiteDataLoader: mockSampleSiteDataLoader, critterDataLoader: mockCritterDataLoader, - deploymentDataLoader: mockDeploymentDataLoader + deploymentDataLoader: mockDeploymentDataLoader, + critterDeployments: [] }}> diff --git a/app/src/features/surveys/view/components/SurveyGeneralInformation.tsx b/app/src/features/surveys/view/components/SurveyGeneralInformation.tsx index bcdccd51b6..36066eecaf 100644 --- a/app/src/features/surveys/view/components/SurveyGeneralInformation.tsx +++ b/app/src/features/surveys/view/components/SurveyGeneralInformation.tsx @@ -114,7 +114,7 @@ const SurveyGeneralInformation = () => { ); })} {species.ancillary_species?.length <= 0 && ( - No secondary species of interest + No secondary species of interest )} diff --git a/app/src/features/surveys/view/components/SurveyProprietaryData.test.tsx b/app/src/features/surveys/view/components/SurveyProprietaryData.test.tsx index 4975824121..73b221c4ba 100644 --- a/app/src/features/surveys/view/components/SurveyProprietaryData.test.tsx +++ b/app/src/features/surveys/view/components/SurveyProprietaryData.test.tsx @@ -22,6 +22,7 @@ describe('SurveyProprietaryData', () => { value={{ projectId: 1, surveyId: 1, + critterDeployments: [], surveyDataLoader: mockSurveyDataLoader, artifactDataLoader: mockArtifactDataLoader, sampleSiteDataLoader: mockSampleSiteDataLoader, @@ -51,6 +52,7 @@ describe('SurveyProprietaryData', () => { value={{ projectId: 1, surveyId: 1, + critterDeployments: [], surveyDataLoader: mockSurveyDataLoader, artifactDataLoader: mockArtifactDataLoader, sampleSiteDataLoader: mockSampleSiteDataLoader, @@ -78,6 +80,7 @@ describe('SurveyProprietaryData', () => { value={{ projectId: 1, surveyId: 1, + critterDeployments: [], surveyDataLoader: mockSurveyDataLoader, artifactDataLoader: mockArtifactDataLoader, sampleSiteDataLoader: mockSampleSiteDataLoader, diff --git a/app/src/features/surveys/view/components/SurveyPurposeAndMethodologyData.test.tsx b/app/src/features/surveys/view/components/SurveyPurposeAndMethodologyData.test.tsx index 58a923a01e..5df5d153bf 100644 --- a/app/src/features/surveys/view/components/SurveyPurposeAndMethodologyData.test.tsx +++ b/app/src/features/surveys/view/components/SurveyPurposeAndMethodologyData.test.tsx @@ -31,6 +31,7 @@ describe('SurveyPurposeAndMethodologyData', () => { value={{ projectId: 1, surveyId: 1, + critterDeployments: [], surveyDataLoader: mockSurveyDataLoader, artifactDataLoader: mockArtifactDataLoader, sampleSiteDataLoader: mockSampleSiteDataLoader, @@ -80,6 +81,7 @@ describe('SurveyPurposeAndMethodologyData', () => { value={{ projectId: 1, surveyId: 1, + critterDeployments: [], surveyDataLoader: mockSurveyDataLoader, artifactDataLoader: mockArtifactDataLoader, sampleSiteDataLoader: mockSampleSiteDataLoader, diff --git a/app/src/features/surveys/view/components/SurveyStudyArea.test.tsx b/app/src/features/surveys/view/components/SurveyStudyArea.test.tsx index 6d75f079e8..b702810c84 100644 --- a/app/src/features/surveys/view/components/SurveyStudyArea.test.tsx +++ b/app/src/features/surveys/view/components/SurveyStudyArea.test.tsx @@ -51,6 +51,7 @@ describe.skip('SurveyStudyArea', () => { value={{ projectId: 1, surveyId: 1, + critterDeployments: [], surveyDataLoader: mockSurveyDataLoader, artifactDataLoader: mockArtifactDataLoader, sampleSiteDataLoader: mockSampleSiteDataLoader, @@ -87,6 +88,7 @@ describe.skip('SurveyStudyArea', () => { value={{ projectId: 1, surveyId: 1, + critterDeployments: [], surveyDataLoader: mockSurveyDataLoader, artifactDataLoader: mockArtifactDataLoader, sampleSiteDataLoader: mockSampleSiteDataLoader, @@ -115,6 +117,7 @@ describe.skip('SurveyStudyArea', () => { value={{ projectId: 1, surveyId: 1, + critterDeployments: [], surveyDataLoader: mockSurveyDataLoader, artifactDataLoader: mockArtifactDataLoader, sampleSiteDataLoader: mockSampleSiteDataLoader, @@ -157,6 +160,7 @@ describe.skip('SurveyStudyArea', () => { value={{ projectId: 1, surveyId: 1, + critterDeployments: [], surveyDataLoader: mockSurveyDataLoader, artifactDataLoader: mockArtifactDataLoader, sampleSiteDataLoader: mockSampleSiteDataLoader, @@ -267,6 +271,7 @@ describe.skip('SurveyStudyArea', () => { value={{ projectId: 1, surveyId: 1, + critterDeployments: [], surveyDataLoader: mockSurveyDataLoader, artifactDataLoader: mockArtifactDataLoader, sampleSiteDataLoader: mockSampleSiteDataLoader, diff --git a/app/src/features/surveys/view/components/spatial-data/SurveySpatialData.tsx b/app/src/features/surveys/view/components/spatial-data/SurveySpatialData.tsx index e9dd0a0bf0..9aabf789b5 100644 --- a/app/src/features/surveys/view/components/spatial-data/SurveySpatialData.tsx +++ b/app/src/features/surveys/view/components/spatial-data/SurveySpatialData.tsx @@ -121,7 +121,7 @@ const SurveySpatialData = () => { coordinates: [telemetry.longitude, telemetry.latitude] as Position } }, - key: `telemetry-${telemetry.telemetry_manual_id}`, + key: `telemetry-${telemetry.id}`, onLoadMetadata: async (): Promise => { return Promise.resolve([ { label: 'Device ID', value: String(deployment.device_id) }, @@ -225,7 +225,8 @@ const SurveySpatialData = () => { layerName: 'Telemetry', layerColors: { fillColor: SURVEY_MAP_LAYER_COLOURS.TELEMETRY_COLOUR, - color: SURVEY_MAP_LAYER_COLOURS.TELEMETRY_COLOUR + color: SURVEY_MAP_LAYER_COLOURS.TELEMETRY_COLOUR, + opacity: 0.5 }, popupRecordTitle: 'Telemetry Record', mapPoints: telemetryPoints @@ -298,7 +299,8 @@ const SurveySpatialData = () => { layerName: supplementaryLayer.layerName, layerColors: { fillColor: supplementaryLayer.layerColors?.fillColor ?? SURVEY_MAP_LAYER_COLOURS.DEFAULT_COLOUR, - color: supplementaryLayer.layerColors?.color ?? SURVEY_MAP_LAYER_COLOURS.DEFAULT_COLOUR + color: supplementaryLayer.layerColors?.color ?? SURVEY_MAP_LAYER_COLOURS.DEFAULT_COLOUR, + fillOpacity: supplementaryLayer.layerColors?.opacity ?? 1 }, features: supplementaryLayer.mapPoints.map((mapPoint: ISurveyMapPoint): IStaticLayerFeature => { const isLoading = !mapPointMetadata[mapPoint.key]; diff --git a/app/src/features/surveys/view/components/spatial-data/SurveySpatialTelemetryDataTable.tsx b/app/src/features/surveys/view/components/spatial-data/SurveySpatialTelemetryDataTable.tsx index edef1f604e..845dd6eded 100644 --- a/app/src/features/surveys/view/components/spatial-data/SurveySpatialTelemetryDataTable.tsx +++ b/app/src/features/surveys/view/components/spatial-data/SurveySpatialTelemetryDataTable.tsx @@ -6,7 +6,6 @@ import { StyledDataGrid } from 'components/data-grid/StyledDataGrid'; import { SurveyContext } from 'contexts/surveyContext'; import dayjs from 'dayjs'; import { useContext, useMemo } from 'react'; -import { ICritterDeployment } from '../../../telemetry/ManualTelemetryList'; // Set height so we the skeleton loader will match table rows const rowHeight = 52; @@ -59,31 +58,20 @@ const SkeletonRow = () => ( const SurveySpatialTelemetryDataTable = (props: ISurveySpatialTelemetryDataTableProps) => { const surveyContext = useContext(SurveyContext); - const flattenedCritterDeployments: ICritterDeployment[] = useMemo(() => { - const data: ICritterDeployment[] = []; - // combine all critter and deployments into a flat list - surveyContext.deploymentDataLoader.data?.forEach((deployment) => { - const critter = surveyContext.critterDataLoader.data?.find( - (critter) => critter.critter_id === deployment.critter_id - ); - if (critter) { - data.push({ critter, deployment }); - } - }); - return data; - }, [surveyContext.critterDataLoader.data, surveyContext.deploymentDataLoader.data]); - - const tableData: ITelemetryData[] = flattenedCritterDeployments.map((item) => ({ - id: item.critter.survey_critter_id, - critter_id: item.critter.animal_id, - device_id: item.deployment.device_id, - start: dayjs(item.deployment.attachment_start).format('YYYY-MM-DD'), - end: item.deployment.attachment_end ? dayjs(item.deployment.attachment_end).format('YYYY-MM-DD') : 'Still Active' - })); + const tableRows = useMemo(() => { + return surveyContext.critterDeployments.map((item) => ({ + // critters in this table may use multiple devices accross multiple timespans + id: `${item.critter.survey_critter_id}-${item.deployment.device_id}-${item.deployment.attachment_start}`, + alias: item.critter.animal_id, + device_id: item.deployment.device_id, + start: dayjs(item.deployment.attachment_start).format('YYYY-MM-DD'), + end: item.deployment.attachment_end ? dayjs(item.deployment.attachment_end).format('YYYY-MM-DD') : 'Still Active' + })); + }, [surveyContext.critterDeployments]); const columns: GridColDef[] = [ { - field: 'critter_id', + field: 'alias', headerName: 'Alias', flex: 1 }, @@ -117,7 +105,7 @@ const SurveySpatialTelemetryDataTable = (props: ISurveySpatialTelemetryDataTable noRowsMessage={'No telemetry records found'} columnHeaderHeight={rowHeight} rowHeight={rowHeight} - rows={tableData} + rows={tableRows} getRowId={(row) => row.id} columns={columns} initialState={{ diff --git a/app/src/features/surveys/view/survey-animals/telemetry-device/TelemetryDeviceForm.tsx b/app/src/features/surveys/view/survey-animals/telemetry-device/TelemetryDeviceForm.tsx index 1a59e997df..f7ef3f7a11 100644 --- a/app/src/features/surveys/view/survey-animals/telemetry-device/TelemetryDeviceForm.tsx +++ b/app/src/features/surveys/view/survey-animals/telemetry-device/TelemetryDeviceForm.tsx @@ -22,21 +22,19 @@ const TelemetryDeviceForm = (props: ITelemetryDeviceFormProps) => { const { mode } = props; const telemetryApi = useTelemetryApi(); - const { values } = useFormikContext(); + const { values: device } = useFormikContext(); - const device = values; + const { data: deviceDetails, refresh, isReady } = useDataLoader(telemetryApi.devices.getDeviceDetails); - const { data: deviceDetails, refresh } = useDataLoader(() => - telemetryApi.devices.getDeviceDetails(Number(device.device_id), device.device_make) - ); + const canRenderFileUpload = + isReady && ((device.device_make === 'Vectronic' && !deviceDetails?.keyXStatus) || device.device_make === 'Lotek'); useEffect(() => { - if (!device.device_id || !device.device_make) { - return; + if (device.device_id && device.device_make) { + refresh(device.device_id, device.device_make); } - refresh(); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [device.device_id, device.device_make, deviceDetails?.device?.device_make]); + }, [device.device_id, device.device_make]); if (!device) { return <>; @@ -107,7 +105,7 @@ const TelemetryDeviceForm = (props: ITelemetryDeviceFormProps) => { - {((device.device_make === 'Vectronic' && !deviceDetails?.keyXStatus) || device.device_make === 'Lotek') && ( + {canRenderFileUpload && ( Upload Attachment diff --git a/app/src/features/surveys/view/survey-animals/telemetry-device/device.ts b/app/src/features/surveys/view/survey-animals/telemetry-device/device.ts index 00fd395456..19360b0ad7 100644 --- a/app/src/features/surveys/view/survey-animals/telemetry-device/device.ts +++ b/app/src/features/surveys/view/survey-animals/telemetry-device/device.ts @@ -9,6 +9,8 @@ export type IDeploymentTimespan = InferType; +export type ICreateAnimalDeployment = InferType; + export type ITelemetryPointCollection = { points: FeatureCollection; tracks: FeatureCollection }; const req = 'Required.'; @@ -48,6 +50,17 @@ export const AnimalDeploymentSchema = yup.object({}).shape({ frequency_unit: yup.string() }); +export const CreateAnimalDeployment = yup.object({ + critter_id: yup.string().uuid().required(req), // Critterbase critter_id + device_id: intSchema, + device_make: yup.string().required(req), + frequency: numSchema.optional(), + frequency_unit: yup.string().optional(), + device_model: yup.string().optional(), + attachment_start: yup.string().isValidDateString(), + attachment_end: yup.string().isValidDateString().isEndDateSameOrAfterStartDate('attachment_start') +}); + export interface IAnimalTelemetryDeviceFile extends IAnimalTelemetryDevice { attachmentFile?: File; attachmentType?: AttachmentType; diff --git a/app/src/hooks/api/useSurveyApi.test.ts b/app/src/hooks/api/useSurveyApi.test.ts index 2b9c73b435..42f292da71 100644 --- a/app/src/hooks/api/useSurveyApi.test.ts +++ b/app/src/hooks/api/useSurveyApi.test.ts @@ -102,45 +102,13 @@ describe('useSurveyApi', () => { device_model: 'E', frequency: 1, frequency_unit: 'Hz', - deployments: [ - { - deployment_id: '', - attachment_start: '2023-01-01', - attachment_end: undefined - } - ], + attachment_start: '2023-01-01', + attachment_end: undefined, critter_id: v4() }); expect(result).toBe(1); }); - - it('should fail to add deployment to survey critter', async () => { - mock.onPost(`/api/project/${projectId}/survey/${surveyId}/critters/${critterId}/deployments`).reply(201, 1); - - const result = useSurveyApi(axios).addDeployment(projectId, surveyId, critterId, { - device_id: 1, - device_make: 'ATS', - device_model: 'E', - frequency: 1, - frequency_unit: 'Hz', - deployments: [ - { - deployment_id: '', - attachment_start: '2023-01-01', - attachment_end: undefined - }, - { - deployment_id: '', - attachment_start: '2023-01-01', - attachment_end: undefined - } - ], - critter_id: v4() - }); - - await expect(result).rejects.toThrow(); - }); }); describe('getDeploymentsInSurvey', () => { diff --git a/app/src/hooks/api/useSurveyApi.ts b/app/src/hooks/api/useSurveyApi.ts index fd8f819c15..0b9ffe2068 100644 --- a/app/src/hooks/api/useSurveyApi.ts +++ b/app/src/hooks/api/useSurveyApi.ts @@ -4,7 +4,7 @@ import { IReportMetaForm } from 'components/attachments/ReportMetaForm'; import { ICreateCritter } from 'features/surveys/view/survey-animals/animal'; import { IAnimalDeployment, - IAnimalTelemetryDevice, + ICreateAnimalDeployment, IDeploymentTimespan, ITelemetryPointCollection } from 'features/surveys/view/survey-animals/telemetry-device/device'; @@ -419,26 +419,12 @@ const useSurveyApi = (axios: AxiosInstance) => { const addDeployment = async ( projectId: number, surveyId: number, - critterId: number, - body: IAnimalTelemetryDevice & { critter_id: string } + critterId: number, // Survey critter_id + body: ICreateAnimalDeployment // Critterbase critter_id ): Promise => { - body.device_id = Number(body.device_id); //Turn this into validation class soon - body.frequency = body.frequency != null ? Number(body.frequency) : undefined; - body.frequency_unit = body.frequency_unit?.length ? body.frequency_unit : undefined; - if (!body.deployments || body.deployments.length !== 1) { - throw Error('Calling this with any amount other than 1 deployments currently unsupported.'); - } - const flattened = { ...body, ...body.deployments[0] }; - - delete flattened.deployment_id; - delete flattened.deployments; - if (!flattened.device_model) { - delete flattened.device_model; - } - const { data } = await axios.post( `/api/project/${projectId}/survey/${surveyId}/critters/${critterId}/deployments`, - flattened + body ); return data; }; diff --git a/app/src/hooks/usePersistentState.test.tsx b/app/src/hooks/usePersistentState.test.tsx new file mode 100644 index 0000000000..661f123bd9 --- /dev/null +++ b/app/src/hooks/usePersistentState.test.tsx @@ -0,0 +1,122 @@ +import { renderHook, waitFor } from '@testing-library/react'; +import { usePersistentState } from './usePersistentState'; + +const KEY = 'KEY'; +const VALUE = 'VALUE'; + +describe('usePersistentState', () => { + const lsGetItemMock = jest.spyOn(window.localStorage.__proto__, 'getItem'); + const lsSetItemMock = jest.spyOn(window.localStorage.__proto__, 'setItem'); + + beforeEach(() => { + lsGetItemMock.mockClear(); + lsSetItemMock.mockClear(); + }); + + describe('get initial value', () => { + it('when nothing in local storage should return initial value', () => { + lsGetItemMock.mockReturnValue(null); + + const { result } = renderHook(() => usePersistentState(KEY, VALUE)); + const [value, setValue] = result.current; + + expect(value).toBe(VALUE); + expect(setValue).toBeDefined(); + }); + + it('when local storage value exists return stored value', () => { + lsGetItemMock.mockReturnValue('STORAGE'); + + const { result } = renderHook(() => usePersistentState(KEY, 'STORAGE')); + const [value] = result.current; + + expect(value).toBe('STORAGE'); + }); + + it('handles objects', () => { + lsGetItemMock.mockReturnValue({ hello: 'world' }); + + const { result } = renderHook(() => usePersistentState(KEY, { hello: 'world' })); + const [value] = result.current; + + expect(value).toStrictEqual({ hello: 'world' }); + }); + + it('handles 0', () => { + lsGetItemMock.mockReturnValue(0); + + const { result } = renderHook(() => usePersistentState(KEY, 0)); + const [value] = result.current; + + expect(value).toBe(0); + }); + + it('handles 1', () => { + lsGetItemMock.mockReturnValue(1); + + const { result } = renderHook(() => usePersistentState(KEY, 1)); + const [value] = result.current; + + expect(value).toBe(1); + }); + + it('handles arrays', () => { + lsGetItemMock.mockReturnValue([0, 1]); + + const { result } = renderHook(() => usePersistentState(KEY, [0, 1])); + const [value] = result.current; + + expect(value).toStrictEqual([0, 1]); + }); + + it('handles undefined', () => { + lsGetItemMock.mockReturnValue(undefined); + + const { result } = renderHook(() => usePersistentState(KEY, undefined)); + const [value] = result.current; + + expect(value).toStrictEqual(undefined); + }); + + it('handles null', () => { + lsGetItemMock.mockReturnValue(null); + + const { result } = renderHook(() => usePersistentState(KEY, null)); + const [value] = result.current; + + expect(value).toStrictEqual(null); + }); + }); + describe('setValue', () => { + it('also sets local storage', async () => { + lsGetItemMock.mockReturnValue(VALUE); + const { result } = renderHook(() => usePersistentState(KEY, VALUE)); + const [value, setValue] = result.current; + + expect(value).toBe(VALUE); + + await waitFor(() => { + setValue('NEW'); + expect(lsSetItemMock).toHaveBeenCalledWith(`USE_PERSISTENT_STATE_${KEY}`, 'NEW'); + const [newValue] = result.current; + expect(newValue).toBe('NEW'); + }); + }); + + it('sanity checking it works with objects', async () => { + lsGetItemMock.mockReturnValue(VALUE); + const { result } = renderHook(() => usePersistentState(KEY, VALUE)); + const [value, setValue] = result.current; + + expect(value).toBe(VALUE); + + const obj = { hello: 'world' }; + await waitFor(() => { + setValue(obj as unknown as string); + expect(lsSetItemMock).toHaveBeenCalledWith(`USE_PERSISTENT_STATE_${KEY}`, JSON.stringify(obj)); + const [newValue] = result.current; + expect(newValue).toBe(obj); + }); + }); + }); +}); diff --git a/app/src/hooks/usePersistentState.tsx b/app/src/hooks/usePersistentState.tsx new file mode 100644 index 0000000000..24eb439443 --- /dev/null +++ b/app/src/hooks/usePersistentState.tsx @@ -0,0 +1,51 @@ +import { useState } from 'react'; + +/** + * Save state between refreshes, windows and sessions. + * + * NOTE: This hook will attempt to grab from local storage BEFORE defaulting to intitial value. + * If this hook is being rendered multiple times in children components, a unique key per child + * must be provided. + * + * @template T - Generic. + * @param {T} initialValue - Initial value for localStorage. + * @param {string} localStorageId - Local storage identifier. + * @returns {[T, (newValue: T) => void]} State and SetState handler. + */ +export const usePersistentState = (localStorageId: string, initialValue: T): [T, (newValue: T) => void] => { + // local storage key - used to access the stored value + const prefixedKey = `USE_PERSISTENT_STATE_${localStorageId}`; + + const [value, setValue] = useState(() => { + // attempt to retrieve value from local storage + const storageValue = localStorage.getItem(prefixedKey); + + // if local storage is null, default to initialValue + if (storageValue === null) { + return initialValue; + } + + try { + // attempt to parse storage value + return JSON.parse(storageValue); + } catch (err) { + // unable to parse just return the value + return storageValue; + } + }); + + /** + * Set the value in local storage and state. + * + * @param {T} newValue - Updated value. + */ + const setPersistentValue = (newValue: T) => { + const parsedValue = typeof newValue === 'string' ? newValue : JSON.stringify(newValue); + // set local storage value + localStorage.setItem(prefixedKey, parsedValue); + // set state value + setValue(newValue); + }; + + return [value, setPersistentValue]; +}; diff --git a/app/src/index.tsx b/app/src/index.tsx index bfd3c35dfc..2700932f0e 100644 --- a/app/src/index.tsx +++ b/app/src/index.tsx @@ -1,8 +1,10 @@ import App from 'App'; -import ReactDOM from 'react-dom'; +import { createRoot } from 'react-dom/client'; import * as serviceWorker from './serviceWorker'; -ReactDOM.render(, document.getElementById('root')); +//https://react.dev/blog/2022/03/08/react-18-upgrade-guide#updates-to-client-rendering-apis + +createRoot(document.getElementById('root')!).render(); // If you want your app to work offline and load faster, you can change // unregister() to register() below. Note this comes with some pitfalls. diff --git a/database/src/migrations/20240515000000_deprecate_project_region_table.ts b/database/src/migrations/20240515000000_deprecate_project_region_table.ts new file mode 100644 index 0000000000..14696d0673 --- /dev/null +++ b/database/src/migrations/20240515000000_deprecate_project_region_table.ts @@ -0,0 +1,19 @@ +import { Knex } from 'knex'; + +/** + * Drop the project region table + * + * @export + * @param {Knex} knex + * @return {*} {Promise} + */ +export async function up(knex: Knex): Promise { + await knex.raw(`--sql + DROP VIEW IF EXISTS biohub_dapi_v1.project_region; + DROP TABLE IF EXISTS biohub.project_region; + `); +} + +export async function down(knex: Knex): Promise { + await knex.raw(``); +} diff --git a/database/src/seeds/03_basic_project_survey_setup.ts b/database/src/seeds/03_basic_project_survey_setup.ts index abc521b42b..d5d5d8b1a8 100644 --- a/database/src/seeds/03_basic_project_survey_setup.ts +++ b/database/src/seeds/03_basic_project_survey_setup.ts @@ -25,6 +25,9 @@ const ancillaryTaxonIdOptions = [ { itis_tsn: 180543, itis_scientific_name: 'Ursus arctos' } // Grizzly bear ]; +const surveyRegionsA = ['Kootenay-Boundary Natural Resource Region', 'West Coast Natural Resource Region']; +const surveyRegionsB = ['Cariboo Natural Resource Region', 'South Coast Natural Resource Region']; + /** * Add spatial transform * @@ -88,6 +91,19 @@ export async function seed(knex: Knex): Promise { ${insertSurveySamplePeriodData(surveyId)} `); + // Insert regions into surveys + if (projectId % 2 === 0) { + // Insert survey regions A + for (const region of surveyRegionsA) { + await knex.raw(`${insertSurveyRegionData(surveyId, region)}`); + } + } else { + // Insert survey regions B + for (const region of surveyRegionsB) { + await knex.raw(`${insertSurveyRegionData(surveyId, region)}`); + } + } + const response1 = await knex.raw(insertSurveyObservationData(surveyId, 20)); await knex.raw(insertObservationSubCount(response1.rows[0].survey_observation_id)); @@ -743,3 +759,22 @@ const insertProjectData = (projectName?: string) => ` ) RETURNING project_id; `; + +/** + * SQL to insert survey regions + * + */ +const insertSurveyRegionData = (surveyId: string, region: string) => ` + INSERT INTO survey_region + ( + survey_id, + region_id + ) + SELECT + $$${surveyId}$$, + region_id + FROM + region_lookup + WHERE + region_name = $$${region}$$; +`; diff --git a/env_config/env.docker b/env_config/env.docker index b855ad13eb..b99fb0baf6 100644 --- a/env_config/env.docker +++ b/env_config/env.docker @@ -1,11 +1,11 @@ # ------------------------------------------------------------------------------ -# These environment variables are only used for local development. +# These environment variables are only used for local development. # # For more information on environment variables in general, see the root README.md. -# +# # These env vars are automatically read by the makefile (when running make commands). # -# Newly added environment variables need to be added to the docker-compose file, +# Newly added environment variables need to be added to the docker-compose file, # under whichever service needs them (api, app, etc) # # Exposed Ports/URLs @@ -90,7 +90,7 @@ BIOHUB_TAXON_TSN_PATH=/api/taxonomy/taxon/tsn # API - BC Telemetry Warehouse Connection # ------------------------------------------------------------------------------ # BCTW Platform - BCTW API URL -# (Note): If BCTW is running locally, you can use `http://host.docker.internal:` +# (Note): If BCTW is running locally, you can use `http://host.docker.internal:/api` BCTW_API_HOST=https://moe-bctw-api-dev.apps.silver.devops.gov.bc.ca # ------------------------------------------------------------------------------ @@ -216,4 +216,4 @@ NUM_SEED_SURVEYS_PER_PROJECT=2 NUM_SEED_OBSERVATIONS_PER_SURVEY=3 # Sets the number of desired seed subcounts to generate per observation. defaults to 1. -NUM_SEED_SUBCOUNTS_PER_OBSERVATION=1 \ No newline at end of file +NUM_SEED_SUBCOUNTS_PER_OBSERVATION=1 From f0197192c4e19aa1f9fa1afa85ef05c02811c2ee Mon Sep 17 00:00:00 2001 From: Andrew <105487051+LouisThedroux@users.noreply.github.com> Date: Fri, 24 May 2024 11:09:44 -0700 Subject: [PATCH 13/31] SIMSBIOHUB-567: Add/Support New Observation Environment Attributes (#1289) Add/Support New Observation Environment Attributes Add migration for environment attribute values from SPI --------- Co-authored-by: Nick Phura Co-authored-by: Macgregor Aubertin-Young --- .../observations/environments/delete.ts | 145 + .../{surveyId}/observations/index.test.ts | 36 +- .../survey/{surveyId}/observations/index.ts | 221 +- api/src/paths/reference/search/environment.ts | 164 ++ .../repositories/observation-repository.ts | 84 +- ...rvation-subcount-environment-repository.ts | 478 ++++ ...rvation-subcount-measurement-repository.ts | 8 + .../repositories/sample-period-repository.ts | 6 +- api/src/services/code-service.ts | 25 + api/src/services/critterbase-service.ts | 1 - api/src/services/observation-service.test.ts | 9 +- api/src/services/observation-service.ts | 474 ++-- ...bservation-subcount-environment-service.ts | 126 + ...bservation-subcount-measurement-service.ts | 29 +- api/src/services/subcount-service.test.ts | 6 + api/src/services/subcount-service.ts | 39 +- api/src/services/telemetry-service.ts | 10 +- api/src/utils/media/csv/csv-file.test.ts | 387 +-- api/src/utils/media/csv/csv-file.ts | 2 +- .../common-utils.test.ts | 155 ++ .../observation-xlsx-utils/common-utils.ts | 40 + .../environment-column-utils.test.ts | 547 ++++ .../environment-column-utils.ts | 133 + .../measurement-column-utils.test.ts | 446 ++++ .../measurement-column-utils.ts | 219 ++ .../standard-column-utils.test.ts | 283 ++ .../standard-column-utils.ts | 134 + .../utils/xlsx-utils/worksheet-utils.test.ts | 786 +----- api/src/utils/xlsx-utils/worksheet-utils.ts | 384 +-- app/src/App.tsx | 3 +- .../GenericGridColumnDefinitions.tsx | 5 +- .../data-grid/TextFieldDataGrid.tsx | 4 +- app/src/constants/i18n.ts | 19 + app/src/constants/session-storage.ts | 7 + app/src/contexts/observationsTableContext.tsx | 615 +++-- .../components/EnvironmentStandardCard.tsx | 75 + .../components/MeasurementStandardCard.tsx | 16 +- .../observations-table/ObservationsTable.tsx | 77 +- .../ObservationsTableContainer.tsx | 25 +- .../ConfigureColumnsButton.tsx | 100 + .../components/ConfigureColumnsDialog.tsx | 165 ++ .../components/ConfigureColumnsPage.tsx | 224 ++ .../ConfigureEnvironmentColumns.tsx | 115 + .../environment/search/EnvironmentsSearch.tsx | 51 + .../search/EnvironmentsSearchAutocomplete.tsx | 204 ++ .../useConfigureEnvironmentColumns.tsx | 147 ++ .../general/ConfigureGeneralColumns.tsx | 169 ++ ...ConfigureGeneralColumnsSecondaryAction.tsx | 116 + .../general/useConfigureGeneralColumns.tsx | 62 + .../ConfigureMeasurementColumns.tsx | 86 + .../search/MeasurementsSearch.tsx | 45 + .../search/MeasurementsSearchAutocomplete.tsx | 43 +- .../useConfigureMeasurementColumns.tsx | 103 + .../configure-table/ConfigureColumns.tsx | 138 - .../ConfigureColumnsContainer.tsx | 209 -- .../ConfigureColumnsPopoverContent.tsx | 135 - .../dialog/MeasurementsButton.tsx | 61 - .../dialog/MeasurementsDialog.tsx | 109 - .../measurements/list/MeasurementsList.tsx | 43 - .../list/MeasurementsListCard.tsx | 93 - .../search/MeasurementsSearch.tsx | 52 - .../GridColumnDefinitions.tsx | 131 +- .../GridColumnDefinitionsUtils.tsx | 78 +- .../ImportObservationsButton.tsx | 8 + .../ObservationRowValidationUtils.ts | 264 +- app/src/hooks/api/useObservationApi.ts | 49 +- app/src/hooks/api/useReferenceApi.ts | 28 + app/src/hooks/useBioHubApi.ts | 6 +- .../interfaces/useObservationApi.interface.ts | 89 +- .../interfaces/useReferenceApi.interface.ts | 49 + ...417000000_obsevation_environment_tables.ts | 283 ++ ..._populate_obsevation_environment_tables.ts | 2343 +++++++++++++++++ database/src/seeds/01_db_system_users.ts | 10 + 73 files changed, 9277 insertions(+), 2754 deletions(-) create mode 100644 api/src/paths/project/{projectId}/survey/{surveyId}/observations/environments/delete.ts create mode 100644 api/src/paths/reference/search/environment.ts create mode 100644 api/src/repositories/observation-subcount-environment-repository.ts create mode 100644 api/src/services/observation-subcount-environment-service.ts create mode 100644 api/src/utils/observation-xlsx-utils/common-utils.test.ts create mode 100644 api/src/utils/observation-xlsx-utils/common-utils.ts create mode 100644 api/src/utils/observation-xlsx-utils/environment-column-utils.test.ts create mode 100644 api/src/utils/observation-xlsx-utils/environment-column-utils.ts create mode 100644 api/src/utils/observation-xlsx-utils/measurement-column-utils.test.ts create mode 100644 api/src/utils/observation-xlsx-utils/measurement-column-utils.ts create mode 100644 api/src/utils/observation-xlsx-utils/standard-column-utils.test.ts create mode 100644 api/src/utils/observation-xlsx-utils/standard-column-utils.ts create mode 100644 app/src/features/standards/view/components/EnvironmentStandardCard.tsx create mode 100644 app/src/features/surveys/observations/observations-table/configure-columns/ConfigureColumnsButton.tsx create mode 100644 app/src/features/surveys/observations/observations-table/configure-columns/components/ConfigureColumnsDialog.tsx create mode 100644 app/src/features/surveys/observations/observations-table/configure-columns/components/ConfigureColumnsPage.tsx create mode 100644 app/src/features/surveys/observations/observations-table/configure-columns/components/environment/ConfigureEnvironmentColumns.tsx create mode 100644 app/src/features/surveys/observations/observations-table/configure-columns/components/environment/search/EnvironmentsSearch.tsx create mode 100644 app/src/features/surveys/observations/observations-table/configure-columns/components/environment/search/EnvironmentsSearchAutocomplete.tsx create mode 100644 app/src/features/surveys/observations/observations-table/configure-columns/components/environment/useConfigureEnvironmentColumns.tsx create mode 100644 app/src/features/surveys/observations/observations-table/configure-columns/components/general/ConfigureGeneralColumns.tsx create mode 100644 app/src/features/surveys/observations/observations-table/configure-columns/components/general/ConfigureGeneralColumnsSecondaryAction.tsx create mode 100644 app/src/features/surveys/observations/observations-table/configure-columns/components/general/useConfigureGeneralColumns.tsx create mode 100644 app/src/features/surveys/observations/observations-table/configure-columns/components/measurements/ConfigureMeasurementColumns.tsx create mode 100644 app/src/features/surveys/observations/observations-table/configure-columns/components/measurements/search/MeasurementsSearch.tsx rename app/src/features/surveys/observations/observations-table/{configure-table => configure-columns/components}/measurements/search/MeasurementsSearchAutocomplete.tsx (78%) create mode 100644 app/src/features/surveys/observations/observations-table/configure-columns/components/measurements/useConfigureMeasurementColumns.tsx delete mode 100644 app/src/features/surveys/observations/observations-table/configure-table/ConfigureColumns.tsx delete mode 100644 app/src/features/surveys/observations/observations-table/configure-table/ConfigureColumnsContainer.tsx delete mode 100644 app/src/features/surveys/observations/observations-table/configure-table/ConfigureColumnsPopoverContent.tsx delete mode 100644 app/src/features/surveys/observations/observations-table/configure-table/measurements/dialog/MeasurementsButton.tsx delete mode 100644 app/src/features/surveys/observations/observations-table/configure-table/measurements/dialog/MeasurementsDialog.tsx delete mode 100644 app/src/features/surveys/observations/observations-table/configure-table/measurements/list/MeasurementsList.tsx delete mode 100644 app/src/features/surveys/observations/observations-table/configure-table/measurements/list/MeasurementsListCard.tsx delete mode 100644 app/src/features/surveys/observations/observations-table/configure-table/measurements/search/MeasurementsSearch.tsx create mode 100644 app/src/hooks/api/useReferenceApi.ts create mode 100644 app/src/interfaces/useReferenceApi.interface.ts create mode 100644 database/src/migrations/20240417000000_obsevation_environment_tables.ts create mode 100644 database/src/migrations/20240417000001_populate_obsevation_environment_tables.ts diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/observations/environments/delete.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/observations/environments/delete.ts new file mode 100644 index 0000000000..2642ee34ef --- /dev/null +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/observations/environments/delete.ts @@ -0,0 +1,145 @@ +import { RequestHandler } from 'express'; +import { Operation } from 'express-openapi'; +import { PROJECT_PERMISSION, SYSTEM_ROLE } from '../../../../../../../constants/roles'; +import { getDBConnection } from '../../../../../../../database/db'; +import { authorizeRequestHandler } from '../../../../../../../request-handlers/security/authorization'; +import { ObservationSubCountEnvironmentService } from '../../../../../../../services/observation-subcount-environment-service'; +import { getLogger } from '../../../../../../../utils/logger'; + +const defaultLog = getLogger('/api/project/{projectId}/survey/{surveyId}/observations/environments'); + +export const POST: Operation = [ + authorizeRequestHandler((req) => { + return { + or: [ + { + validProjectPermissions: [PROJECT_PERMISSION.COORDINATOR, PROJECT_PERMISSION.COLLABORATOR], + surveyId: Number(req.params.surveyId), + discriminator: 'ProjectPermission' + }, + { + validSystemRoles: [SYSTEM_ROLE.DATA_ADMINISTRATOR], + discriminator: 'SystemRole' + } + ] + }; + }), + deleteObservationEnvironments() +]; + +POST.apiDoc = { + description: 'Delete survey observation environments.', + tags: ['observation'], + security: [ + { + Bearer: [] + } + ], + parameters: [ + { + in: 'path', + name: 'projectId', + schema: { + type: 'integer', + minimum: 1 + }, + required: true + }, + { + in: 'path', + name: 'surveyId', + schema: { + type: 'integer', + minimum: 1 + }, + required: true + } + ], + requestBody: { + description: 'Survey observation environment delete request body.', + content: { + 'application/json': { + schema: { + type: 'object', + additionalProperties: false, + properties: { + environment_qualitative_id: { + description: 'An array of qualitative environment ids to delete', + type: 'array', + items: { + type: 'string', + format: 'uuid' + } + }, + environment_quantitative_id: { + description: 'An array of quantitative environment ids to delete', + type: 'array', + items: { + type: 'string', + format: 'uuid' + } + } + } + } + } + } + }, + responses: { + 200: { + description: 'Delete OK' + }, + 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' + } + } +}; + +/** + * Deletes survey observation environment records, for all observation records, for the given survey and set of + * environment ids. + * + * @export + * @return {*} {RequestHandler} + */ +export function deleteObservationEnvironments(): RequestHandler { + return async (req, res) => { + const connection = getDBConnection(req['keycloak_token']); + + try { + const surveyId = Number(req.params.surveyId); + + const environmentIds = { + environment_qualitative_id: req.body.environment_qualitative_id, + environment_quantitative_id: req.body.environment_quantitative_id + }; + + defaultLog.debug({ label: 'deleteObservationEnvironments', surveyId }); + await connection.open(); + + const service = new ObservationSubCountEnvironmentService(connection); + await service.deleteEnvironmentsForEnvironmentIds(surveyId, environmentIds); + + await connection.commit(); + + return res.status(200).send(); + } catch (error) { + defaultLog.error({ label: 'deleteObservationEnvironments', message: 'error', error }); + await connection.rollback(); + throw error; + } finally { + connection.release(); + } + }; +} diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/observations/index.test.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/observations/index.test.ts index d9a46301fc..9b5ed61b4c 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/observations/index.test.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/observations/index.test.ts @@ -12,7 +12,7 @@ import * as observationRecords from './index'; chai.use(sinonChai); -describe('insertUpdateSurveyObservationsWithMeasurements', () => { +describe('insertUpdateManualSurveyObservations', () => { afterEach(() => { sinon.restore(); }); @@ -27,7 +27,7 @@ describe('insertUpdateSurveyObservationsWithMeasurements', () => { .resolves(true); const insertUpdateSurveyObservationsStub = sinon - .stub(ObservationService.prototype, 'insertUpdateSurveyObservationsWithMeasurements') + .stub(ObservationService.prototype, 'insertUpdateManualSurveyObservations') .resolves(); const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); @@ -75,7 +75,7 @@ describe('insertUpdateSurveyObservationsWithMeasurements', () => { surveyObservations }; - const requestHandler = observationRecords.insertUpdateSurveyObservationsWithMeasurements(); + const requestHandler = observationRecords.insertUpdateManualSurveyObservations(); await requestHandler(mockReq, mockRes, mockNext); @@ -95,9 +95,7 @@ describe('insertUpdateSurveyObservationsWithMeasurements', () => { sinon.stub(ObservationService.prototype, 'validateSurveyObservations').resolves(true); - sinon - .stub(ObservationService.prototype, 'insertUpdateSurveyObservationsWithMeasurements') - .rejects(new Error('a test error')); + sinon.stub(ObservationService.prototype, 'insertUpdateManualSurveyObservations').rejects(new Error('a test error')); const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); @@ -128,7 +126,7 @@ describe('insertUpdateSurveyObservationsWithMeasurements', () => { }; try { - const requestHandler = observationRecords.insertUpdateSurveyObservationsWithMeasurements(); + const requestHandler = observationRecords.insertUpdateManualSurveyObservations(); await requestHandler(mockReq, mockRes, mockNext); expect.fail(); @@ -160,7 +158,9 @@ describe('getSurveyObservations', () => { supplementaryObservationData: { observationCount: 59, qualitative_measurements: [], - quantitative_measurements: [] + quantitative_measurements: [], + qualitative_environments: [], + quantitative_environments: [] } }); @@ -188,7 +188,9 @@ describe('getSurveyObservations', () => { supplementaryObservationData: { observationCount: 59, qualitative_measurements: [], - quantitative_measurements: [] + quantitative_measurements: [], + qualitative_environments: [], + quantitative_environments: [] }, pagination: { total: 59, @@ -216,7 +218,9 @@ describe('getSurveyObservations', () => { supplementaryObservationData: { observationCount: 50, qualitative_measurements: [], - quantitative_measurements: [] + quantitative_measurements: [], + qualitative_environments: [], + quantitative_environments: [] } }); @@ -242,7 +246,9 @@ describe('getSurveyObservations', () => { supplementaryObservationData: { observationCount: 50, qualitative_measurements: [], - quantitative_measurements: [] + quantitative_measurements: [], + qualitative_environments: [], + quantitative_environments: [] }, pagination: { total: 50, @@ -270,7 +276,9 @@ describe('getSurveyObservations', () => { supplementaryObservationData: { observationCount: 2, qualitative_measurements: [], - quantitative_measurements: [] + quantitative_measurements: [], + qualitative_environments: [], + quantitative_environments: [] } }); @@ -291,7 +299,9 @@ describe('getSurveyObservations', () => { supplementaryObservationData: { observationCount: 2, qualitative_measurements: [], - quantitative_measurements: [] + quantitative_measurements: [], + qualitative_environments: [], + quantitative_environments: [] }, pagination: { total: 2, diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/observations/index.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/observations/index.ts index 38e138d89d..cbdb17374f 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/observations/index.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/observations/index.ts @@ -8,10 +8,7 @@ import { } from '../../../../../../openapi/schemas/pagination'; import { authorizeRequestHandler } from '../../../../../../request-handlers/security/authorization'; import { CritterbaseService } from '../../../../../../services/critterbase-service'; -import { - InsertUpdateObservationsWithMeasurements, - ObservationService -} from '../../../../../../services/observation-service'; +import { InsertUpdateObservations, ObservationService } from '../../../../../../services/observation-service'; import { getLogger } from '../../../../../../utils/logger'; import { ensureCompletePaginationOptions, makePaginationResponse } from '../../../../../../utils/pagination'; import { ApiPaginationOptions } from '../../../../../../zod-schema/pagination'; @@ -57,7 +54,7 @@ export const PUT: Operation = [ ] }; }), - insertUpdateSurveyObservationsWithMeasurements() + insertUpdateManualSurveyObservations() ]; GET.apiDoc = { @@ -189,7 +186,9 @@ GET.apiDoc = { 'observation_subcount_id', 'subcount', 'qualitative_measurements', - 'quantitative_measurements' + 'quantitative_measurements', + 'qualitative_environments', + 'quantitative_environments' ], properties: { observation_subcount_id: { @@ -235,6 +234,44 @@ GET.apiDoc = { } } } + }, + qualitative_environments: { + type: 'array', + items: { + type: 'object', + additionalProperties: false, + required: ['environment_qualitative_id', 'environment_qualitative_option_id'], + properties: { + observation_subcount_qualitative_environment_id: { + type: 'integer' + }, + environment_qualitative_id: { + type: 'string', + format: 'uuid' + }, + environment_qualitative_option_id: { + type: 'string', + format: 'uuid' + } + } + } + }, + quantitative_environments: { + type: 'array', + items: { + type: 'object', + additionalProperties: false, + required: ['environment_quantitative_id', 'value'], + properties: { + environment_quantitative_id: { + type: 'string', + format: 'uuid' + }, + value: { + type: 'number' + } + } + } } } } @@ -245,7 +282,13 @@ GET.apiDoc = { supplementaryObservationData: { type: 'object', additionalProperties: false, - required: ['observationCount', 'qualitative_measurements', 'quantitative_measurements'], + required: [ + 'observationCount', + 'qualitative_measurements', + 'quantitative_measurements', + 'qualitative_environments', + 'quantitative_environments' + ], properties: { observationCount: { type: 'integer', @@ -347,6 +390,95 @@ GET.apiDoc = { } } } + }, + qualitative_environments: { + description: 'All qualitative environment type definitions for the survey.', + type: 'array', + items: { + description: 'A qualitative environment type definition, with array of valid/accepted options', + type: 'object', + additionalProperties: false, + required: ['environment_qualitative_id', 'name', 'description', 'options'], + properties: { + environment_qualitative_id: { + type: 'string', + format: 'uuid' + }, + name: { + type: 'string' + }, + description: { + type: 'string', + nullable: true + }, + options: { + description: 'Valid options for the environment.', + type: 'array', + items: { + type: 'object', + additionalProperties: false, + required: [ + 'environment_qualitative_option_id', + 'environment_qualitative_id', + 'name', + 'description' + ], + properties: { + environment_qualitative_option_id: { + type: 'string', + format: 'uuid' + }, + environment_qualitative_id: { + type: 'string', + format: 'uuid' + }, + name: { + type: 'string' + }, + description: { + type: 'string', + nullable: true + } + } + } + } + } + } + }, + quantitative_environments: { + description: 'All quantitative environment type definitions for the survey.', + type: 'array', + items: { + description: 'A quantitative environment type definition, with possible min/max constraint.', + type: 'object', + additionalProperties: false, + required: ['environment_quantitative_id', 'name', 'description', 'min', 'max', 'unit'], + properties: { + environment_quantitative_id: { + type: 'string', + format: 'uuid' + }, + name: { + type: 'string' + }, + description: { + type: 'string', + nullable: true + }, + min: { + type: 'number', + nullable: true + }, + max: { + type: 'number', + nullable: true + }, + unit: { + type: 'string', + nullable: true + } + } + } } } }, @@ -430,11 +562,8 @@ PUT.apiDoc = { survey_observation_id: { type: 'integer', minimum: 1, - nullable: true - }, - survey_id: { - type: 'integer', - minimum: 1 + nullable: true, + description: 'The survey observation ID. If provided observation, the record will be updated.' }, itis_tsn: { type: 'integer' @@ -459,10 +588,8 @@ PUT.apiDoc = { nullable: true }, count: { - type: 'integer' - }, - subcount: { - type: 'integer' + type: 'integer', + description: "The observation record's count." }, latitude: { type: 'number' @@ -483,20 +610,29 @@ PUT.apiDoc = { } }, subcounts: { - description: 'An array of observation subcount and measurement data', + description: 'An array of observation subcount records.', type: 'array', items: { type: 'object', additionalProperties: false, + required: [ + 'subcount', + 'qualitative_measurements', + 'quantitative_measurements', + 'qualitative_environments', + 'quantitative_environments' + ], properties: { observation_subcount_id: { type: 'number', - nullable: true + nullable: true, + description: 'The observation subcount ID. If provided, the subcount record will be updated.' }, subcount: { - type: 'number' + type: 'number', + description: "The subcount record's count." }, - qualitative: { + qualitative_measurements: { type: 'array', items: { type: 'object', @@ -511,7 +647,7 @@ PUT.apiDoc = { } } }, - quantitative: { + quantitative_measurements: { type: 'array', items: { type: 'object', @@ -525,6 +661,39 @@ PUT.apiDoc = { } } } + }, + qualitative_environments: { + type: 'array', + items: { + type: 'object', + additionalProperties: false, + properties: { + environment_qualitative_id: { + type: 'string', + format: 'uuid' + }, + environment_qualitative_option_id: { + type: 'string', + format: 'uuid' + } + } + } + }, + quantitative_environments: { + type: 'array', + items: { + type: 'object', + additionalProperties: false, + properties: { + environment_quantitative_id: { + type: 'string', + format: 'uuid' + }, + value: { + type: 'number' + } + } + } } } } @@ -633,7 +802,7 @@ export function getSurveyObservations(): RequestHandler { * @export * @return {*} {RequestHandler} */ -export function insertUpdateSurveyObservationsWithMeasurements(): RequestHandler { +export function insertUpdateManualSurveyObservations(): RequestHandler { return async (req, res) => { const surveyId = Number(req.params.surveyId); @@ -646,7 +815,7 @@ export function insertUpdateSurveyObservationsWithMeasurements(): RequestHandler const observationService = new ObservationService(connection); - const observationRows: InsertUpdateObservationsWithMeasurements[] = req.body.surveyObservations; + const observationRows: InsertUpdateObservations[] = req.body.surveyObservations; const critterBaseService = new CritterbaseService({ keycloak_guid: req['system_user']?.user_guid, @@ -656,16 +825,16 @@ export function insertUpdateSurveyObservationsWithMeasurements(): RequestHandler // Validate measurement data against fetched measurement definition const isValid = await observationService.validateSurveyObservations(observationRows, critterBaseService); if (!isValid) { - throw new Error('Failed to save observation data, measurement values failed validation.'); + throw new Error('Failed to save observation data, failed data validation.'); } - await observationService.insertUpdateSurveyObservationsWithMeasurements(surveyId, observationRows); + await observationService.insertUpdateManualSurveyObservations(surveyId, observationRows); await connection.commit(); return res.status(204).send(); } catch (error) { - defaultLog.error({ label: 'insertUpdateSurveyObservationsWithMeasurements', message: 'error', error }); + defaultLog.error({ label: 'insertUpdateManualSurveyObservations', message: 'error', error }); await connection.rollback(); throw error; } finally { diff --git a/api/src/paths/reference/search/environment.ts b/api/src/paths/reference/search/environment.ts new file mode 100644 index 0000000000..11a563f3ea --- /dev/null +++ b/api/src/paths/reference/search/environment.ts @@ -0,0 +1,164 @@ +import { RequestHandler } from 'express'; +import { Operation } from 'express-openapi'; +import { getAPIUserDBConnection } from '../../../database/db'; +import { CodeService } from '../../../services/code-service'; +import { getLogger } from '../../../utils/logger'; + +const defaultLog = getLogger('paths/code'); + +export const GET: Operation = [findSubcountEnvironments()]; + +GET.apiDoc = { + description: 'Find subcount environment data.', + tags: ['reference'], + parameters: [ + { + in: 'query', + name: 'searchTerm', + schema: { + type: 'string' + }, + required: true + } + ], + responses: { + 200: { + description: 'Subcount environment data response object.', + content: { + 'application/json': { + schema: { + type: 'object', + additionalProperties: false, + properties: { + qualitative_environments: { + type: 'array', + items: { + type: 'object', + additionalProperties: false, + required: ['environment_qualitative_id', 'name', 'description', 'options'], + properties: { + environment_qualitative_id: { + type: 'string', + format: 'uuid' + }, + name: { + type: 'string' + }, + description: { + type: 'string', + nullable: true + }, + options: { + type: 'array', + items: { + type: 'object', + additionalProperties: false, + required: ['environment_qualitative_option_id', 'name', 'description'], + properties: { + environment_qualitative_option_id: { + type: 'string', + format: 'uuid' + }, + environment_qualitative_id: { + type: 'string', + format: 'uuid' + }, + name: { + type: 'string' + }, + description: { + type: 'string', + nullable: true + } + } + } + } + } + } + }, + quantitative_environments: { + type: 'array', + items: { + type: 'object', + additionalProperties: false, + required: ['environment_quantitative_id', 'name', 'description', 'min', 'max', 'unit'], + properties: { + environment_quantitative_id: { + type: 'string', + format: 'uuid' + }, + name: { + type: 'string' + }, + description: { + type: 'string', + nullable: true + }, + min: { + type: 'integer', + nullable: true + }, + max: { + type: 'integer', + nullable: true + }, + unit: { + type: 'string', + nullable: true + } + } + } + } + } + } + } + } + }, + 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' + } + } +}; + +/** + * Find all subcount environments based on the given search term. + * + * @returns {RequestHandler} + */ +export function findSubcountEnvironments(): RequestHandler { + return async (req, res) => { + const connection = getAPIUserDBConnection(); + + try { + const searchTerm = String(req.query.searchTerm); + + await connection.open(); + + const codeService = new CodeService(connection); + + const response = await codeService.findSubcountEnvironments([searchTerm]); + + await connection.commit(); + + return res.status(200).json(response); + } catch (error) { + defaultLog.error({ label: 'findSubcountEnvironments', message: 'error', error }); + await connection.rollback(); + throw error; + } finally { + connection.release(); + } + }; +} diff --git a/api/src/repositories/observation-repository.ts b/api/src/repositories/observation-repository.ts index 7f40db9b98..66f2d37cbf 100644 --- a/api/src/repositories/observation-repository.ts +++ b/api/src/repositories/observation-repository.ts @@ -6,6 +6,10 @@ import { getLogger } from '../utils/logger'; import { GeoJSONPointZodSchema } from '../zod-schema/geoJsonZodSchema'; import { ApiPaginationOptions } from '../zod-schema/pagination'; import { BaseRepository } from './base-repository'; +import { + ObservationSubCountQualitativeEnvironmentRecord, + ObservationSubCountQuantitativeEnvironmentRecord +} from './observation-subcount-environment-repository'; import { ObservationSubCountQualitativeMeasurementRecord, ObservationSubCountQuantitativeMeasurementRecord @@ -56,11 +60,25 @@ const ObservationSubcountQuantitativeMeasurementObject = ObservationSubCountQuan value: true }); +const ObservationSubcountQualitativeEnvironmentObject = ObservationSubCountQualitativeEnvironmentRecord.pick({ + observation_subcount_qualitative_environment_id: true, + environment_qualitative_id: true, + environment_qualitative_option_id: true +}); + +const ObservationSubcountQuantitativeEnvironmentObject = ObservationSubCountQuantitativeEnvironmentRecord.pick({ + observation_subcount_quantitative_environment_id: true, + environment_quantitative_id: true, + value: true +}); + const ObservationSubcountObject = z.object({ observation_subcount_id: ObservationSubCountRecord.shape.observation_subcount_id, subcount: ObservationSubCountRecord.shape.subcount, qualitative_measurements: z.array(ObservationSubcountQualitativeMeasurementObject), - quantitative_measurements: z.array(ObservationSubcountQuantitativeMeasurementObject) + quantitative_measurements: z.array(ObservationSubcountQuantitativeMeasurementObject), + qualitative_environments: z.array(ObservationSubcountQualitativeEnvironmentObject), + quantitative_environments: z.array(ObservationSubcountQuantitativeEnvironmentObject) }); const ObservationSubcountsObject = z.object({ @@ -375,6 +393,56 @@ export class ObservationRepository extends BaseRepository { }) .groupBy('observation_subcount_id') ) + // Get all qualitative environments for all subcounts associated to all observations for the survey + .with( + 'w_qualitative_environments', + knex + .select( + 'observation_subcount_id', + knex.raw(` + json_agg(json_build_object( + 'observation_subcount_qualitative_environment_id', observation_subcount_qualitative_environment_id, + 'environment_qualitative_id', environment_qualitative_id, + 'environment_qualitative_option_id', environment_qualitative_option_id + )) as qualitative_environments + `) + ) + .from('observation_subcount_qualitative_environment') + .whereIn('observation_subcount_id', (qb1) => { + qb1 + .select('observation_subcount_id') + .from('observation_subcount') + .whereIn('survey_observation_id', (qb2) => { + qb2.select('survey_observation_id').from('survey_observation').where('survey_id', surveyId); + }); + }) + .groupBy('observation_subcount_id') + ) + // Get all quantitative environments for all subcounts associated to all observations for the survey + .with( + 'w_quantitative_environments', + knex + .select( + 'observation_subcount_id', + knex.raw(` + json_agg(json_build_object( + 'observation_subcount_quantitative_environment_id', observation_subcount_quantitative_environment_id, + 'environment_quantitative_id', environment_quantitative_id, + 'value', value + )) as quantitative_environments + `) + ) + .from('observation_subcount_quantitative_environment') + .whereIn('observation_subcount_id', (qb1) => { + qb1 + .select('observation_subcount_id') + .from('observation_subcount') + .whereIn('survey_observation_id', (qb2) => { + qb2.select('survey_observation_id').from('survey_observation').where('survey_id', surveyId); + }); + }) + .groupBy('observation_subcount_id') + ) // Rollup the subcount records into an array of objects for each observation .with( 'w_subcounts', @@ -386,7 +454,9 @@ export class ObservationRepository extends BaseRepository { 'observation_subcount_id', observation_subcount.observation_subcount_id, 'subcount', subcount, 'qualitative_measurements', COALESCE(w_qualitative_measurements.qualitative_measurements, '[]'::json), - 'quantitative_measurements', COALESCE(w_quantitative_measurements.quantitative_measurements, '[]'::json) + 'quantitative_measurements', COALESCE(w_quantitative_measurements.quantitative_measurements, '[]'::json), + 'qualitative_environments', COALESCE(w_qualitative_environments.qualitative_environments, '[]'::json), + 'quantitative_environments', COALESCE(w_quantitative_environments.quantitative_environments, '[]'::json) )) as subcounts `) ) @@ -401,6 +471,16 @@ export class ObservationRepository extends BaseRepository { 'observation_subcount.observation_subcount_id', 'w_quantitative_measurements.observation_subcount_id' ) + .leftJoin( + 'w_qualitative_environments', + 'observation_subcount.observation_subcount_id', + 'w_qualitative_environments.observation_subcount_id' + ) + .leftJoin( + 'w_quantitative_environments', + 'observation_subcount.observation_subcount_id', + 'w_quantitative_environments.observation_subcount_id' + ) .whereIn( 'survey_observation_id', knex('survey_observation').select('survey_observation_id').where('survey_id', surveyId) diff --git a/api/src/repositories/observation-subcount-environment-repository.ts b/api/src/repositories/observation-subcount-environment-repository.ts new file mode 100644 index 0000000000..755800f629 --- /dev/null +++ b/api/src/repositories/observation-subcount-environment-repository.ts @@ -0,0 +1,478 @@ +import SQL from 'sql-template-strings'; +import { z } from 'zod'; +import { getKnex } from '../database/db'; +import { BaseRepository } from './base-repository'; + +// Environment unit type definition. +export const EnvironmentUnit = z.enum([ + // Should be kept in sync with the database table `environment_unit` + 'millimeter', + 'centimeter', + 'meter', + 'milligram', + 'gram', + 'kilogram', + 'percent', + 'celsius', + 'ppt', + 'SCF', + 'degrees', + 'pH' +]); +export type EnvironmentUnit = z.infer; + +// Qualitative environment option type definition. +const QualitativeEnvironmentOption = z.object({ + environment_qualitative_option_id: z.string().uuid(), + environment_qualitative_id: z.string().uuid(), + name: z.string(), + description: z.string().nullable() +}); +export type QualitativeEnvironmentOption = z.infer; + +// Qualitative environment type definition. +export const QualitativeEnvironmentTypeDefinition = z.object({ + environment_qualitative_id: z.string().uuid(), + name: z.string(), + description: z.string().nullable(), + options: z.array(QualitativeEnvironmentOption) +}); +export type QualitativeEnvironmentTypeDefinition = z.infer; + +// Quantitative environment type definition. +const QuantitativeEnvironmentTypeDefinition = z.object({ + environment_quantitative_id: z.string().uuid(), + name: z.string(), + description: z.string().nullable(), + min: z.number().nullable(), + max: z.number().nullable(), + unit: EnvironmentUnit.nullable() +}); +export type QuantitativeEnvironmentTypeDefinition = z.infer; + +/** + * Mixed environment columns type definition. + */ +export type EnvironmentType = { + qualitative_environments: QualitativeEnvironmentTypeDefinition[]; + quantitative_environments: QuantitativeEnvironmentTypeDefinition[]; +}; + +export const ObservationSubCountQualitativeEnvironmentRecord = z.object({ + observation_subcount_qualitative_environment_id: z.number(), + observation_subcount_id: z.number(), + environment_qualitative_id: z.string().uuid(), + environment_qualitative_option_id: z.string().uuid(), + create_date: z.string(), + create_user: z.number(), + update_date: z.string().nullable(), + update_user: z.number().nullable(), + revision_count: z.number() +}); +export type ObservationSubCountQualitativeEnvironmentRecord = z.infer< + typeof ObservationSubCountQualitativeEnvironmentRecord +>; + +export const ObservationSubCountQuantitativeEnvironmentRecord = z.object({ + observation_subcount_quantitative_environment_id: z.number(), + observation_subcount_id: z.number(), + environment_quantitative_id: z.string().uuid(), + value: z.number(), + create_date: z.string(), + create_user: z.number(), + update_date: z.string().nullable(), + update_user: z.number().nullable(), + revision_count: z.number() +}); +export type ObservationSubCountQuantitativeEnvironmentRecord = z.infer< + typeof ObservationSubCountQuantitativeEnvironmentRecord +>; + +export interface InsertObservationSubCountQualitativeEnvironmentRecord { + observation_subcount_id: number; + environment_qualitative_id: string; + environment_qualitative_option_id: string; +} + +export interface InsertObservationSubCountQuantitativeEnvironmentRecord { + observation_subcount_id: number; + environment_quantitative_id: string; + value: number; +} + +export class ObservationSubCountEnvironmentRepository extends BaseRepository { + /** + * Insert qualitative environment records. + * + * @param {InsertObservationSubCountQualitativeEnvironmentRecord[]} record + * @return {*} {Promise} + * @memberof ObservationSubCountEnvironmentRepository + */ + async insertObservationQualitativeEnvironmentRecords( + record: InsertObservationSubCountQualitativeEnvironmentRecord[] + ): Promise { + const qb = getKnex() + .queryBuilder() + .insert(record) + .into('observation_subcount_qualitative_environment') + .returning('*'); + + const response = await this.connection.knex(qb, ObservationSubCountQualitativeEnvironmentRecord); + + return response.rows; + } + + /** + * Insert quantitative environment records. + * + * @param {InsertObservationSubCountQuantitativeEnvironmentRecord[]} record + * @return {*} {Promise} + * @memberof ObservationSubCountEnvironmentRepository + */ + async insertObservationQuantitativeEnvironmentRecords( + record: InsertObservationSubCountQuantitativeEnvironmentRecord[] + ): Promise { + const qb = getKnex() + .queryBuilder() + .insert(record) + .into('observation_subcount_quantitative_environment') + .returning('*'); + + const response = await this.connection.knex(qb, ObservationSubCountQuantitativeEnvironmentRecord); + + return response.rows; + } + + /** + * Delete all environment records for a given survey and set of survey observation ids. + * + * @param {number} surveyId + * @param {number[]} surveyObservationId + * @memberof ObservationSubCountEnvironmentRepository + */ + async deleteObservationEnvironments(surveyId: number, surveyObservationId: number[]) { + await this.deleteObservationQualitativeEnvironmentRecordsForSurveyObservationIds(surveyObservationId, surveyId); + await this.deleteObservationQuantitativeEnvironmentRecordsForSurveyObservationIds(surveyObservationId, surveyId); + } + + /** + * Get all distinct qualitative environment type definition records for all unique qualitative environment records + * associated to a given survey. + * + * @param {number} surveyId + * @return {*} {Promise} + * @memberof ObservationSubCountEnvironmentRepository + */ + async getQualitativeEnvironmentTypeDefinitions(surveyId: number): Promise { + const sqlStatement = SQL` + WITH w_observation_subcount_qualitative_environment AS ( + SELECT DISTINCT + environment_qualitative_id + FROM + survey_observation + LEFT JOIN observation_subcount ON survey_observation.survey_observation_id = observation_subcount.survey_observation_id + LEFT JOIN observation_subcount_qualitative_environment ON observation_subcount.observation_subcount_id = observation_subcount_qualitative_environment.observation_subcount_id + WHERE + survey_observation.survey_id = ${surveyId} + ) + SELECT + environment_qualitative.environment_qualitative_id, + environment_qualitative.name, + environment_qualitative.description, + json_agg( + json_build_object( + 'environment_qualitative_option_id', environment_qualitative_option.environment_qualitative_option_id, + 'environment_qualitative_id', environment_qualitative_option.environment_qualitative_id, + 'name', environment_qualitative_option.name, + 'description', environment_qualitative_option.description + ) + ) AS options + FROM + w_observation_subcount_qualitative_environment + INNER JOIN environment_qualitative ON environment_qualitative.environment_qualitative_id = w_observation_subcount_qualitative_environment.environment_qualitative_id + INNER JOIN environment_qualitative_option ON environment_qualitative.environment_qualitative_id = environment_qualitative_option.environment_qualitative_id + GROUP BY + environment_qualitative.environment_qualitative_id, + environment_qualitative.name, + environment_qualitative.description; + `; + + const response = await this.connection.sql(sqlStatement, QualitativeEnvironmentTypeDefinition); + + return response.rows; + } + + /** + * Get all distinct quantitative environment type definition records for all unique quantitative environments for a + * given survey. + * + * @param {number} surveyId + * @return {*} {Promise} + * @memberof ObservationSubCountEnvironmentRepository + */ + async getQuantitativeEnvironmentTypeDefinitions(surveyId: number): Promise { + const sqlStatement = SQL` + SELECT + environment_quantitative.environment_quantitative_id, + environment_quantitative.name, + environment_quantitative.description, + environment_quantitative.min, + environment_quantitative.max, + environment_quantitative.unit + FROM + survey_observation + INNER JOIN observation_subcount ON + survey_observation.survey_observation_id = observation_subcount.survey_observation_id + INNER JOIN observation_subcount_quantitative_environment + ON observation_subcount.observation_subcount_id = observation_subcount_quantitative_environment.observation_subcount_id + INNER JOIN environment_quantitative + ON observation_subcount_quantitative_environment.environment_quantitative_id = environment_quantitative.environment_quantitative_id + WHERE + survey_observation.survey_id = ${surveyId}; + `; + + const response = await this.connection.sql(sqlStatement, QuantitativeEnvironmentTypeDefinition); + + return response.rows; + } + + /** + * Find qualitative environment type definitions for the given search terms. + * + * @param {string[]} searchTerms + * @return {*} {Promise} + * @memberof ObservationSubCountEnvironmentRepository + */ + async findQualitativeEnvironmentTypeDefinitions( + searchTerms: string[] + ): Promise { + const knex = getKnex(); + + const queryBuilder = knex + .queryBuilder() + .select( + 'environment_qualitative.environment_qualitative_id', + 'environment_qualitative.name', + 'environment_qualitative.description', + knex.raw(` + COALESCE( + json_agg( + CASE + WHEN environment_qualitative_option.environment_qualitative_option_id IS NOT NULL THEN + json_build_object( + 'environment_qualitative_option_id', environment_qualitative_option.environment_qualitative_option_id, + 'environment_qualitative_id', environment_qualitative.environment_qualitative_id, + 'name', environment_qualitative_option.name, + 'description', environment_qualitative_option.description + ) + END + ) FILTER ( + WHERE environment_qualitative_option.environment_qualitative_option_id IS NOT NULL + ), + '[]'::json + ) AS options + `) + ) + .from('environment_qualitative') + .leftJoin( + 'environment_qualitative_option', + 'environment_qualitative_option.environment_qualitative_id', + '=', + 'environment_qualitative.environment_qualitative_id' + ); + + for (const searchTerm of searchTerms) { + queryBuilder + .where('environment_qualitative.name', 'ILIKE', `%${searchTerm}%`) + .orWhere('environment_qualitative.description', 'ILIKE', `%${searchTerm}%`); + } + + queryBuilder.groupBy( + 'environment_qualitative.environment_qualitative_id', + 'environment_qualitative.name', + 'environment_qualitative.description' + ); + + const response = await this.connection.knex(queryBuilder, QualitativeEnvironmentTypeDefinition); + + return response.rows; + } + + /** + * Find quantitative environment type definitions for the given search terms. + * + * @param {string[]} searchTerms + * @return {*} {Promise} + * @memberof ObservationSubCountEnvironmentRepository + */ + async findQuantitativeEnvironmentTypeDefinitions( + searchTerms: string[] + ): Promise { + const queryBuilder = getKnex() + .select( + 'environment_quantitative.environment_quantitative_id', + 'environment_quantitative.name', + 'environment_quantitative.description', + 'environment_quantitative.min', + 'environment_quantitative.max', + 'environment_quantitative.unit' + ) + .from('environment_quantitative') + .whereIn( + 'environment_quantitative.name', + searchTerms.map((term) => `%${term}%`) + ) + .orWhereIn( + 'environment_quantitative.description', + searchTerms.map((term) => `%${term}%`) + ); + + const response = await this.connection.knex(queryBuilder, QuantitativeEnvironmentTypeDefinition); + + return response.rows; + } + + /** + * Delete all qualitative environment records for a given survey and set of survey observation ids. + * + * @param {number[]} surveyObservationId + * @param {number} surveyId + * @return {*} {Promise} + * @memberof ObservationSubCountEnvironmentRepository + */ + async deleteObservationQualitativeEnvironmentRecordsForSurveyObservationIds( + surveyObservationId: number[], + surveyId: number + ): Promise { + const qb = getKnex() + .queryBuilder() + .delete() + .from('observation_subcount_qualitative_environment') + .using(['observation_subcount', 'survey_observation']) + .whereRaw( + 'observation_subcount_qualitative_environment.observation_subcount_id = observation_subcount.observation_subcount_id' + ) + .whereRaw('observation_subcount.survey_observation_id = survey_observation.survey_observation_id') + .andWhere(`survey_observation.survey_id`, surveyId) + .whereIn('survey_observation.survey_observation_id', surveyObservationId); + const response = await this.connection.knex(qb); + + return response.rowCount ?? 0; + } + + /** + * Delete all quantitative environment records for a given survey and set of survey observation ids. + * + * @param {number[]} surveyObservationId + * @param {number} surveyId + * @return {*} {Promise} + * @memberof ObservationSubCountEnvironmentRepository + */ + async deleteObservationQuantitativeEnvironmentRecordsForSurveyObservationIds( + surveyObservationId: number[], + surveyId: number + ): Promise { + const qb = getKnex() + .queryBuilder() + .delete() + .from('observation_subcount_quantitative_environment') + .using(['observation_subcount', 'survey_observation']) + .whereRaw( + 'observation_subcount_quantitative_environment.observation_subcount_id = observation_subcount.observation_subcount_id' + ) + .whereRaw('observation_subcount.survey_observation_id = survey_observation.survey_observation_id') + .andWhere(`survey_observation.survey_id`, surveyId) + .whereIn('survey_observation.survey_observation_id', surveyObservationId); + + const response = await this.connection.knex(qb); + + return response.rowCount ?? 0; + } + + /** + * Delete all environment records, for all observation records, for a given survey and set of environment ids. + * + * @param {number} surveyId + * @param {{ + * environment_qualitative_id: string[]; + * environment_quantitative_id: string[]; + * }} environmentIds + * @return {*} {Promise} + * @memberof ObservationSubCountEnvironmentRepository + */ + async deleteEnvironmentsForEnvironmentIds( + surveyId: number, + environmentIds: { + environment_qualitative_id: string[]; + environment_quantitative_id: string[]; + } + ): Promise { + await Promise.all([ + this.deleteQualitativeEnvironmentForEnvironmentIds(surveyId, environmentIds.environment_qualitative_id), + this.deleteQuantitativeEnvironmentForEnvironmentIds(surveyId, environmentIds.environment_quantitative_id) + ]); + } + + /** + * Delete all qualitative environment records, for all observation records, for a given survey and set of environment + * qualitative ids. + * + * @param {number} surveyId + * @param {string[]} environment_qualitative_id + * @return {*} {Promise} + * @memberof ObservationSubCountEnvironmentRepository + */ + async deleteQualitativeEnvironmentForEnvironmentIds( + surveyId: number, + environment_qualitative_ids: string[] + ): Promise { + const qb = getKnex() + .queryBuilder() + .delete() + .from('observation_subcount_qualitative_environment') + .using(['observation_subcount', 'survey_observation']) + .whereRaw( + 'observation_subcount_qualitative_environment.observation_subcount_id = observation_subcount.observation_subcount_id' + ) + .whereRaw('observation_subcount.survey_observation_id = survey_observation.survey_observation_id') + .andWhere('survey_observation.survey_id', surveyId) + .whereIn('observation_subcount_qualitative_environment.environment_qualitative_id', environment_qualitative_ids); + + const response = await this.connection.knex(qb); + + return response.rowCount ?? 0; + } + + /** + * Delete all quantitative environment records, for all observation records, for a given survey and set of environment + * quantitative ids. + * + * @param {number} surveyId + * @param {string[]} environment_quantitative_id + * @return {*} {Promise} + * @memberof ObservationSubCountEnvironmentRepository + */ + async deleteQuantitativeEnvironmentForEnvironmentIds( + surveyId: number, + environment_quantitative_ids: string[] + ): Promise { + const qb = getKnex() + .queryBuilder() + .delete() + .from('observation_subcount_quantitative_environment') + .using(['observation_subcount', 'survey_observation']) + .whereRaw( + 'observation_subcount_quantitative_environment.observation_subcount_id = observation_subcount.observation_subcount_id' + ) + .whereRaw('observation_subcount.survey_observation_id = survey_observation.survey_observation_id') + .andWhere('survey_observation.survey_id', surveyId) + .whereIn( + 'observation_subcount_quantitative_environment.environment_quantitative_id', + environment_quantitative_ids + ); + + const response = await this.connection.knex(qb); + + return response.rowCount ?? 0; + } +} diff --git a/api/src/repositories/observation-subcount-measurement-repository.ts b/api/src/repositories/observation-subcount-measurement-repository.ts index 4f09da70bc..f82358222d 100644 --- a/api/src/repositories/observation-subcount-measurement-repository.ts +++ b/api/src/repositories/observation-subcount-measurement-repository.ts @@ -43,6 +43,7 @@ export interface InsertObservationSubCountQuantitativeMeasurementRecord { critterbase_taxon_measurement_id: string; value: number; } + export class ObservationSubCountMeasurementRepository extends BaseRepository { async insertObservationQualitativeMeasurementRecords( record: InsertObservationSubCountQualitativeMeasurementRecord[] @@ -70,6 +71,13 @@ export class ObservationSubCountMeasurementRepository extends BaseRepository { return response.rows; } + /** + * Deletes all observation measurements for a given survey and set of survey observation ids. + * + * @param {number} surveyId + * @param {number[]} surveyObservationId + * @memberof ObservationSubCountMeasurementRepository + */ async deleteObservationMeasurements(surveyId: number, surveyObservationId: number[]) { await this.deleteObservationQualitativeMeasurementRecordsForSurveyObservationIds(surveyObservationId, surveyId); await this.deleteObservationQuantitativeMeasurementRecordsForSurveyObservationIds(surveyObservationId, surveyId); diff --git a/api/src/repositories/sample-period-repository.ts b/api/src/repositories/sample-period-repository.ts index a86b3bd6cc..15279a85aa 100644 --- a/api/src/repositories/sample-period-repository.ts +++ b/api/src/repositories/sample-period-repository.ts @@ -154,7 +154,7 @@ export class SamplePeriodRepository extends BaseRepository { end_time = ${samplePeriod.end_time || null} FROM survey_sample_method AS ssm - JOIN + INNER JOIN survey_sample_site AS sss ON ssm.survey_sample_site_id = sss.survey_sample_site_id WHERE ssp.survey_sample_method_id = ssm.survey_sample_method_id @@ -230,11 +230,11 @@ export class SamplePeriodRepository extends BaseRepository { ssp FROM survey_sample_period AS ssp - JOIN + INNER JOIN survey_sample_method AS ssm ON ssp.survey_sample_method_id = ssm.survey_sample_method_id - JOIN + INNER JOIN survey_sample_site AS sss ON ssm.survey_sample_site_id = sss.survey_sample_site_id diff --git a/api/src/services/code-service.ts b/api/src/services/code-service.ts index 266ae2310d..55c1481250 100644 --- a/api/src/services/code-service.ts +++ b/api/src/services/code-service.ts @@ -1,7 +1,9 @@ import { IDBConnection } from '../database/db'; import { CodeRepository, IAllCodeSets } from '../repositories/code-repository'; +import { EnvironmentType } from '../repositories/observation-subcount-environment-repository'; import { getLogger } from '../utils/logger'; import { DBService } from './db-service'; +import { ObservationSubCountEnvironmentService } from './observation-subcount-environment-service'; const defaultLog = getLogger('services/code-queries'); @@ -89,4 +91,27 @@ export class CodeService extends DBService { method_response_metrics }; } + + /** + * Find qualitative and quantitative environments that match the given search terms. + * + * @param {string[]} searchTerms + * @return {*} {Promise} + * @memberof CodeService + */ + async findSubcountEnvironments(searchTerms: string[]): Promise { + defaultLog.debug({ message: 'getEnvironments' }); + + const observationSubCountEnvironmentService = new ObservationSubCountEnvironmentService(this.connection); + + const [qualitative_environments, quantitative_environments] = await Promise.all([ + await observationSubCountEnvironmentService.findQualitativeEnvironmentTypeDefinitions(searchTerms), + await observationSubCountEnvironmentService.findQuantitativeEnvironmentTypeDefinitions(searchTerms) + ]); + + return { + qualitative_environments, + quantitative_environments + }; + } } diff --git a/api/src/services/critterbase-service.ts b/api/src/services/critterbase-service.ts index 96ef217afd..351cdbef88 100644 --- a/api/src/services/critterbase-service.ts +++ b/api/src/services/critterbase-service.ts @@ -182,7 +182,6 @@ export type CBQuantitativeMeasurementTypeDefinition = z.infer { const mockSupplementaryData = { observationCount: 2, qualitative_measurements: [], - quantitative_measurements: [] + quantitative_measurements: [], + qualitative_environments: [], + quantitative_environments: [] }; const getSurveyObservationsStub = sinon @@ -177,6 +179,10 @@ describe('ObservationService', () => { .stub(SubCountService.prototype, 'getMeasurementTypeDefinitionsForSurvey') .resolves({ qualitative_measurements: [], quantitative_measurements: [] }); + const getEnvironmentTypeDefinitionsForSurveyStub = sinon + .stub(SubCountService.prototype, 'getEnvironmentTypeDefinitionsForSurvey') + .resolves({ qualitative_environments: [], quantitative_environments: [] }); + const surveyId = 1; const observationService = new ObservationService(mockDBConnection); @@ -188,6 +194,7 @@ describe('ObservationService', () => { expect(getSurveyObservationsStub).to.be.calledOnceWith(surveyId); expect(getSurveyObservationCountStub).to.be.calledOnceWith(surveyId); expect(getMeasurementTypeDefinitionsForSurveyStub).to.be.calledOnceWith(surveyId); + expect(getEnvironmentTypeDefinitionsForSurveyStub).to.be.calledOnceWith(surveyId); expect(response).to.eql({ surveyObservations: [ { diff --git a/api/src/services/observation-service.ts b/api/src/services/observation-service.ts index 67a02b3dee..b9b889535f 100644 --- a/api/src/services/observation-service.ts +++ b/api/src/services/observation-service.ts @@ -1,4 +1,3 @@ -import xlsx from 'xlsx'; import { IDBConnection } from '../database/db'; import { ApiGeneralError } from '../errors/api-error'; import { @@ -10,6 +9,12 @@ import { ObservationSubmissionRecord, UpdateObservation } from '../repositories/observation-repository'; +import { + InsertObservationSubCountQualitativeEnvironmentRecord, + InsertObservationSubCountQuantitativeEnvironmentRecord, + QualitativeEnvironmentTypeDefinition, + QuantitativeEnvironmentTypeDefinition +} from '../repositories/observation-subcount-environment-repository'; import { InsertObservationSubCountQualitativeMeasurementRecord, InsertObservationSubCountQuantitativeMeasurementRecord @@ -18,24 +23,38 @@ import { SamplePeriodHierarchyIds } from '../repositories/sample-period-reposito import { generateS3FileKey, getFileFromS3 } from '../utils/file-utils'; import { getLogger } from '../utils/logger'; import { parseS3File } from '../utils/media/media-utils'; -import { DEFAULT_XLSX_SHEET_NAME } from '../utils/media/xlsx/xlsx-file'; import { - constructWorksheets, - constructXLSXWorkbook, - findMeasurementFromTsnMeasurements, - getCBMeasurementsFromTSN, - getCBMeasurementsFromWorksheet, - getMeasurementColumnNameFromWorksheet, - getWorksheetRowObjects, + EnvironmentNameTypeDefinitionMap, + getEnvironmentColumnsTypeDefinitionMap, + getEnvironmentTypeDefinitionsFromColumnNames, + IEnvironmentDataToValidate, + isEnvironmentQualitativeTypeDefinition, + validateEnvironments +} from '../utils/observation-xlsx-utils/environment-column-utils'; +import { + getMeasurementColumnNames, + getMeasurementFromTsnMeasurementTypeDefinitionMap, + getTsnMeasurementTypeDefinitionMap, IMeasurementDataToValidate, isMeasurementCBQualitativeTypeDefinition, - IXLSXCSVValidator, - TsnMeasurementMap, - validateCsvFile, - validateCsvMeasurementColumns, - validateMeasurements, - validateWorksheetColumnTypes, - validateWorksheetHeaders + TsnMeasurementTypeDefinitionMap, + validateMeasurements +} from '../utils/observation-xlsx-utils/measurement-column-utils'; +import { + getCountFromRow, + getDateFromRow, + getLatitudeFromRow, + getLongitudeFromRow, + getNonStandardColumnNamesFromWorksheet, + getTimeFromRow, + getTsnFromRow, + observationStandardColumnValidator +} from '../utils/observation-xlsx-utils/standard-column-utils'; +import { + constructXLSXWorkbook, + getDefaultWorksheet, + getWorksheetRowObjects, + validateCsvFile } from '../utils/xlsx-utils/worksheet-utils'; import { ApiPaginationOptions } from '../zod-schema/pagination'; import { @@ -44,6 +63,7 @@ import { CritterbaseService } from './critterbase-service'; import { DBService } from './db-service'; +import { ObservationSubCountEnvironmentService } from './observation-subcount-environment-service'; import { ObservationSubCountMeasurementService } from './observation-subcount-measurement-service'; import { PlatformService } from './platform-service'; import { SamplePeriodService } from './sample-period-service'; @@ -51,30 +71,28 @@ import { SubCountService } from './subcount-service'; const defaultLog = getLogger('services/observation-service'); -const observationCSVColumnValidator: IXLSXCSVValidator = { - columnNames: ['ITIS_TSN', 'COUNT', 'DATE', 'TIME', 'LATITUDE', 'LONGITUDE'], - columnTypes: ['number', 'number', 'date', 'string', 'number', 'number'], - columnAliases: { - ITIS_TSN: ['TAXON', 'SPECIES', 'TSN'], - LATITUDE: ['LAT'], - LONGITUDE: ['LON', 'LONG', 'LNG'] - } -}; - export interface InsertSubCount { observation_subcount_id: number | null; subcount: number; - qualitative: { + qualitative_measurements: { measurement_id: string; measurement_option_id: string; }[]; - quantitative: { + quantitative_measurements: { measurement_id: string; measurement_value: number; }[]; + qualitative_environments: { + environment_qualitative_id: string; + environment_qualitative_option_id: string; + }[]; + quantitative_environments: { + environment_quantitative_id: string; + value: number; + }[]; } -export type InsertUpdateObservationsWithMeasurements = { +export type InsertUpdateObservations = { standardColumns: InsertObservation | UpdateObservation; subcounts: InsertSubCount[]; }; @@ -86,6 +104,8 @@ export type ObservationCountSupplementaryData = { export type ObservationMeasurementSupplementaryData = { qualitative_measurements: CBQualitativeMeasurementTypeDefinition[]; quantitative_measurements: CBQuantitativeMeasurementTypeDefinition[]; + qualitative_environments: QualitativeEnvironmentTypeDefinition[]; + quantitative_environments: QuantitativeEnvironmentTypeDefinition[]; }; export type AllObservationSupplementaryData = ObservationCountSupplementaryData & @@ -99,28 +119,6 @@ export class ObservationService extends DBService { this.observationRepository = new ObservationRepository(connection); } - /** - * Validates the given CSV file against the given column validator - * - * @param {xlsx.WorkSheet} xlsxWorksheets - * @param {IXLSXCSVValidator} columnValidator - * @return {*} {boolean} - * @memberof ObservationService - */ - validateCsvFile(xlsxWorksheets: xlsx.WorkSheet, columnValidator: IXLSXCSVValidator): boolean { - // Validate the worksheet headers - if (!validateWorksheetHeaders(xlsxWorksheets[DEFAULT_XLSX_SHEET_NAME], columnValidator)) { - return false; - } - - // Validate the worksheet column types - if (!validateWorksheetColumnTypes(xlsxWorksheets[DEFAULT_XLSX_SHEET_NAME], columnValidator)) { - return false; - } - - return true; - } - /** * Performs an upsert for all observation records belonging to the given survey, while removing * any records associated for the survey that aren't included in the given records, then @@ -150,16 +148,18 @@ export class ObservationService extends DBService { * Upserts the given observation records and their associated measurements. * * @param {number} surveyId - * @param {InsertUpdateObservationsWithMeasurements[]} observations + * @param {InsertUpdateObservations[]} observations * @return {*} {Promise} * @memberof ObservationService */ - async insertUpdateSurveyObservationsWithMeasurements( + async insertUpdateManualSurveyObservations( surveyId: number, - observations: InsertUpdateObservationsWithMeasurements[] + observations: InsertUpdateObservations[] ): Promise { const subCountService = new SubCountService(this.connection); const measurementService = new ObservationSubCountMeasurementService(this.connection); + const environmentService = new ObservationSubCountEnvironmentService(this.connection); + for (const observation of observations) { // Upsert observation standard columns const upsertedObservationRecord = await this.observationRepository.insertUpdateSurveyObservations( @@ -173,36 +173,56 @@ export class ObservationService extends DBService { // Delete old observation subcount records (critters, measurements and subcounts) await subCountService.deleteObservationSubCountRecords(surveyId, [surveyObservationId]); - // Insert observation subcount record (event) + // Insert observation subcount record const observationSubCountRecord = await subCountService.insertObservationSubCount({ survey_observation_id: surveyObservationId, subcount: observation.standardColumns.count }); - // Process currently treats all incoming data as source of truth, deletes all - if (observation.subcounts.length) { - for (const subcount of observation.subcounts) { - // TODO: Update process to fetch and find differences between incoming and existing data to only add, update or delete records as needed - if (subcount.qualitative.length) { - const qualitativeData: InsertObservationSubCountQualitativeMeasurementRecord[] = subcount.qualitative.map( - (item) => ({ - observation_subcount_id: observationSubCountRecord.observation_subcount_id, - critterbase_taxon_measurement_id: item.measurement_id, - critterbase_measurement_qualitative_option_id: item.measurement_option_id - }) - ); - await measurementService.insertObservationSubCountQualitativeMeasurement(qualitativeData); - } - - if (subcount.quantitative.length) { - const quantitativeData: InsertObservationSubCountQuantitativeMeasurementRecord[] = - subcount.quantitative.map((item) => ({ - observation_subcount_id: observationSubCountRecord.observation_subcount_id, - critterbase_taxon_measurement_id: item.measurement_id, - value: item.measurement_value - })); - await measurementService.insertObservationSubCountQuantitativeMeasurement(quantitativeData); - } + if (!observation.subcounts.length) { + return; + } + + for (const subcount of observation.subcounts) { + // TODO: Update process to fetch and find differences between incoming and existing data to only add, update or delete records as needed + if (subcount.qualitative_measurements.length) { + const qualitativeData: InsertObservationSubCountQualitativeMeasurementRecord[] = + subcount.qualitative_measurements.map((item) => ({ + observation_subcount_id: observationSubCountRecord.observation_subcount_id, + critterbase_taxon_measurement_id: item.measurement_id, + critterbase_measurement_qualitative_option_id: item.measurement_option_id + })); + await measurementService.insertObservationSubCountQualitativeMeasurement(qualitativeData); + } + + if (subcount.quantitative_measurements.length) { + const quantitativeData: InsertObservationSubCountQuantitativeMeasurementRecord[] = + subcount.quantitative_measurements.map((item) => ({ + observation_subcount_id: observationSubCountRecord.observation_subcount_id, + critterbase_taxon_measurement_id: item.measurement_id, + value: item.measurement_value + })); + await measurementService.insertObservationSubCountQuantitativeMeasurement(quantitativeData); + } + + if (subcount.qualitative_environments.length) { + const qualitativeData: InsertObservationSubCountQualitativeEnvironmentRecord[] = + subcount.qualitative_environments.map((item) => ({ + observation_subcount_id: observationSubCountRecord.observation_subcount_id, + environment_qualitative_id: item.environment_qualitative_id, + environment_qualitative_option_id: item.environment_qualitative_option_id + })); + await environmentService.insertObservationSubCountQualitativeEnvironment(qualitativeData); + } + + if (subcount.quantitative_environments.length) { + const quantitativeData: InsertObservationSubCountQuantitativeEnvironmentRecord[] = + subcount.quantitative_environments.map((item) => ({ + observation_subcount_id: observationSubCountRecord.observation_subcount_id, + environment_quantitative_id: item.environment_quantitative_id, + value: item.value + })); + await environmentService.insertObservationSubCountQuantitativeEnvironment(quantitativeData); } } } @@ -258,13 +278,16 @@ export class ObservationService extends DBService { const observationCount = await this.observationRepository.getSurveyObservationCount(surveyId); const subCountService = new SubCountService(this.connection); const measurementTypeDefinitions = await subCountService.getMeasurementTypeDefinitionsForSurvey(surveyId); + const environmentTypeDefinitions = await subCountService.getEnvironmentTypeDefinitionsForSurvey(surveyId); return { surveyObservations: surveyObservations, supplementaryObservationData: { observationCount, qualitative_measurements: measurementTypeDefinitions.qualitative_measurements, - quantitative_measurements: measurementTypeDefinitions.quantitative_measurements + quantitative_measurements: measurementTypeDefinitions.quantitative_measurements, + qualitative_environments: environmentTypeDefinitions.qualitative_environments, + quantitative_environments: environmentTypeDefinitions.quantitative_environments } }; } @@ -383,10 +406,13 @@ export class ObservationService extends DBService { } /** - * Processes a observation upload submission. This method receives an ID belonging to an - * observation submission, gets the CSV file associated with the submission, and appends - * all of the records in the CSV file to the observations for the survey. If the CSV - * file fails validation, this method fails. + * Processes an observation CSV file submission. + * + * This method: + * - Receives an id belonging to an observation submission, + * - Fetches the CSV file associated with the submission id + * - Validates the CSV file and its content, failing the entire process if any validation check fails + * - Appends all of the records in the CSV file to the observations for the survey. * * @param {number} surveyId * @param {number} submissionId @@ -401,16 +427,16 @@ export class ObservationService extends DBService { ): Promise { defaultLog.debug({ label: 'processObservationCsvSubmission', submissionId }); - // Step 1. Retrieve the observation submission record - const submission = await this.getObservationSubmissionById(surveyId, submissionId); + // Get the observation submission record + const observationSubmissionRecord = await this.getObservationSubmissionById(surveyId, submissionId); - // Step 2. Retrieve the S3 object containing the uploaded CSV file - const s3Object = await getFileFromS3(submission.key); + // Get the S3 object containing the uploaded CSV file + const s3Object = await getFileFromS3(observationSubmissionRecord.key); - // Step 3. Get the contents of the S3 object + // Get the csv file from the S3 object const mediaFile = parseS3File(s3Object); - // Step 4. Validate the CSV file + // Validate the CSV file mime type if (mediaFile.mimetype !== 'text/csv') { throw new Error('Failed to process file for importing observations. Invalid CSV file.'); } @@ -418,33 +444,89 @@ export class ObservationService extends DBService { // Construct the XLSX workbook const xlsxWorkBook = constructXLSXWorkbook(mediaFile); - // Construct the worksheets - const xlsxWorksheets = constructWorksheets(xlsxWorkBook); + // Get the default XLSX worksheet + const xlsxWorksheet = getDefaultWorksheet(xlsxWorkBook); - if (!validateCsvFile(xlsxWorksheets, observationCSVColumnValidator)) { + // Validate the standard columns in the CSV file + if (!validateCsvFile(xlsxWorksheet, observationStandardColumnValidator)) { throw new Error('Failed to process file for importing observations. Column validator failed.'); } - // Step 5. Validate Measurement data in CSV file - const service = new CritterbaseService({ + // Filter out the standard columns from the worksheet + const nonStandardColumnNames = getNonStandardColumnNamesFromWorksheet(xlsxWorksheet); + + // Get the worksheet row objects + const worksheetRowObjects = getWorksheetRowObjects(xlsxWorksheet); + + // VALIDATE MEASUREMENTS ----------------------------------------------------------------------------------------- + + // Validate the Measurement columns in CSV file + const critterBaseService = new CritterbaseService({ keycloak_guid: this.connection.systemUserGUID(), username: this.connection.systemUserIdentifier() }); - // reach out to critterbase for TSN Measurement data - const tsnMeasurements = await getCBMeasurementsFromWorksheet(xlsxWorksheets, service); + // Fetch all measurement type definitions from Critterbase for all unique TSNs + const tsns = worksheetRowObjects.map((row) => + String(row['ITIS_TSN'] ?? row['TSN'] ?? row['TAXON'] ?? row['SPECIES']) + ); - // collection additional measurement columns - const measurementColumns = getMeasurementColumnNameFromWorksheet(xlsxWorksheets, observationCSVColumnValidator); + const tsnMeasurementTypeDefinitionMap = await getTsnMeasurementTypeDefinitionMap(tsns, critterBaseService); - // Get the worksheet row objects - const worksheetRowObjects = getWorksheetRowObjects(xlsxWorksheets[DEFAULT_XLSX_SHEET_NAME]); - // Validate measurement data against - if (!validateCsvMeasurementColumns(worksheetRowObjects, measurementColumns, tsnMeasurements)) { + // Get all measurement columns names from the worksheet, that match a measurement in the TSN measurements + const measurementColumnNames = getMeasurementColumnNames(nonStandardColumnNames, tsnMeasurementTypeDefinitionMap); + + const measurementsToValidate: IMeasurementDataToValidate[] = worksheetRowObjects.flatMap((row) => { + return measurementColumnNames.map((columnName) => ({ + tsn: String(row['ITIS_TSN'] ?? row['TSN'] ?? row['TAXON'] ?? row['SPECIES']), + key: columnName, + value: row[columnName] + })); + }); + + // Validate measurement column data + if (!validateMeasurements(measurementsToValidate, tsnMeasurementTypeDefinitionMap)) { throw new Error('Failed to process file for importing observations. Measurement column validator failed.'); } + // VALIDATE ENVIRONMENTS ----------------------------------------------------------------------------------------- + + // Filter out the measurement columns from the non-standard columns. + // Note: This assumes that after filtering out both standard and measurement columns, the remaining columns are the + // environment columns + const environmentColumnNames = nonStandardColumnNames.filter( + (nonStandardColumnHeader) => !measurementColumnNames.includes(nonStandardColumnHeader) + ); + + const observationSubCountEnvironmentService = new ObservationSubCountEnvironmentService(this.connection); + + // Fetch all measurement type definitions from Critterbase for all unique environment column names in the CSV file + const environmentTypeDefinitions = await getEnvironmentTypeDefinitionsFromColumnNames( + environmentColumnNames, + observationSubCountEnvironmentService + ); + + const environmentColumnsTypeDefinitionMap = getEnvironmentColumnsTypeDefinitionMap( + environmentColumnNames, + environmentTypeDefinitions + ); + + const environmentsToValidate: IEnvironmentDataToValidate[] = worksheetRowObjects.flatMap((row) => { + return environmentColumnNames.map((columnName) => ({ + key: columnName, + value: row[columnName] + })); + }); + + // Validate environment column data + if (!validateEnvironments(environmentsToValidate, environmentColumnsTypeDefinitionMap)) { + throw new Error('Failed to process file for importing observations. Environment column validator failed.'); + } + + // ----------------------------------------------------------------------------------------- + let samplePeriodHierarchyIds: SamplePeriodHierarchyIds; + if (options?.surveySamplePeriodId) { const samplePeriodService = new SamplePeriodService(this.connection); samplePeriodHierarchyIds = await samplePeriodService.getSamplePeriodHierarchyIds( @@ -453,61 +535,75 @@ export class ObservationService extends DBService { ); } - // Step 6. Merge all the table rows into an array of InsertUpdateObservationsWithMeasurements[] - const newRowData: InsertUpdateObservationsWithMeasurements[] = worksheetRowObjects.map((row) => { + // Merge all the table rows into an array of InsertUpdateObservations[] + const newRowData: InsertUpdateObservations[] = worksheetRowObjects.map((row) => { const newSubcount: InsertSubCount = { observation_subcount_id: null, - subcount: row['COUNT'], - qualitative: [], - quantitative: [] + subcount: getCountFromRow(row), + qualitative_measurements: [], + quantitative_measurements: [], + qualitative_environments: [], + quantitative_environments: [] }; - const measurements = this._pullMeasurementsFromWorkSheetRowObject(row, measurementColumns, tsnMeasurements); - newSubcount.qualitative = measurements.qualitative; - newSubcount.quantitative = measurements.quantitative; + const measurements = this._pullMeasurementsFromWorkSheetRowObject( + row, + measurementColumnNames, + tsnMeasurementTypeDefinitionMap + ); + newSubcount.qualitative_measurements = measurements.qualitative_measurements; + newSubcount.quantitative_measurements = measurements.quantitative_measurements; + + const environments = this._pullEnvironmentsFromWorkSheetRowObject( + row, + environmentColumnNames, + environmentColumnsTypeDefinitionMap + ); + newSubcount.qualitative_environments = environments.qualitative_environments; + newSubcount.quantitative_environments = environments.quantitative_environments; return { standardColumns: { survey_id: surveyId, - itis_tsn: row['ITIS_TSN'] ?? row['TSN'] ?? row['TAXON'] ?? row['SPECIES'], + itis_tsn: getTsnFromRow(row), itis_scientific_name: null, survey_sample_site_id: samplePeriodHierarchyIds?.survey_sample_site_id ?? null, survey_sample_method_id: samplePeriodHierarchyIds?.survey_sample_method_id ?? null, survey_sample_period_id: samplePeriodHierarchyIds?.survey_sample_period_id ?? null, - latitude: row['LATITUDE'] ?? row['LAT'], - longitude: row['LONGITUDE'] ?? row['LON'] ?? row['LONG'] ?? row['LNG'], - count: row['COUNT'], - observation_time: row['TIME'], - observation_date: row['DATE'] + latitude: getLatitudeFromRow(row), + longitude: getLongitudeFromRow(row), + count: getCountFromRow(row), + observation_time: getTimeFromRow(row), + observation_date: getDateFromRow(row) }, subcounts: [newSubcount] }; }); - // Step 7. Insert new rows and return them - await this.insertUpdateSurveyObservationsWithMeasurements(surveyId, newRowData); + // Insert the parsed observation rows + await this.insertUpdateManualSurveyObservations(surveyId, newRowData); } /** * This function is a helper method for the `processObservationCsvSubmission` function. It will take row data from an uploaded CSV - * and find and connect the CSV measurement data with proper measurement taxon ids (UUIDs) from the TsnMeasurementMap passed in. + * and find and connect the CSV measurement data with proper measurement taxon ids (UUIDs) from the TsnMeasurementTypeDefinitionMap passed in. * Any qualitative and quantitative measurements found are returned to be inserted into the database. This function assumes that the * data in the CSV has already been validated. * * @param {Record} row A worksheet row object from a CSV that was uploaded for processing * @param {string[]} measurementColumns A list of the measurement columns found in a CSV uploaded - * @param {TsnMeasurementMap} tsnMeasurements Map of TSNs and their valid measurements - * @returns {*} Pick + * @param {TsnMeasurementTypeDefinitionMap} tsnMeasurements Map of TSNs and their valid measurements + * @return {*} {(Pick)} * @memberof ObservationService */ _pullMeasurementsFromWorkSheetRowObject( row: Record, measurementColumns: string[], - tsnMeasurements: TsnMeasurementMap - ): Pick { - const foundMeasurements: Pick = { - qualitative: [], - quantitative: [] + tsnMeasurements: TsnMeasurementTypeDefinitionMap + ): Pick { + const foundMeasurements: Pick = { + qualitative_measurements: [], + quantitative_measurements: [] }; measurementColumns.forEach((mColumn) => { @@ -523,8 +619,8 @@ export class ObservationService extends DBService { return; } - const measurement = findMeasurementFromTsnMeasurements( - String(row['ITIS_TSN'] ?? row['TSN'] ?? row['TAXON'] ?? row['SPECIES']), + const measurement = getMeasurementFromTsnMeasurementTypeDefinitionMap( + getTsnFromRow(row), mColumn, tsnMeasurements ); @@ -539,16 +635,20 @@ export class ObservationService extends DBService { const foundOption = measurement.options.find( (option) => option.option_label.toLowerCase() === String(rowData).toLowerCase() || - option.option_value === Number(rowData) + option.option_value === Number(rowData) || + option.qualitative_option_id === rowData ); - if (foundOption) { - foundMeasurements.qualitative.push({ - measurement_id: measurement.taxon_measurement_id, - measurement_option_id: foundOption.qualitative_option_id - }); + + if (!foundOption) { + return; } + + foundMeasurements.qualitative_measurements.push({ + measurement_id: measurement.taxon_measurement_id, + measurement_option_id: foundOption.qualitative_option_id + }); } else { - foundMeasurements.quantitative.push({ + foundMeasurements.quantitative_measurements.push({ measurement_id: measurement.taxon_measurement_id, measurement_value: Number(rowData) }); @@ -558,6 +658,59 @@ export class ObservationService extends DBService { return foundMeasurements; } + _pullEnvironmentsFromWorkSheetRowObject( + row: Record, + environmentColumns: string[], + environmentNameTypeDefinitionMap: EnvironmentNameTypeDefinitionMap + ): Pick { + const foundEnvironments: Pick = { + qualitative_environments: [], + quantitative_environments: [] + }; + + environmentColumns.forEach((mColumn) => { + // Ignore blank columns + if (!mColumn) { + return; + } + + const rowData = row[mColumn]; + + // Ignore empty rows + if (rowData === undefined) { + return; + } + + const environment = environmentNameTypeDefinitionMap.get(mColumn); + + // Ignore empty environments + if (!environment) { + return; + } + + // if environment is qualitative, find the option id + if (isEnvironmentQualitativeTypeDefinition(environment)) { + const foundOption = environment.options.find((option) => option.name === String(rowData)); + + if (!foundOption) { + return; + } + + foundEnvironments.qualitative_environments.push({ + environment_qualitative_id: foundOption.environment_qualitative_id, + environment_qualitative_option_id: foundOption.environment_qualitative_option_id + }); + } else { + foundEnvironments.quantitative_environments.push({ + environment_quantitative_id: environment.environment_quantitative_id, + value: Number(rowData) + }); + } + }); + + return foundEnvironments; + } + /** * Maps over an array of inserted/updated observation records in order to update its scientific * name to match its ITIS TSN. @@ -614,47 +767,54 @@ export class ObservationService extends DBService { } /** - * Validates given observations against measurement definitions found in Critterbase. - * This validation is all or nothing, any failed validation will return a false value and stop processing. + * Processes manual observation data. * - * @param {InsertUpdateObservationsWithMeasurements[]} observationRows The observations to validate + * This method: + * - Validates the given observations against measurement definitions found in Critterbase. + * - Validates the given observations against environment definitions found in SIMS. + * - If the observations are valid, the observations are inserted into the database. + * - Returns a boolean value indicating if the observations are valid. + * + * @param {InsertUpdateObservations[]} observationRows The observations to validate * @param {CritterbaseService} critterBaseService Used to collection measurement definitions to validate against - * @returns {*} boolean True: Observations are valid False: Observations are invalid + * @return {*} {Promise} `true` if the observations are valid, `false` otherwise + * @memberof ObservationService */ async validateSurveyObservations( - observationRows: InsertUpdateObservationsWithMeasurements[], + observationRows: InsertUpdateObservations[], critterBaseService: CritterbaseService ): Promise { - // Fetch measurement definitions from CritterBase - const tsns = observationRows.map((item: any) => String(item.standardColumns.itis_tsn)); - const tsnMeasurementsMap = await getCBMeasurementsFromTSN(tsns, critterBaseService); + // Fetch all measurement type definitions from Critterbase for all unique TSNs + const tsns = observationRows.map((row) => String(row.standardColumns.itis_tsn)); + const tsnMeasurementTypeDefinitionMap = await getTsnMeasurementTypeDefinitionMap(tsns, critterBaseService); // Map observation subcount data into objects to a IMeasurementDataToValidate array const measurementsToValidate: IMeasurementDataToValidate[] = observationRows.flatMap( - (item: InsertUpdateObservationsWithMeasurements) => { + (item: InsertUpdateObservations) => { return item.subcounts.flatMap((subcount) => { - const qualitativeValues = subcount.qualitative.map((qualitative) => { + const qualitativeMeasurementsToValidate = subcount.qualitative_measurements.map((qualitative_measurement) => { return { tsn: String(item.standardColumns.itis_tsn), - measurement_key: qualitative.measurement_id, - measurement_value: qualitative.measurement_option_id + key: qualitative_measurement.measurement_id, + value: qualitative_measurement.measurement_option_id }; }); - const quantitativeValues: IMeasurementDataToValidate[] = subcount.quantitative.map((quantitative) => { - return { - tsn: String(item.standardColumns.itis_tsn), - measurement_key: quantitative.measurement_id, - measurement_value: quantitative.measurement_value - }; - }); + const quantitativeMeasurementsToValidate: IMeasurementDataToValidate[] = + subcount.quantitative_measurements.map((quantitative_measurement) => { + return { + tsn: String(item.standardColumns.itis_tsn), + key: quantitative_measurement.measurement_id, + value: quantitative_measurement.measurement_value + }; + }); - return [...qualitativeValues, ...quantitativeValues]; + return [...qualitativeMeasurementsToValidate, ...quantitativeMeasurementsToValidate]; }); } ); // Validate measurement data against fetched measurement definition - return validateMeasurements(measurementsToValidate, tsnMeasurementsMap); + return validateMeasurements(measurementsToValidate, tsnMeasurementTypeDefinitionMap); } } diff --git a/api/src/services/observation-subcount-environment-service.ts b/api/src/services/observation-subcount-environment-service.ts new file mode 100644 index 0000000000..3211eb57d7 --- /dev/null +++ b/api/src/services/observation-subcount-environment-service.ts @@ -0,0 +1,126 @@ +import { IDBConnection } from '../database/db'; +import { + InsertObservationSubCountQualitativeEnvironmentRecord, + InsertObservationSubCountQuantitativeEnvironmentRecord, + ObservationSubCountEnvironmentRepository, + ObservationSubCountQualitativeEnvironmentRecord, + ObservationSubCountQuantitativeEnvironmentRecord, + QualitativeEnvironmentTypeDefinition, + QuantitativeEnvironmentTypeDefinition +} from '../repositories/observation-subcount-environment-repository'; +import { DBService } from './db-service'; + +export class ObservationSubCountEnvironmentService extends DBService { + observationSubCountEnvironmentRepository: ObservationSubCountEnvironmentRepository; + + constructor(connection: IDBConnection) { + super(connection); + this.observationSubCountEnvironmentRepository = new ObservationSubCountEnvironmentRepository(connection); + } + + /** + * Insert qualitative environment records. + * + * @param {InsertObservationSubCountQualitativeEnvironmentRecord[]} data + * @return {*} {Promise} + * @memberof ObservationSubCountEnvironmentService + */ + async insertObservationSubCountQualitativeEnvironment( + data: InsertObservationSubCountQualitativeEnvironmentRecord[] + ): Promise { + return this.observationSubCountEnvironmentRepository.insertObservationQualitativeEnvironmentRecords(data); + } + + /** + * Insert quantitative environment records. + * + * @param {InsertObservationSubCountQuantitativeEnvironmentRecord[]} data + * @return {*} {Promise} + * @memberof ObservationSubCountEnvironmentService + */ + async insertObservationSubCountQuantitativeEnvironment( + data: InsertObservationSubCountQuantitativeEnvironmentRecord[] + ): Promise { + return this.observationSubCountEnvironmentRepository.insertObservationQuantitativeEnvironmentRecords(data); + } + + /** + * Deletes all environments for a given survey and set of survey observation ids. + * + * @param {number} surveyId + * @param {number[]} surveyObservationId + * @memberof ObservationSubCountEnvironmentService + */ + async deleteObservationEnvironments(surveyId: number, surveyObservationId: number[]) { + await this.observationSubCountEnvironmentRepository.deleteObservationEnvironments(surveyId, surveyObservationId); + } + + /** + * Get all distinct environment qualitative type definitions for all qualitative environments for a given survey. + * + * @param {number} surveyId + * @return {*} {Promise} + * @memberof ObservationSubCountEnvironmentService + */ + async getQualitativeEnvironmentTypeDefinitions(surveyId: number): Promise { + return this.observationSubCountEnvironmentRepository.getQualitativeEnvironmentTypeDefinitions(surveyId); + } + + /** + * Get all distinct environment quantitative type definitions for all quantitative environments for a given survey. + * + * @param {number} surveyId + * @return {*} {Promise} + * @memberof ObservationSubCountEnvironmentService + */ + async getQuantitativeEnvironmentTypeDefinitions(surveyId: number): Promise { + return this.observationSubCountEnvironmentRepository.getQuantitativeEnvironmentTypeDefinitions(surveyId); + } + + /** + * Find environment qualitative type definitions for the given search terms. + * + * @param {string[]} searchTerms + * @return {*} {Promise} + * @memberof ObservationSubCountEnvironmentService + */ + async findQualitativeEnvironmentTypeDefinitions( + searchTerms: string[] + ): Promise { + return this.observationSubCountEnvironmentRepository.findQualitativeEnvironmentTypeDefinitions(searchTerms); + } + + /** + * Find environment quantitative type definitions for the given search terms. + * + * @param {string[]} searchTerms + * @return {*} {Promise} + * @memberof ObservationSubCountEnvironmentService + */ + async findQuantitativeEnvironmentTypeDefinitions( + searchTerms: string[] + ): Promise { + return this.observationSubCountEnvironmentRepository.findQuantitativeEnvironmentTypeDefinitions(searchTerms); + } + + /** + * Delete all environment records, for all observation records, for a given survey and set of environment ids. + * + * @param {number} surveyId + * @param {{ + * environment_qualitative_id: string[]; + * environment_quantitative_id: string[]; + * }} environmentIds + * @return {*} {Promise} + * @memberof ObservationSubCountEnvironmentService + */ + async deleteEnvironmentsForEnvironmentIds( + surveyId: number, + environmentIds: { + environment_qualitative_id: string[]; + environment_quantitative_id: string[]; + } + ): Promise { + return this.observationSubCountEnvironmentRepository.deleteEnvironmentsForEnvironmentIds(surveyId, environmentIds); + } +} diff --git a/api/src/services/observation-subcount-measurement-service.ts b/api/src/services/observation-subcount-measurement-service.ts index bc9472ad08..f1304d5021 100644 --- a/api/src/services/observation-subcount-measurement-service.ts +++ b/api/src/services/observation-subcount-measurement-service.ts @@ -16,18 +16,43 @@ export class ObservationSubCountMeasurementService extends DBService { this.observationSubCountMeasurementRepository = new ObservationSubCountMeasurementRepository(connection); } + /** + * Insert qualitative measurement records. + * + * @param {InsertObservationSubCountQualitativeMeasurementRecord[]} data + * @return {*} {Promise} + * @memberof ObservationSubCountMeasurementService + */ async insertObservationSubCountQualitativeMeasurement( data: InsertObservationSubCountQualitativeMeasurementRecord[] ): Promise { return this.observationSubCountMeasurementRepository.insertObservationQualitativeMeasurementRecords(data); } + /** + * Insert quantitative measurement records. + * + * @param {InsertObservationSubCountQuantitativeMeasurementRecord[]} data + * @return {*} {Promise} + * @memberof ObservationSubCountMeasurementService + */ async insertObservationSubCountQuantitativeMeasurement( data: InsertObservationSubCountQuantitativeMeasurementRecord[] ): Promise { return this.observationSubCountMeasurementRepository.insertObservationQuantitativeMeasurementRecords(data); } + /** + * Deletes all observation measurements for a given survey and set of survey observation ids. + * + * @param {number} surveyId + * @param {number[]} surveyObservationId + * @memberof ObservationSubCountMeasurementService + */ + async deleteObservationMeasurements(surveyId: number, surveyObservationId: number[]) { + await this.observationSubCountMeasurementRepository.deleteObservationMeasurements(surveyId, surveyObservationId); + } + /** * Get all distinct taxon_measurment_ids for all qualitative measurements for a given survey. * @@ -35,7 +60,7 @@ export class ObservationSubCountMeasurementService extends DBService { * @return {*} {Promise} * @memberof ObservationSubCountMeasurementService */ - async getObservationSubCountQualitativeTaxonMeasurements(surveyId: number): Promise { + async getObservationSubCountQualitativeTaxonMeasurementIds(surveyId: number): Promise { return this.observationSubCountMeasurementRepository.getObservationSubCountQualitativeTaxonMeasurementIds(surveyId); } @@ -46,7 +71,7 @@ export class ObservationSubCountMeasurementService extends DBService { * @return {*} {Promise} * @memberof ObservationSubCountMeasurementService */ - async getObservationSubCountQuantitativeTaxonMeasurements(surveyId: number): Promise { + async getObservationSubCountQuantitativeTaxonMeasurementIds(surveyId: number): Promise { return this.observationSubCountMeasurementRepository.getObservationSubCountQuantitativeTaxonMeasurementIds( surveyId ); diff --git a/api/src/services/subcount-service.test.ts b/api/src/services/subcount-service.test.ts index a470eddbc9..e527439627 100644 --- a/api/src/services/subcount-service.test.ts +++ b/api/src/services/subcount-service.test.ts @@ -2,6 +2,7 @@ import chai, { expect } from 'chai'; import { describe } from 'mocha'; import sinon from 'sinon'; import sinonChai from 'sinon-chai'; +import { ObservationSubCountEnvironmentRepository } from '../repositories/observation-subcount-environment-repository'; import { ObservationSubCountMeasurementRepository } from '../repositories/observation-subcount-measurement-repository'; import { InsertObservationSubCount, @@ -70,6 +71,10 @@ describe('SubCountService', () => { .stub(ObservationSubCountMeasurementRepository.prototype, 'deleteObservationMeasurements') .resolves(); + const deleteObservationEnvironmentsStub = sinon + .stub(ObservationSubCountEnvironmentRepository.prototype, 'deleteObservationEnvironments') + .resolves(); + const deleteObservationSubCountRecordsStub = sinon .stub(SubCountRepository.prototype, 'deleteObservationSubCountRecords') .resolves(); @@ -81,6 +86,7 @@ describe('SubCountService', () => { mockSurveyObservationIds ); expect(deleteObservationMeasurementsStub).to.be.calledOnceWith(mockSurveyId, mockSurveyObservationIds); + expect(deleteObservationEnvironmentsStub).to.be.calledOnceWith(mockSurveyId, mockSurveyObservationIds); expect(deleteObservationSubCountRecordsStub).to.be.calledOnceWith(mockSurveyId, mockSurveyObservationIds); }); }); diff --git a/api/src/services/subcount-service.ts b/api/src/services/subcount-service.ts index c7d2df4595..f51732a2e8 100644 --- a/api/src/services/subcount-service.ts +++ b/api/src/services/subcount-service.ts @@ -1,5 +1,5 @@ import { IDBConnection } from '../database/db'; -import { ObservationSubCountMeasurementRepository } from '../repositories/observation-subcount-measurement-repository'; +import { EnvironmentType } from '../repositories/observation-subcount-environment-repository'; import { InsertObservationSubCount, InsertSubCountEvent, @@ -13,6 +13,8 @@ import { CritterbaseService } from './critterbase-service'; import { DBService } from './db-service'; +import { ObservationSubCountEnvironmentService } from './observation-subcount-environment-service'; +import { ObservationSubCountMeasurementService } from './observation-subcount-measurement-service'; export class SubCountService extends DBService { subCountRepository: SubCountRepository; @@ -47,7 +49,7 @@ export class SubCountService extends DBService { /** * Delete observation_subcount records for the given set of survey observation ids. * - * Note: Also deletes all related child records (subcount_critter, subcount_event). + * Note: Also deletes all related child records. * * @param {number} surveyId * @param {number[]} surveyObservationIds @@ -55,13 +57,16 @@ export class SubCountService extends DBService { * @memberof SubCountService */ async deleteObservationSubCountRecords(surveyId: number, surveyObservationIds: number[]): Promise { - const repo = new ObservationSubCountMeasurementRepository(this.connection); - // Delete child subcount_critter records, if any await this.subCountRepository.deleteSubCountCritterRecordsForObservationId(surveyId, surveyObservationIds); // Delete child observation measurements, if any - await repo.deleteObservationMeasurements(surveyId, surveyObservationIds); + const observationSubCountMeasurementService = new ObservationSubCountMeasurementService(this.connection); + await observationSubCountMeasurementService.deleteObservationMeasurements(surveyId, surveyObservationIds); + + // Delete child environments, if any + const observationSubCountEnvironmentService = new ObservationSubCountEnvironmentService(this.connection); + await observationSubCountEnvironmentService.deleteObservationEnvironments(surveyId, surveyObservationIds); // Delete observation_subcount records, if any return this.subCountRepository.deleteObservationSubCountRecords(surveyId, surveyObservationIds); @@ -82,7 +87,7 @@ export class SubCountService extends DBService { qualitative_measurements: CBQualitativeMeasurementTypeDefinition[]; quantitative_measurements: CBQuantitativeMeasurementTypeDefinition[]; }> { - const observationSubCountMeasurementService = new ObservationSubCountMeasurementRepository(this.connection); + const observationSubCountMeasurementService = new ObservationSubCountMeasurementService(this.connection); // Fetch all unique taxon_measurement_ids for qualitative and quantitative measurements const [qualitativeTaxonMeasurementIds, quantitativeTaxonMeasurementIds] = await Promise.all([ @@ -103,4 +108,26 @@ export class SubCountService extends DBService { return { qualitative_measurements: response[0], quantitative_measurements: response[1] }; } + + /** + * Returns a unique set of all environment type definitions for all environments of all observations in the given + * survey. + * + * @param {number} surveyId + * @return {*} {Promise} + * @memberof SubCountService + */ + async getEnvironmentTypeDefinitionsForSurvey(surveyId: number): Promise { + const observationSubCountEnvironmentService = new ObservationSubCountEnvironmentService(this.connection); + + const [qualitativeEnvironmentTypeDefinitions, quantitativeEnvironmentTypeDefinitions] = await Promise.all([ + observationSubCountEnvironmentService.getQualitativeEnvironmentTypeDefinitions(surveyId), + observationSubCountEnvironmentService.getQuantitativeEnvironmentTypeDefinitions(surveyId) + ]); + + return { + qualitative_environments: qualitativeEnvironmentTypeDefinitions, + quantitative_environments: quantitativeEnvironmentTypeDefinitions + }; + } } diff --git a/api/src/services/telemetry-service.ts b/api/src/services/telemetry-service.ts index d50e5a903d..9247df1756 100644 --- a/api/src/services/telemetry-service.ts +++ b/api/src/services/telemetry-service.ts @@ -4,10 +4,9 @@ import { ApiGeneralError } from '../errors/api-error'; import { TelemetryRepository, TelemetrySubmissionRecord } from '../repositories/telemetry-repository'; import { generateS3FileKey, getFileFromS3 } from '../utils/file-utils'; import { parseS3File } from '../utils/media/media-utils'; -import { DEFAULT_XLSX_SHEET_NAME } from '../utils/media/xlsx/xlsx-file'; import { - constructWorksheets, constructXLSXWorkbook, + getDefaultWorksheet, getWorksheetRowObjects, IXLSXCSVValidator, validateCsvFile @@ -78,14 +77,15 @@ export class TelemetryService extends DBService { // step 5 construct workbook/ setup const xlsxWorkBook = constructXLSXWorkbook(mediaFile); - const xlsxWorksheets = constructWorksheets(xlsxWorkBook); + // Get the default XLSX worksheet + const xlsxWorksheet = getDefaultWorksheet(xlsxWorkBook); // step 6 validate columns - if (!validateCsvFile(xlsxWorksheets, telemetryCSVColumnValidator)) { + if (!validateCsvFile(xlsxWorksheet, telemetryCSVColumnValidator)) { throw new ApiGeneralError('Failed to process file for importing telemetry. Invalid CSV file.'); } - const worksheetRowObjects = getWorksheetRowObjects(xlsxWorksheets[DEFAULT_XLSX_SHEET_NAME]); + const worksheetRowObjects = getWorksheetRowObjects(xlsxWorksheet); // step 7 fetch survey deployments const bctwService = new BctwService(user); diff --git a/api/src/utils/media/csv/csv-file.test.ts b/api/src/utils/media/csv/csv-file.test.ts index d8bed9c8b0..8f35ffe9cf 100644 --- a/api/src/utils/media/csv/csv-file.test.ts +++ b/api/src/utils/media/csv/csv-file.test.ts @@ -5,55 +5,35 @@ import xlsx from 'xlsx'; import { SUBMISSION_MESSAGE_TYPE } from '../../../constants/status'; import { CSVValidation, CSVWorkBook, CSVWorksheet, IHeaderError, IKeyError, IRowError } from './csv-file'; -describe('CSVWorkBook', () => { - it('constructs with no rawWorkbook param', () => { - const csvWorkBook = new CSVWorkBook(); - - expect(csvWorkBook).not.to.be.null; - expect(csvWorkBook.rawWorkbook).not.to.be.null; - expect(csvWorkBook.worksheets).to.eql({}); - }); - - it('constructs with rawWorkbook param', () => { - const xlsxWorkSheet = xlsx.utils.aoa_to_sheet([ - ['Header1', 'Header2'], - ['Header1Data', 'Header2Data'] - ]); - - const xlsxWorkBook = xlsx.utils.book_new(); - xlsx.utils.book_append_sheet(xlsxWorkBook, xlsxWorkSheet); - - const csvWorkBook = new CSVWorkBook(xlsxWorkBook); - - expect(csvWorkBook).not.to.be.null; - expect(csvWorkBook.rawWorkbook).not.to.be.null; - expect(csvWorkBook.worksheets['Sheet1']).not.to.be.null; - }); -}); - -describe('CSVWorksheet', () => { - it('constructs', () => { - const xlsxWorkSheet = xlsx.utils.aoa_to_sheet([ - ['Header1', 'Header2'], - ['Header1Data', 'Header2Data'] - ]); - - const csvWorksheet = new CSVWorksheet('Sheet1', xlsxWorkSheet); +describe('csv-file', () => { + describe('CSVWorkBook', () => { + it('constructs with no rawWorkbook param', () => { + const csvWorkBook = new CSVWorkBook(); + + expect(csvWorkBook).not.to.be.null; + expect(csvWorkBook.rawWorkbook).not.to.be.null; + expect(csvWorkBook.worksheets).to.eql({}); + }); - expect(csvWorksheet).not.to.be.null; - }); + it('constructs with rawWorkbook param', () => { + const xlsxWorkSheet = xlsx.utils.aoa_to_sheet([ + ['Header1', 'Header2'], + ['Header1Data', 'Header2Data'] + ]); - describe('getHeaders', () => { - it('returns empty array if the worksheet is null', () => { - const xlsxWorkSheet = null as unknown as xlsx.WorkSheet; + const xlsxWorkBook = xlsx.utils.book_new(); + xlsx.utils.book_append_sheet(xlsxWorkBook, xlsxWorkSheet); - const csvWorksheet = new CSVWorksheet('Sheet1', xlsxWorkSheet); + const csvWorkBook = new CSVWorkBook(xlsxWorkBook); - expect(csvWorksheet).not.to.be.null; - expect(csvWorksheet.getHeaders()).to.eql([]); + expect(csvWorkBook).not.to.be.null; + expect(csvWorkBook.rawWorkbook).not.to.be.null; + expect(csvWorkBook.worksheets['Sheet1']).not.to.be.null; }); + }); - it('returns an array of headers', () => { + describe('CSVWorksheet', () => { + it('constructs', () => { const xlsxWorkSheet = xlsx.utils.aoa_to_sheet([ ['Header1', 'Header2'], ['Header1Data', 'Header2Data'] @@ -62,188 +42,243 @@ describe('CSVWorksheet', () => { const csvWorksheet = new CSVWorksheet('Sheet1', xlsxWorkSheet); expect(csvWorksheet).not.to.be.null; - expect(csvWorksheet.getHeaders()).to.eql(['Header1', 'Header2']); }); - }); - describe('getRows', () => { - it('returns empty array if the worksheet is null', () => { - const xlsxWorkSheet = null as unknown as xlsx.WorkSheet; + describe('getHeaders', () => { + it('returns empty array if the worksheet is null', () => { + const xlsxWorkSheet = null as unknown as xlsx.WorkSheet; - const csvWorksheet = new CSVWorksheet('Sheet1', xlsxWorkSheet); + const csvWorksheet = new CSVWorksheet('Sheet1', xlsxWorkSheet); - expect(csvWorksheet).not.to.be.null; - expect(csvWorksheet.getRows()).to.eql([]); - }); + expect(csvWorksheet).not.to.be.null; + expect(csvWorksheet.getHeaders()).to.eql([]); + }); - it('returns an array of rows data arrays', () => { - const xlsxWorkSheet = xlsx.utils.aoa_to_sheet([ - ['Header1', 'Header2'], - ['Header1Data1', 'Header2Data1'], - ['Header1Data2', 'Header2Data2'] - ]); + it('returns an array of headers', () => { + const xlsxWorkSheet = xlsx.utils.aoa_to_sheet([ + ['Header1', 'Header2'], + ['Header1Data', 'Header2Data'] + ]); - const csvWorksheet = new CSVWorksheet('Sheet1', xlsxWorkSheet); + const csvWorksheet = new CSVWorksheet('Sheet1', xlsxWorkSheet); - expect(csvWorksheet).not.to.be.null; - expect(csvWorksheet.getRows()).to.eql([ - ['Header1Data1', 'Header2Data1'], - ['Header1Data2', 'Header2Data2'] - ]); + expect(csvWorksheet).not.to.be.null; + expect(csvWorksheet.getHeaders()).to.eql(['Header1', 'Header2']); + }); }); - }); - describe('validate', () => { - it('calls all provided validator functions', () => { - const xlsxWorkSheet = xlsx.utils.aoa_to_sheet([ - ['Header1', 'Header2'], - ['Header1Data1', 'Header2Data1'], - ['Header1Data2', 'Header2Data2'] - ]); + describe('getRows', () => { + it('returns empty array if the worksheet is null', () => { + const xlsxWorkSheet = null as unknown as xlsx.WorkSheet; - const csvWorksheet = new CSVWorksheet('Sheet1', xlsxWorkSheet); + const csvWorksheet = new CSVWorksheet('Sheet1', xlsxWorkSheet); - expect(csvWorksheet).not.to.be.null; + expect(csvWorksheet).not.to.be.null; + expect(csvWorksheet.getRows()).to.eql([]); + }); - const mockValidationFunction1 = sinon.stub(); - const mockValidationFunction2 = sinon.stub(); - const mockValidationFunction3 = sinon.stub(); + it('returns an array of rows data arrays', () => { + const xlsxWorkSheet = xlsx.utils.aoa_to_sheet([ + ['Header1', 'Header2'], + ['Header1Data1', 'Header2Data1'], + ['Header1Data2', 'Header2Data2'] + ]); - csvWorksheet.validate([mockValidationFunction1, mockValidationFunction2, mockValidationFunction3]); + const csvWorksheet = new CSVWorksheet('Sheet1', xlsxWorkSheet); - expect(mockValidationFunction1).to.have.been.calledOnce; - expect(mockValidationFunction2).to.have.been.calledOnce; - expect(mockValidationFunction3).to.have.been.calledOnce; + expect(csvWorksheet).not.to.be.null; + expect(csvWorksheet.getRows()).to.eql([ + ['Header1Data1', 'Header2Data1'], + ['Header1Data2', 'Header2Data2'] + ]); + }); }); - }); -}); -describe('CSVValidation', () => { - it('constructs', () => { - const csvValidation = new CSVValidation('fileName'); + describe('getRowObjects', () => { + it('returns empty array if the worksheet is null', () => { + const xlsxWorkSheet = null as unknown as xlsx.WorkSheet; - expect(csvValidation).not.to.be.null; - }); + const csvWorksheet = new CSVWorksheet('Sheet1', xlsxWorkSheet); - describe('addFileErrors', () => { - it('adds new file errors', () => { - const csvValidation = new CSVValidation('fileName'); + expect(csvWorksheet).not.to.be.null; + expect(csvWorksheet.getRows()).to.eql([]); + }); - expect(csvValidation).not.to.be.null; + it('returns an array of rows data arrays', () => { + const xlsxWorkSheet = xlsx.utils.aoa_to_sheet([ + ['Header1', 'Header2'], + ['Header1Data1', 'Header2Data1'], + ['Header1Data2', 'Header2Data2'] + ]); + + const csvWorksheet = new CSVWorksheet('Sheet1', xlsxWorkSheet); + + expect(csvWorksheet).not.to.be.null; + expect(csvWorksheet.getRowObjects()).to.eql([ + { + Header1: 'Header1Data1', + Header2: 'Header2Data1' + }, + { + Header1: 'Header1Data2', + Header2: 'Header2Data2' + } + ]); + }); + }); - const fileError1 = 'a file error'; - const fileError2 = 'a second file error'; + describe('validate', () => { + it('calls all provided validator functions', () => { + const xlsxWorkSheet = xlsx.utils.aoa_to_sheet([ + ['Header1', 'Header2'], + ['Header1Data1', 'Header2Data1'], + ['Header1Data2', 'Header2Data2'] + ]); - csvValidation.addFileErrors([fileError1]); + const csvWorksheet = new CSVWorksheet('Sheet1', xlsxWorkSheet); - expect(csvValidation.fileErrors).to.eql([fileError1]); + expect(csvWorksheet).not.to.be.null; - csvValidation.addFileErrors([fileError2]); + const mockValidationFunction1 = sinon.stub(); + const mockValidationFunction2 = sinon.stub(); + const mockValidationFunction3 = sinon.stub(); - expect(csvValidation.fileErrors).to.eql([fileError1, fileError2]); + csvWorksheet.validate([mockValidationFunction1, mockValidationFunction2, mockValidationFunction3]); + + expect(mockValidationFunction1).to.have.been.calledOnce; + expect(mockValidationFunction2).to.have.been.calledOnce; + expect(mockValidationFunction3).to.have.been.calledOnce; + }); }); }); - describe('addHeaderErrors', () => { - it('adds new header errors', () => { + describe('CSVValidation', () => { + it('constructs', () => { const csvValidation = new CSVValidation('fileName'); expect(csvValidation).not.to.be.null; + }); + + describe('addFileErrors', () => { + it('adds new file errors', () => { + const csvValidation = new CSVValidation('fileName'); - const headerError1: IHeaderError = { - errorCode: SUBMISSION_MESSAGE_TYPE.DUPLICATE_HEADER, - message: 'a header error', - col: 0 - }; + expect(csvValidation).not.to.be.null; - const headerError2: IHeaderError = { - errorCode: SUBMISSION_MESSAGE_TYPE.UNKNOWN_HEADER, - message: 'a second header error', - col: 1 - }; + const fileError1 = 'a file error'; + const fileError2 = 'a second file error'; - csvValidation.addHeaderErrors([headerError1]); + csvValidation.addFileErrors([fileError1]); - expect(csvValidation.headerErrors).to.eql([headerError1]); + expect(csvValidation.fileErrors).to.eql([fileError1]); - csvValidation.addHeaderErrors([headerError2]); + csvValidation.addFileErrors([fileError2]); - expect(csvValidation.headerErrors).to.eql([headerError1, headerError2]); + expect(csvValidation.fileErrors).to.eql([fileError1, fileError2]); + }); }); - }); - describe('addRowErrors', () => { - it('adds new header errors', () => { - const csvValidation = new CSVValidation('fileName'); + describe('addHeaderErrors', () => { + it('adds new header errors', () => { + const csvValidation = new CSVValidation('fileName'); - expect(csvValidation).not.to.be.null; + expect(csvValidation).not.to.be.null; - const rowError1: IRowError = { - errorCode: SUBMISSION_MESSAGE_TYPE.MISSING_REQUIRED_FIELD, - message: 'a row error', - col: 'col1', - row: 1 - }; + const headerError1: IHeaderError = { + errorCode: SUBMISSION_MESSAGE_TYPE.DUPLICATE_HEADER, + message: 'a header error', + col: 0 + }; - const rowError2: IRowError = { - errorCode: SUBMISSION_MESSAGE_TYPE.MISSING_REQUIRED_FIELD, - message: 'a second row error', - col: 'col1', - row: 2 - }; + const headerError2: IHeaderError = { + errorCode: SUBMISSION_MESSAGE_TYPE.UNKNOWN_HEADER, + message: 'a second header error', + col: 1 + }; - csvValidation.addRowErrors([rowError1]); + csvValidation.addHeaderErrors([headerError1]); - expect(csvValidation.rowErrors).to.eql([rowError1]); + expect(csvValidation.headerErrors).to.eql([headerError1]); - csvValidation.addRowErrors([rowError2]); + csvValidation.addHeaderErrors([headerError2]); - expect(csvValidation.rowErrors).to.eql([rowError1, rowError2]); + expect(csvValidation.headerErrors).to.eql([headerError1, headerError2]); + }); }); - }); - describe('getState', () => { - it('gets the current validation state', () => { - const csvValidation = new CSVValidation('fileName'); + describe('addRowErrors', () => { + it('adds new header errors', () => { + const csvValidation = new CSVValidation('fileName'); - expect(csvValidation).not.to.be.null; + expect(csvValidation).not.to.be.null; + + const rowError1: IRowError = { + errorCode: SUBMISSION_MESSAGE_TYPE.MISSING_REQUIRED_FIELD, + message: 'a row error', + col: 'col1', + row: 1 + }; + + const rowError2: IRowError = { + errorCode: SUBMISSION_MESSAGE_TYPE.MISSING_REQUIRED_FIELD, + message: 'a second row error', + col: 'col1', + row: 2 + }; + + csvValidation.addRowErrors([rowError1]); + + expect(csvValidation.rowErrors).to.eql([rowError1]); + + csvValidation.addRowErrors([rowError2]); + + expect(csvValidation.rowErrors).to.eql([rowError1, rowError2]); + }); + }); - const fileError1 = 'a file error'; - - const headerError1: IHeaderError = { - errorCode: SUBMISSION_MESSAGE_TYPE.DUPLICATE_HEADER, - message: 'a header error', - col: 0 - }; - - const rowError1: IRowError = { - errorCode: SUBMISSION_MESSAGE_TYPE.MISSING_REQUIRED_FIELD, - message: 'a row error', - col: 'col1', - row: 1 - }; - - const keyError1: IKeyError = { - errorCode: SUBMISSION_MESSAGE_TYPE.DANGLING_PARENT_CHILD_KEY, - message: 'a key error', - colNames: ['col1', 'col2'], - rows: [2, 3, 4] - }; - - csvValidation.addFileErrors([fileError1]); - csvValidation.addHeaderErrors([headerError1]); - csvValidation.addRowErrors([rowError1]); - csvValidation.addKeyErrors([keyError1]); - - const validationState = csvValidation.getState(); - - expect(validationState).to.eql({ - fileName: 'fileName', - fileErrors: [fileError1], - headerErrors: [headerError1], - rowErrors: [rowError1], - keyErrors: [keyError1], - isValid: false + describe('getState', () => { + it('gets the current validation state', () => { + const csvValidation = new CSVValidation('fileName'); + + expect(csvValidation).not.to.be.null; + + const fileError1 = 'a file error'; + + const headerError1: IHeaderError = { + errorCode: SUBMISSION_MESSAGE_TYPE.DUPLICATE_HEADER, + message: 'a header error', + col: 0 + }; + + const rowError1: IRowError = { + errorCode: SUBMISSION_MESSAGE_TYPE.MISSING_REQUIRED_FIELD, + message: 'a row error', + col: 'col1', + row: 1 + }; + + const keyError1: IKeyError = { + errorCode: SUBMISSION_MESSAGE_TYPE.DANGLING_PARENT_CHILD_KEY, + message: 'a key error', + colNames: ['col1', 'col2'], + rows: [2, 3, 4] + }; + + csvValidation.addFileErrors([fileError1]); + csvValidation.addHeaderErrors([headerError1]); + csvValidation.addRowErrors([rowError1]); + csvValidation.addKeyErrors([keyError1]); + + const validationState = csvValidation.getState(); + + expect(validationState).to.eql({ + fileName: 'fileName', + fileErrors: [fileError1], + headerErrors: [headerError1], + rowErrors: [rowError1], + keyErrors: [keyError1], + isValid: false + }); }); }); }); diff --git a/api/src/utils/media/csv/csv-file.ts b/api/src/utils/media/csv/csv-file.ts index e5ff6c5438..5cafd4cb34 100644 --- a/api/src/utils/media/csv/csv-file.ts +++ b/api/src/utils/media/csv/csv-file.ts @@ -199,7 +199,7 @@ export class CSVWorksheet { const headers = this.getHeaders(); rows.forEach((row: string[]) => { - const rowObject = {}; + const rowObject: Record = {}; headers.forEach((header: string, index: number) => { rowObject[header] = row[index]; diff --git a/api/src/utils/observation-xlsx-utils/common-utils.test.ts b/api/src/utils/observation-xlsx-utils/common-utils.test.ts new file mode 100644 index 0000000000..dd79efd55d --- /dev/null +++ b/api/src/utils/observation-xlsx-utils/common-utils.test.ts @@ -0,0 +1,155 @@ +import { expect } from 'chai'; +import { describe } from 'mocha'; +import { isQualitativeValueValid, isQuantitativeValueValid } from './common-utils'; + +describe('common-utils', () => { + describe('isQualitativeValueValid', () => { + it('qualitative value is valid', () => { + const value = 'Hind Leg'; + const options = ['Hind Leg', 'Front Leg']; + + const results = isQualitativeValueValid(value, options); + + expect(results).to.be.true; + }); + + it('qualitative value is invalid', () => { + const value = 'Hind Leg'; + const options = ['Back Leg', 'Front Leg']; + + const results = isQualitativeValueValid(value, options); + + expect(results).to.be.false; + }); + }); + + describe('isQuantitativeValueValid', () => { + describe('both min and max set', () => { + it('value is between the min and max', () => { + const value = 2; + const min = 1; + const max = 4; + + const results = isQuantitativeValueValid(value, min, max); + + expect(results).to.be.true; + }); + + it('value is equal to the minimum', () => { + const value = 1; + const min = 1; + const max = 4; + + const results = isQuantitativeValueValid(value, min, max); + + expect(results).to.be.true; + }); + + it('value is equal to the maximum', () => { + const value = 4; + const min = 1; + const max = 4; + + const results = isQuantitativeValueValid(value, min, max); + + expect(results).to.be.true; + }); + + it('value is less than the minimum', () => { + const value = 0; + const min = 1; + const max = 4; + + const results = isQuantitativeValueValid(value, min, max); + + expect(results).to.be.false; + }); + + it('value is greater than the maximum', () => { + const value = 5; + const min = 1; + const max = 4; + + const results = isQuantitativeValueValid(value, min, max); + + expect(results).to.be.false; + }); + }); + + describe('only min set', () => { + it('value is greater than the minimum', () => { + const value = 5; + const min = 1; + const max = null; + + const results = isQuantitativeValueValid(value, min, max); + + expect(results).to.be.true; + }); + + it('value is equal to the minimum', () => { + const value = 1; + const min = 1; + const max = null; + + const results = isQuantitativeValueValid(value, min, max); + + expect(results).to.be.true; + }); + + it('value is less than the minimum', () => { + const value = -1; + const min = 1; + const max = null; + + const results = isQuantitativeValueValid(value, min, max); + + expect(results).to.be.false; + }); + }); + + describe('only max set', () => { + it('value is less than the maximum', () => { + const value = -1; + const min = null; + const max = 4; + + const results = isQuantitativeValueValid(value, min, max); + + expect(results).to.be.true; + }); + + it('value is equal to the maximum', () => { + const value = 4; + const min = null; + const max = 4; + + const results = isQuantitativeValueValid(value, min, max); + + expect(results).to.be.true; + }); + + it('value is greater than the maximum', () => { + const value = 5; + const min = null; + const max = 4; + + const results = isQuantitativeValueValid(value, min, max); + + expect(results).to.be.false; + }); + }); + + describe('neither min nor max set', () => { + it('value is valid', () => { + const value = 2; + const min = null; + const max = null; + + const results = isQuantitativeValueValid(value, min, max); + + expect(results).to.be.true; + }); + }); + }); +}); diff --git a/api/src/utils/observation-xlsx-utils/common-utils.ts b/api/src/utils/observation-xlsx-utils/common-utils.ts new file mode 100644 index 0000000000..504f00aab9 --- /dev/null +++ b/api/src/utils/observation-xlsx-utils/common-utils.ts @@ -0,0 +1,40 @@ +/** + * Validates any qualitative cell value against the provided options. + * If the value does not match any of the valid options, `false` is returned. + * + * @param {string} cellValue the value of the cell to validate (expected to match one of the option ids) + * @param {string[]} optionNames the valid option names for the column + * @return {*} {boolean} + */ +export function isQualitativeValueValid(cellValue: string, optionNames: string[]): boolean { + return optionNames.includes(cellValue); +} + +/** + * Validates any quantitative cell value against the provided min and max values. + * If the value is outside of the valid range, `false` is returned. + * + * @param {number} cellValue the value of the cell to validate (expected to be within the min and max values, if set) + * @param {(number | null)} minValue the minimum value for the column + * @param {(number | null)} maxValue the maximum value for the column + * @return {*} {boolean} + */ +export function isQuantitativeValueValid(cellValue: number, minValue: number | null, maxValue: number | null): boolean { + if (minValue !== null && maxValue !== null && (cellValue < minValue || cellValue > maxValue)) { + // Both min and max values are set and the cell value is outside of the valid range + return false; + } + + if (minValue !== null && cellValue < minValue) { + // Only the min value is set and the cell value is less than the min value + return false; + } + + if (maxValue !== null && cellValue > maxValue) { + // Only the max value is set and the cell value is greater than the max value + return false; + } + + // The cell value is within the valid range or no range is set + return true; +} diff --git a/api/src/utils/observation-xlsx-utils/environment-column-utils.test.ts b/api/src/utils/observation-xlsx-utils/environment-column-utils.test.ts new file mode 100644 index 0000000000..825a780682 --- /dev/null +++ b/api/src/utils/observation-xlsx-utils/environment-column-utils.test.ts @@ -0,0 +1,547 @@ +import { expect } from 'chai'; +import { describe } from 'mocha'; +import sinon from 'sinon'; +import { + EnvironmentType, + QualitativeEnvironmentTypeDefinition, + QuantitativeEnvironmentTypeDefinition +} from '../../repositories/observation-subcount-environment-repository'; +import { ObservationSubCountEnvironmentService } from '../../services/observation-subcount-environment-service'; +import { getMockDBConnection } from '../../__mocks__/db'; +import * as environment_column_utils from './environment-column-utils'; +import { EnvironmentNameTypeDefinitionMap, IEnvironmentDataToValidate } from './environment-column-utils'; + +describe('environment-column-utils', () => { + afterEach(() => { + sinon.restore(); + }); + + describe('getEnvironmentTypeDefinitionsFromColumnNames', async () => { + const dbConnectionObj = getMockDBConnection(); + + const findQualitativeEnvironmentTypeDefinitionsStub = sinon + .stub(ObservationSubCountEnvironmentService.prototype, 'findQualitativeEnvironmentTypeDefinitions') + .resolves([ + { + environment_qualitative_id: '11-123-456', + name: 'Wind Speed', + description: 'Wind Speed', + options: [ + { + environment_qualitative_id: '22-123-456', + environment_qualitative_option_id: '33-123-456', + name: 'Low', + description: 'Low' + } + ] + }, + { + environment_qualitative_id: '11-123-456', + name: 'Wind Direction', + description: 'Wind Direction', + options: [ + { + environment_qualitative_id: '44-123-456', + environment_qualitative_option_id: '55-123-456', + name: 'North', + description: 'North' + } + ] + } + ]); + + const findQuantitativeEnvironmentTypeDefinitionsStub = sinon + .stub(ObservationSubCountEnvironmentService.prototype, 'findQuantitativeEnvironmentTypeDefinitions') + .resolves([ + { + environment_quantitative_id: '66-123-456', + name: 'Weight', + description: 'Weight', + min: 0, + max: null, + unit: 'kilogram' + } + ]); + + const columnNames: string[] = ['Wind Speed', 'Weight', 'Col With No Match', 'Wind Direction', 'Height']; + const observationSubCountEnvironmentService: ObservationSubCountEnvironmentService = + new ObservationSubCountEnvironmentService(dbConnectionObj); + + const result = await environment_column_utils.getEnvironmentTypeDefinitionsFromColumnNames( + columnNames, + observationSubCountEnvironmentService + ); + + expect(findQualitativeEnvironmentTypeDefinitionsStub).to.have.been.calledOnceWith(columnNames); + expect(findQuantitativeEnvironmentTypeDefinitionsStub).to.have.been.calledOnceWith(columnNames); + + expect(result).to.eql({ + qualitative_environments: [ + { + environment_qualitative_id: '11-123-456', + name: 'Wind Speed', + description: 'Wind Speed', + options: [ + { + environment_qualitative_id: '22-123-456', + environment_qualitative_option_id: '33-123-456', + name: 'Low', + description: 'Low' + } + ] + }, + { + environment_qualitative_id: '11-123-456', + name: 'Wind Direction', + description: 'Wind Direction', + options: [ + { + environment_qualitative_id: '44-123-456', + environment_qualitative_option_id: '55-123-456', + name: 'North', + description: 'North' + } + ] + } + ], + quantitative_environments: [ + { + environment_quantitative_id: '66-123-456', + name: 'Weight', + description: 'Weight', + min: 0, + max: null, + unit: 'kilogram' + } + ] + }); + }); + + describe('getEnvironmentColumnsTypeDefinitionMap', () => { + it('returns the column name definition map', () => { + const environmentColumns: string[] = ['Wind Speed', 'Weight', 'Col With No Match', 'Wind Direction', 'Height']; + const environmentTypeDefinitions: EnvironmentType = { + qualitative_environments: [ + { + environment_qualitative_id: '11-123-456', + name: 'Wind Speed', + description: 'Wind Speed', + options: [ + { + environment_qualitative_id: '22-123-456', + environment_qualitative_option_id: '33-123-456', + name: 'Low', + description: 'Low' + } + ] + }, + { + environment_qualitative_id: '11-123-456', + name: 'Wind Direction', + description: 'Wind Direction', + options: [ + { + environment_qualitative_id: '44-123-456', + environment_qualitative_option_id: '55-123-456', + name: 'North', + description: 'North' + } + ] + } + ], + quantitative_environments: [ + { + environment_quantitative_id: '66-123-456', + name: 'Weight', + description: 'Weight', + min: 0, + max: null, + unit: 'kilogram' + }, + { + environment_quantitative_id: '77-123-456', + name: 'Env With No Match', + description: 'Env With No Match', + min: 0, + max: 100, + unit: 'meter' + }, + { + environment_quantitative_id: '88-123-456', + name: 'Height', + description: 'Height', + min: 0, + max: null, + unit: 'centimeter' + } + ] + }; + + const expectedResult = new Map< + string, + QualitativeEnvironmentTypeDefinition | QuantitativeEnvironmentTypeDefinition + >([ + [ + 'Wind Speed', + { + environment_qualitative_id: '11-123-456', + name: 'Wind Speed', + description: 'Wind Speed', + options: [ + { + environment_qualitative_id: '22-123-456', + environment_qualitative_option_id: '33-123-456', + name: 'Low', + description: 'Low' + } + ] + } + ], + [ + 'Wind Direction', + { + environment_qualitative_id: '11-123-456', + name: 'Wind Direction', + description: 'Wind Direction', + options: [ + { + environment_qualitative_id: '44-123-456', + environment_qualitative_option_id: '55-123-456', + name: 'North', + description: 'North' + } + ] + } + ], + [ + 'Weight', + { + environment_quantitative_id: '66-123-456', + name: 'Weight', + description: 'Weight', + min: 0, + max: null, + unit: 'kilogram' + } + ], + [ + 'Height', + { + environment_quantitative_id: '88-123-456', + name: 'Height', + description: 'Height', + min: 0, + max: null, + unit: 'centimeter' + } + ] + ]); + + const result = environment_column_utils.getEnvironmentColumnsTypeDefinitionMap( + environmentColumns, + environmentTypeDefinitions + ); + + expect(result).to.eql(expectedResult); + }); + }); + + describe('validateEnvironments', () => { + it('returns true when there are no environments to validate', () => { + const environmentsToValidate: IEnvironmentDataToValidate[] = []; + const environmentNameTypeDefinitionMap: EnvironmentNameTypeDefinitionMap = new Map(); + + const results = environment_column_utils.validateEnvironments( + environmentsToValidate, + environmentNameTypeDefinitionMap + ); + + expect(results).to.be.true; + }); + + it('returns true when the values match valid environment definitions', () => { + const environmentsToValidate: IEnvironmentDataToValidate[] = [ + { + key: 'Wind Speed', + value: 'Low' + }, + { + key: 'Weight', + value: 100 + } + ]; + const environmentNameTypeDefinitionMap: EnvironmentNameTypeDefinitionMap = new Map([ + [ + 'Wind Speed', + { + environment_qualitative_id: '11-123-456', + name: 'Wind Speed', + description: 'Wind Speed', + options: [ + { + environment_qualitative_id: '22-123-456', + environment_qualitative_option_id: '33-123-456', + name: 'Low', + description: 'Low' + } + ] + } + ], + [ + 'Weight', + { + environment_quantitative_id: '66-123-456', + name: 'Weight', + description: 'Weight', + min: 0, + max: null, + unit: 'kilogram' + } + ] + ]); + + const results = environment_column_utils.validateEnvironments( + environmentsToValidate, + environmentNameTypeDefinitionMap + ); + + expect(results).to.be.true; + }); + + it('returns true when the values match valid environment definitions - with a stringified number', () => { + const environmentsToValidate: IEnvironmentDataToValidate[] = [ + { + key: 'Wind Speed', + value: 'Low' + }, + { + key: 'Weight', + value: '100' + } + ]; + const environmentNameTypeDefinitionMap: EnvironmentNameTypeDefinitionMap = new Map([ + [ + 'Wind Speed', + { + environment_qualitative_id: '11-123-456', + name: 'Wind Speed', + description: 'Wind Speed', + options: [ + { + environment_qualitative_id: '22-123-456', + environment_qualitative_option_id: '33-123-456', + name: 'Low', + description: 'Low' + } + ] + } + ], + [ + 'Weight', + { + environment_quantitative_id: '66-123-456', + name: 'Weight', + description: 'Weight', + min: 0, + max: null, + unit: 'kilogram' + } + ] + ]); + + const results = environment_column_utils.validateEnvironments( + environmentsToValidate, + environmentNameTypeDefinitionMap + ); + + expect(results).to.be.true; + }); + + it('returns false when a column name does not match any valid environment definitions', () => { + const environmentsToValidate: IEnvironmentDataToValidate[] = [ + { + key: 'Col With No Match', + value: 'Low' + } + ]; + const environmentNameTypeDefinitionMap: EnvironmentNameTypeDefinitionMap = new Map([ + [ + 'Wind Speed', + { + environment_qualitative_id: '11-123-456', + name: 'Wind Speed', + description: 'Wind Speed', + options: [ + { + environment_qualitative_id: '22-123-456', + environment_qualitative_option_id: '33-123-456', + name: 'Low', + description: 'Low' + } + ] + } + ] + ]); + const results = environment_column_utils.validateEnvironments( + environmentsToValidate, + environmentNameTypeDefinitionMap + ); + + expect(results).to.be.false; + }); + + it('returns false when a qualitative option does not match any of the matching environment definitions options', () => { + const environmentsToValidate: IEnvironmentDataToValidate[] = [ + { + key: 'Wind Speed', + value: 'Not A Valid Option' + } + ]; + const environmentNameTypeDefinitionMap: EnvironmentNameTypeDefinitionMap = new Map([ + [ + 'Wind Speed', + { + environment_qualitative_id: '11-123-456', + name: 'Wind Speed', + description: 'Wind Speed', + options: [ + { + environment_qualitative_id: '22-123-456', + environment_qualitative_option_id: '33-123-456', + name: 'Low', + description: 'Low' + } + ] + } + ] + ]); + const results = environment_column_utils.validateEnvironments( + environmentsToValidate, + environmentNameTypeDefinitionMap + ); + + expect(results).to.be.false; + }); + + it('returns false when a quantitative value is less than the environment definitions minimum', () => { + const environmentsToValidate: IEnvironmentDataToValidate[] = [ + { + key: 'Col With No Match', + value: '-10' + } + ]; + const environmentNameTypeDefinitionMap: EnvironmentNameTypeDefinitionMap = new Map([ + [ + 'Weight', + { + environment_quantitative_id: '66-123-456', + name: 'Weight', + description: 'Weight', + min: 0, + max: 200, + unit: 'kilogram' + } + ] + ]); + const results = environment_column_utils.validateEnvironments( + environmentsToValidate, + environmentNameTypeDefinitionMap + ); + + expect(results).to.be.false; + }); + + it('returns false when a quantitative value is greater than the environment definitions maximum', () => { + const environmentsToValidate: IEnvironmentDataToValidate[] = [ + { + key: 'Col With No Match', + value: 500 + } + ]; + const environmentNameTypeDefinitionMap: EnvironmentNameTypeDefinitionMap = new Map([ + [ + 'Weight', + { + environment_quantitative_id: '66-123-456', + name: 'Weight', + description: 'Weight', + min: 0, + max: 200, + unit: 'kilogram' + } + ] + ]); + const results = environment_column_utils.validateEnvironments( + environmentsToValidate, + environmentNameTypeDefinitionMap + ); + + expect(results).to.be.false; + }); + + it('returns false when a quantitative value is not a number', () => { + const environmentsToValidate: IEnvironmentDataToValidate[] = [ + { + key: 'Col With No Match', + value: 'Not A Number' + } + ]; + const environmentNameTypeDefinitionMap: EnvironmentNameTypeDefinitionMap = new Map([ + [ + 'Weight', + { + environment_quantitative_id: '66-123-456', + name: 'Weight', + description: 'Weight', + min: null, + max: null, + unit: 'kilogram' + } + ] + ]); + const results = environment_column_utils.validateEnvironments( + environmentsToValidate, + environmentNameTypeDefinitionMap + ); + + expect(results).to.be.false; + }); + }); + + describe('isEnvironmentQualitativeTypeDefinition', () => { + it('returns true', () => { + const item: QualitativeEnvironmentTypeDefinition = { + environment_qualitative_id: '11-123-456', + name: 'Wind speed', + description: 'Wind speed', + options: [ + { + environment_qualitative_id: '22-123-456', + environment_qualitative_option_id: '33-123-456', + name: 'Low', + description: 'Low' + } + ] + }; + + const result = environment_column_utils.isEnvironmentQualitativeTypeDefinition(item); + + expect(result).to.be.true; + }); + + it('returns false', () => { + const item: QuantitativeEnvironmentTypeDefinition = { + environment_quantitative_id: '11-123-456', + name: 'Weight', + description: 'Weight', + min: 0, + max: null, + unit: 'kilogram' + }; + + const result = environment_column_utils.isEnvironmentQualitativeTypeDefinition(item); + + expect(result).to.be.false; + }); + }); +}); diff --git a/api/src/utils/observation-xlsx-utils/environment-column-utils.ts b/api/src/utils/observation-xlsx-utils/environment-column-utils.ts new file mode 100644 index 0000000000..cae6067f70 --- /dev/null +++ b/api/src/utils/observation-xlsx-utils/environment-column-utils.ts @@ -0,0 +1,133 @@ +import { + EnvironmentType, + QualitativeEnvironmentTypeDefinition, + QuantitativeEnvironmentTypeDefinition +} from '../../repositories/observation-subcount-environment-repository'; +import { ObservationSubCountEnvironmentService } from '../../services/observation-subcount-environment-service'; +import { isQualitativeValueValid, isQuantitativeValueValid } from './common-utils'; + +export type EnvironmentNameTypeDefinitionMap = Map< + string, + QualitativeEnvironmentTypeDefinition | QuantitativeEnvironmentTypeDefinition +>; + +export interface IEnvironmentDataToValidate { + key: string; + value: string | number; +} + +/** + * Given a list of column names, fetches the environment type definitions for each column (if the column has a matching + * environment type definition). + * + * @export + * @param {string[]} columnNames + * @param {ObservationSubCountEnvironmentService} observationSubCountEnvironmentService + * @return {*} {Promise} + */ +export async function getEnvironmentTypeDefinitionsFromColumnNames( + columnNames: string[], + observationSubCountEnvironmentService: ObservationSubCountEnvironmentService +): Promise { + const [qualitative_environments, quantitative_environments] = await Promise.all([ + observationSubCountEnvironmentService.findQualitativeEnvironmentTypeDefinitions(columnNames), + observationSubCountEnvironmentService.findQuantitativeEnvironmentTypeDefinitions(columnNames) + ]); + + return { qualitative_environments, quantitative_environments }; +} + +/** + * Given a list of column names and the environment type definitions, creates a map of column names to their respective + * environment type definitions. + * + * @export + * @param {string[]} columnNames + * @param {EnvironmentType} environmentTypeDefinitions + * @return {*} {EnvironmentNameTypeDefinitionMap} + */ +export function getEnvironmentColumnsTypeDefinitionMap( + columnNames: string[], + environmentTypeDefinitions: EnvironmentType +): EnvironmentNameTypeDefinitionMap { + const columnNameDefinitionMap = new Map< + string, + QualitativeEnvironmentTypeDefinition | QuantitativeEnvironmentTypeDefinition + >(); + + // Map column names to their respective environment type definitions + for (const columnName of columnNames) { + const qualitativeEnvironment = environmentTypeDefinitions.qualitative_environments.find( + (item) => item.name.toLowerCase() === columnName.toLowerCase() + ); + if (qualitativeEnvironment) { + columnNameDefinitionMap.set(columnName, qualitativeEnvironment); + continue; + } + + const quantitativeEnvironment = environmentTypeDefinitions.quantitative_environments.find( + (item) => item.name.toLowerCase() === columnName.toLowerCase() + ); + if (quantitativeEnvironment) { + columnNameDefinitionMap.set(columnName, quantitativeEnvironment); + } + } + + return columnNameDefinitionMap; +} + +/** + * Checks if all passed in environment data is valid. + * Returns false at first invalid environment. + * + * @export + * @param {IEnvironmentDataToValidate[]} environmentsToValidate + * @param {EnvironmentNameTypeDefinitionMap} environmentNameTypeDefinitionMap + * @return {*} {boolean} + */ +export function validateEnvironments( + environmentsToValidate: IEnvironmentDataToValidate[], + environmentNameTypeDefinitionMap: EnvironmentNameTypeDefinitionMap +): boolean { + return environmentsToValidate.every((environmentToValidate) => { + if (!environmentToValidate.value) { + // An empty value is valid + return true; + } + + const environmentDefinition = environmentNameTypeDefinitionMap.get(environmentToValidate.key); + + if (!environmentDefinition) { + // Column name does not match any environment definition. The incoming data is invalid. + return false; + } + + if (isEnvironmentQualitativeTypeDefinition(environmentDefinition)) { + return isQualitativeValueValid( + String(environmentToValidate.value), + environmentDefinition.options.map((option) => option.name) + ); + } + + return isQuantitativeValueValid( + Number(environmentToValidate.value), + environmentDefinition.min, + environmentDefinition.max + ); + }); +} + +/** + * Type guard to check if a given item is a `QualitativeEnvironmentTypeDefinition`. + * + * Qualitative environments have an `options` property, while quantitative environments do not. + * + * @export + * @param {(QualitativeEnvironmentTypeDefinition | QuantitativeEnvironmentTypeDefinition)} item + * @return {*} {item is QualitativeEnvironmentTypeDefinition} + */ +export function isEnvironmentQualitativeTypeDefinition( + item: QualitativeEnvironmentTypeDefinition | QuantitativeEnvironmentTypeDefinition +): item is QualitativeEnvironmentTypeDefinition { + return 'options' in item; +} diff --git a/api/src/utils/observation-xlsx-utils/measurement-column-utils.test.ts b/api/src/utils/observation-xlsx-utils/measurement-column-utils.test.ts new file mode 100644 index 0000000000..015d657887 --- /dev/null +++ b/api/src/utils/observation-xlsx-utils/measurement-column-utils.test.ts @@ -0,0 +1,446 @@ +import { expect } from 'chai'; +import { describe } from 'mocha'; +import sinon from 'sinon'; +import { + CBMeasurementUnit, + CBQualitativeMeasurementTypeDefinition, + CBQuantitativeMeasurementTypeDefinition, + CritterbaseService +} from '../../services/critterbase-service'; +import * as measurement_column_utils from './measurement-column-utils'; + +describe('measurement-column-utils', () => { + describe('isMeasurementCBQualitativeTypeDefinition', () => { + it('returns a CBQualitativeMeasurementTypeDefinition', () => { + const item: CBQualitativeMeasurementTypeDefinition = { + itis_tsn: 1, + taxon_measurement_id: '', + measurement_name: 'Cool measurement', + measurement_desc: '', + options: [ + { + qualitative_option_id: '', + option_label: '', + option_value: 0, + option_desc: '' + } + ] + }; + const result = measurement_column_utils.isMeasurementCBQualitativeTypeDefinition(item); + + expect(result).to.be.true; + }); + it('returns a CBQuantitativeMeasurementTypeDefinition', () => { + const item: CBQuantitativeMeasurementTypeDefinition = { + itis_tsn: 111, + taxon_measurement_id: '', + measurement_name: '', + measurement_desc: '', + min_value: null, + max_value: 500, + unit: CBMeasurementUnit.Enum.centimeter + }; + const result = measurement_column_utils.isMeasurementCBQualitativeTypeDefinition(item); + expect(result).to.be.false; + }); + }); + + describe('getMeasurementFromTsnMeasurementTypeDefinitionMap', () => { + it('finds no measurement and returns null', () => { + const tsnMap: measurement_column_utils.TsnMeasurementTypeDefinitionMap = { + '123': { + qualitative: [ + { + itis_tsn: 123, + taxon_measurement_id: 'taxon_1', + measurement_name: 'Neck Girth', + measurement_desc: '', + options: [ + { + qualitative_option_id: 'option_1', + option_label: 'Neck Girth', + option_value: 0, + option_desc: '' + } + ] + } + ], + quantitative: [ + { + itis_tsn: 123, + taxon_measurement_id: 'taxon_2', + measurement_name: 'legs', + measurement_desc: '', + min_value: null, + max_value: 4, + unit: CBMeasurementUnit.Enum.centimeter + } + ] + } + }; + const results = measurement_column_utils.getMeasurementFromTsnMeasurementTypeDefinitionMap('tsn', '', tsnMap); + expect(results).to.be.null; + }); + it('has measurements but no qualitative or quantitative and returns null', () => { + const tsnMap: measurement_column_utils.TsnMeasurementTypeDefinitionMap = { + '123': { + qualitative: [], + quantitative: [] + } + }; + const results = measurement_column_utils.getMeasurementFromTsnMeasurementTypeDefinitionMap('123', '', tsnMap); + expect(results).to.be.null; + }); + + it('finds a qualitative measurement', () => { + const tsnMap: measurement_column_utils.TsnMeasurementTypeDefinitionMap = { + '123': { + qualitative: [ + { + itis_tsn: 123, + taxon_measurement_id: 'taxon_1', + measurement_name: 'neck_girth', + measurement_desc: '', + options: [ + { + qualitative_option_id: 'option_1', + option_label: 'Neck Girth', + option_value: 0, + option_desc: '' + } + ] + }, + { + itis_tsn: 223, + taxon_measurement_id: 'taxon_2', + measurement_name: 'neck_size', + measurement_desc: '', + options: [ + { + qualitative_option_id: 'option_2', + option_label: 'Big', + option_value: 0, + option_desc: '' + } + ] + } + ], + quantitative: [ + { + itis_tsn: 123, + taxon_measurement_id: 'taxon_2', + measurement_name: 'legs', + measurement_desc: '', + min_value: null, + max_value: 4, + unit: CBMeasurementUnit.Enum.centimeter + } + ] + } + }; + const results = measurement_column_utils.getMeasurementFromTsnMeasurementTypeDefinitionMap( + '123', + 'neck_girth', + tsnMap + ); + expect(results).to.eql({ + itis_tsn: 123, + taxon_measurement_id: 'taxon_1', + measurement_name: 'neck_girth', + measurement_desc: '', + options: [ + { + qualitative_option_id: 'option_1', + option_label: 'Neck Girth', + option_value: 0, + option_desc: '' + } + ] + }); + }); + + it('finds a quantitative measurement', () => { + const tsnMap: measurement_column_utils.TsnMeasurementTypeDefinitionMap = { + '123': { + qualitative: [ + { + itis_tsn: 123, + taxon_measurement_id: 'taxon_1', + measurement_name: 'neck_girth', + measurement_desc: '', + options: [ + { + qualitative_option_id: 'option_1', + option_label: 'Neck Girth', + option_value: 0, + option_desc: '' + } + ] + }, + { + itis_tsn: 223, + taxon_measurement_id: 'taxon_2', + measurement_name: 'neck_size', + measurement_desc: '', + options: [ + { + qualitative_option_id: 'option_2', + option_label: 'Big', + option_value: 0, + option_desc: '' + } + ] + } + ], + quantitative: [ + { + itis_tsn: 123, + taxon_measurement_id: 'taxon_2', + measurement_name: 'legs', + measurement_desc: '', + min_value: null, + max_value: 4, + unit: CBMeasurementUnit.Enum.centimeter + } + ] + } + }; + const results = measurement_column_utils.getMeasurementFromTsnMeasurementTypeDefinitionMap('123', 'legs', tsnMap); + expect(results).to.eql({ + itis_tsn: 123, + taxon_measurement_id: 'taxon_2', + measurement_name: 'legs', + measurement_desc: '', + min_value: null, + max_value: 4, + unit: CBMeasurementUnit.Enum.centimeter + }); + }); + }); + + describe('getTsnMeasurementTypeDefinitionMap', () => { + afterEach(() => { + sinon.restore(); + }); + it('fetch definitions per tsn', async () => { + const getTaxonMeasurementsStub = sinon + .stub(CritterbaseService.prototype, 'getTaxonMeasurements') + .resolves({ qualitative: [], quantitative: [] }); + + const service = new CritterbaseService({ keycloak_guid: '', username: '' }); + const results = await measurement_column_utils.getTsnMeasurementTypeDefinitionMap(['tsn', 'tsn1'], service); + + expect(getTaxonMeasurementsStub).to.be.calledTwice; + expect(results).to.eql({ + tsn: { qualitative: [], quantitative: [] }, + tsn1: { qualitative: [], quantitative: [] } + }); + }); + + it('throws when no measurements are fetched', async () => { + sinon + .stub(CritterbaseService.prototype, 'getTaxonMeasurements') + .resolves(null as unknown as { qualitative: []; quantitative: [] }); + + const service = new CritterbaseService({ keycloak_guid: '', username: '' }); + + try { + await measurement_column_utils.getTsnMeasurementTypeDefinitionMap(['tsn', 'tsn1'], service); + expect.fail(); + } catch (error) { + expect((error as Error).message).contains('No measurements found for tsn: tsn'); + } + }); + + it('throws when critterbase is unavailable', async () => { + sinon.stub(CritterbaseService.prototype, 'getTaxonMeasurements').rejects(); + + const service = new CritterbaseService({ keycloak_guid: '', username: '' }); + + try { + await measurement_column_utils.getTsnMeasurementTypeDefinitionMap(['tsn', 'tsn1'], service); + expect.fail(); + } catch (error) { + expect((error as Error).message).equals('Error connecting to the Critterbase API'); + } + }); + }); + + describe('validateMeasurements', () => { + it('no data to validate return true', () => { + const tsnMap: measurement_column_utils.TsnMeasurementTypeDefinitionMap = { + '123': { + qualitative: [ + { + itis_tsn: 123, + taxon_measurement_id: 'taxon_1', + measurement_name: 'Neck Girth', + measurement_desc: '', + options: [ + { + qualitative_option_id: 'option_1', + option_label: 'Neck Girth', + option_value: 0, + option_desc: '' + } + ] + } + ], + quantitative: [ + { + itis_tsn: 123, + taxon_measurement_id: 'taxon_2', + measurement_name: 'legs', + measurement_desc: '', + min_value: null, + max_value: 4, + unit: CBMeasurementUnit.Enum.centimeter + } + ] + } + }; + const data: measurement_column_utils.IMeasurementDataToValidate[] = []; + const results = measurement_column_utils.validateMeasurements(data, tsnMap); + expect(results).to.be.true; + }); + + it('no measurements returns false', () => { + const tsnMap: measurement_column_utils.TsnMeasurementTypeDefinitionMap = { + '123': { + qualitative: [ + { + itis_tsn: 123, + taxon_measurement_id: 'taxon_1', + measurement_name: 'Neck Girth', + measurement_desc: '', + options: [ + { + qualitative_option_id: 'option_1', + option_label: 'Neck Girth', + option_value: 0, + option_desc: '' + } + ] + } + ], + quantitative: [ + { + itis_tsn: 123, + taxon_measurement_id: 'taxon_2', + measurement_name: 'legs', + measurement_desc: '', + min_value: null, + max_value: 4, + unit: CBMeasurementUnit.Enum.centimeter + } + ] + } + }; + const data: measurement_column_utils.IMeasurementDataToValidate[] = [ + { + tsn: '2', + key: 'taxon_1', + value: 'option_1' + } + ]; + const results = measurement_column_utils.validateMeasurements(data, tsnMap); + expect(results).to.be.false; + }); + + it('data provided is valid', () => { + const tsnMap: measurement_column_utils.TsnMeasurementTypeDefinitionMap = { + '123': { + qualitative: [ + { + itis_tsn: 123, + taxon_measurement_id: 'taxon_1', + measurement_name: 'Neck Girth', + measurement_desc: '', + options: [ + { + qualitative_option_id: 'option_1', + option_label: 'Neck Girth', + option_value: 0, + option_desc: '' + } + ] + } + ], + quantitative: [ + { + itis_tsn: 123, + taxon_measurement_id: 'taxon_2', + measurement_name: 'legs', + measurement_desc: '', + min_value: null, + max_value: 4, + unit: CBMeasurementUnit.Enum.centimeter + } + ] + } + }; + const data: measurement_column_utils.IMeasurementDataToValidate[] = [ + { + tsn: '123', + key: 'taxon_1', + value: 'option_1' + }, + { + tsn: '123', + key: 'taxon_2', + value: 3 + } + ]; + const results = measurement_column_utils.validateMeasurements(data, tsnMap); + expect(results).to.be.true; + }); + + it('data provided, no measurements found, returns false', () => { + const tsnMap: measurement_column_utils.TsnMeasurementTypeDefinitionMap = { + '123': { + qualitative: [ + { + itis_tsn: 123, + taxon_measurement_id: 'taxon_1', + measurement_name: 'Neck Girth', + measurement_desc: '', + options: [ + { + qualitative_option_id: 'option_1', + option_label: 'Neck Girth', + option_value: 0, + option_desc: '' + } + ] + } + ], + quantitative: [ + { + itis_tsn: 123, + taxon_measurement_id: 'taxon_2', + measurement_name: 'legs', + measurement_desc: '', + min_value: null, + max_value: 4, + unit: CBMeasurementUnit.Enum.centimeter + } + ] + } + }; + const data: measurement_column_utils.IMeasurementDataToValidate[] = [ + { + tsn: '123', + key: 'tax_1', + value: 'option_1' + }, + { + tsn: '123', + key: 'tax_2', + value: 3 + } + ]; + const results = measurement_column_utils.validateMeasurements(data, tsnMap); + expect(results).to.be.false; + }); + }); +}); diff --git a/api/src/utils/observation-xlsx-utils/measurement-column-utils.ts b/api/src/utils/observation-xlsx-utils/measurement-column-utils.ts new file mode 100644 index 0000000000..db570dcb53 --- /dev/null +++ b/api/src/utils/observation-xlsx-utils/measurement-column-utils.ts @@ -0,0 +1,219 @@ +import { ApiGeneralError } from '../../errors/api-error'; +import { + CBQualitativeMeasurementTypeDefinition, + CBQuantitativeMeasurementTypeDefinition, + CritterbaseService +} from '../../services/critterbase-service'; +import { isQualitativeValueValid, isQuantitativeValueValid } from './common-utils'; + +export type TsnMeasurementTypeDefinitionMap = Record< + string, + { qualitative: CBQualitativeMeasurementTypeDefinition[]; quantitative: CBQuantitativeMeasurementTypeDefinition[] } +>; + +export interface IMeasurementDataToValidate { + tsn: string; + key: string; + value: string | number; +} + +/** + * Fetches measurement definitions from critterbase for a given list of TSNs and creates and returns a map with all data fetched + * Throws if a TSN does not return measurements. + * + * @param {string[]} tsns List of TSNs + * @param {CritterbaseService} critterBaseService Critterbase service + * @returns {*} Promise + */ +export async function getTsnMeasurementTypeDefinitionMap( + tsns: string[], + critterBaseService: CritterbaseService +): Promise { + const tsnMeasurements: TsnMeasurementTypeDefinitionMap = {}; + + for (const tsn of tsns) { + if (tsnMeasurements[tsn]) { + // Already fetched measurements for this TSN + continue; + } + + const measurements = await critterBaseService.getTaxonMeasurements(tsn).catch((error) => { + throw new ApiGeneralError('Error connecting to the Critterbase API', [error as Error]); + }); + + if (!measurements) { + throw new ApiGeneralError(`No measurements found for tsn: ${tsn}`); + } + + tsnMeasurements[tsn] = measurements; + } + + return tsnMeasurements; +} + +/** + * Get measurement definition from the provided tsn measurement map, based on the provided TSN and + * measurement column name. + * + * @export + * @param {string} tsn An ITIS TSN number. + * @param {string} measurementColumnName + * @param {TsnMeasurementTypeDefinitionMap} tsnMeasurementTypeDefinitionMap + * @return {*} {(CBQuantitativeMeasurementTypeDefinition | CBQualitativeMeasurementTypeDefinition | null | undefined)} + */ +export function getMeasurementFromTsnMeasurementTypeDefinitionMap( + tsn: string, + measurementColumnName: string, + tsnMeasurementTypeDefinitionMap: TsnMeasurementTypeDefinitionMap +): CBQuantitativeMeasurementTypeDefinition | CBQualitativeMeasurementTypeDefinition | null | undefined { + const measurements = tsnMeasurementTypeDefinitionMap[tsn]; + + if (!measurements) { + // No measurements for TSN + return null; + } + + if (measurements.qualitative.length > 0) { + const qualitativeMeasurement = measurements.qualitative.find( + (measurement) => measurement.measurement_name.toLowerCase() === measurementColumnName.toLowerCase() + ); + + if (qualitativeMeasurement) { + // Found qualitative measurement by column/ measurement name + return qualitativeMeasurement; + } + } + + if (measurements.quantitative.length > 0) { + const quantitativeMeasurement = measurements.quantitative.find( + (measurement) => measurement.measurement_name.toLowerCase() === measurementColumnName.toLowerCase() + ); + + if (quantitativeMeasurement) { + // Found quantitative measurement by column/ measurement name + return quantitativeMeasurement; + } + } + + // No measurements found for TSN + return null; +} + +/** + * Type guard to check if a given item is a `CBQualitativeMeasurementTypeDefinition`. + * + * Qualitative measurements have an `options` property, while quantitative measurements do not. + * + * @export + * @param {(CBQuantitativeMeasurementTypeDefinition | CBQualitativeMeasurementTypeDefinition)} item + * @return {*} {item is CBQualitativeMeasurementTypeDefinition} + */ +export function isMeasurementCBQualitativeTypeDefinition( + item: CBQuantitativeMeasurementTypeDefinition | CBQualitativeMeasurementTypeDefinition +): item is CBQualitativeMeasurementTypeDefinition { + return 'options' in item; +} + +/** + * Given an array of column names, returns an array of column names that have a corresponding measurement type + * definition in the provided TsnMeasurementTypeDefinitionMap. + * + * @export + * @param {string[]} columns + * @param {TsnMeasurementTypeDefinitionMap} tsnMeasurements + * @return {*} {string[]} + */ +export function getMeasurementColumnNames( + columns: string[], + tsnMeasurements: TsnMeasurementTypeDefinitionMap +): string[] { + const allQualitativeMeasurementTypeDefinitions = Object.values(tsnMeasurements).flatMap((tsn) => tsn.qualitative); + const allQuantitativeMeasurementTypeDefinitions = Object.values(tsnMeasurements).flatMap((tsn) => tsn.quantitative); + + // Filter out columns that have no corresponding measurement type definition + return columns.filter((column) => { + const columnLowerCase = column.toLowerCase(); + + return ( + allQualitativeMeasurementTypeDefinitions.some( + (measurement) => measurement.measurement_name.toLowerCase() === columnLowerCase + ) || + allQuantitativeMeasurementTypeDefinitions.some( + (measurement) => measurement.measurement_name.toLowerCase() === columnLowerCase + ) + ); + }); +} + +/** + * Checks if all passed in measurement data is valid. + * Returns false at first invalid measurement. + * + * @export + * @param {IMeasurementDataToValidate[]} data The measurement data to validate + * @param {TsnMeasurementTypeDefinitionMap} tsnMeasurementMap An object map of measurement definitions from Critterbase organized by TSN numbers + * @returns {*} boolean Results of validation + */ +export function validateMeasurements( + measurementsToValidate: IMeasurementDataToValidate[], + tsnMeasurementMap: TsnMeasurementTypeDefinitionMap +): boolean { + return measurementsToValidate.every((measurementToValidate) => { + if (!measurementToValidate.value) { + // An empty value is valid + return true; + } + + const measurementsForTsn = tsnMeasurementMap[measurementToValidate.tsn]; + + if (!measurementsForTsn) { + // No measurement was found for the TSN for this measurement. The incoming data is invalid. + return false; + } + + // Attempt to find the qualitative measurement that matches the column name, if any + const matchingQualitativeMeasurement = measurementsForTsn.qualitative.find((measurementForTsn) => + // Compare the column name to the measurement name and the taxon_measurement_id + [measurementForTsn.taxon_measurement_id.toLowerCase(), measurementForTsn.measurement_name.toLowerCase()].includes( + measurementToValidate.key.toLowerCase() + ) + ); + + // Attempt to find the quantitative measurement that matches the column name, if any + const matchingQuantitativeMeasurement = measurementsForTsn.quantitative.find((measurementForTsn) => + // Compare the column name to the measurement name and the taxon_measurement_id + [measurementForTsn.taxon_measurement_id.toLowerCase(), measurementForTsn.measurement_name.toLowerCase()].includes( + measurementToValidate.key.toLowerCase() + ) + ); + + if (matchingQualitativeMeasurement && matchingQuantitativeMeasurement) { + // Column name matches both qualitative and quantitative measurements. The Critterbase measurement + // reference data is invalid. + return false; + } + + if (matchingQualitativeMeasurement) { + return isQualitativeValueValid( + String(measurementToValidate.value).toLowerCase(), + // Flatten the options array to include the option id, value, and label + matchingQualitativeMeasurement.options.flatMap((option) => [ + String(option.qualitative_option_id), + String(option.option_value), + option.option_label.toLowerCase() + ]) + ); + } + + if (matchingQuantitativeMeasurement) { + return isQuantitativeValueValid( + Number(measurementToValidate.value), + matchingQuantitativeMeasurement.min_value, + matchingQuantitativeMeasurement.max_value + ); + } + + // Column name does not match any measurements for the TSN. The incoming data is invalid. + return false; + }); +} diff --git a/api/src/utils/observation-xlsx-utils/standard-column-utils.test.ts b/api/src/utils/observation-xlsx-utils/standard-column-utils.test.ts new file mode 100644 index 0000000000..1ebda9d77a --- /dev/null +++ b/api/src/utils/observation-xlsx-utils/standard-column-utils.test.ts @@ -0,0 +1,283 @@ +import { expect } from 'chai'; +import { describe } from 'mocha'; +import xlsx from 'xlsx'; + +import * as standard_column_utils from './standard-column-utils'; + +describe('standard-column-utils', () => { + describe('getNonStandardColumnNamesFromWorksheet', () => { + it('returns the non-standard column headers in UPPERCASE', () => { + const xlsxWorksheet: xlsx.WorkSheet = { + A1: { t: 's', v: 'Species' }, + B1: { t: 's', v: 'Count' }, + C1: { t: 's', v: 'Date' }, + D1: { t: 's', v: 'Time' }, + E1: { t: 's', v: 'Latitude' }, + F1: { t: 's', v: 'Longitude' }, + G1: { t: 's', v: 'Antler Configuration' }, + H1: { t: 's', v: 'Wind Direction' }, + A2: { t: 'n', w: '180703', v: 180703 }, + B2: { t: 'n', w: '1', v: 1 }, + C2: { z: 'm/d/yy', t: 'd', v: '1970-01-01T08:00:00.000Z', w: '1/1/70' }, + D2: { t: 's', v: '9:01' }, + E2: { t: 'n', w: '-58', v: -58 }, + F2: { t: 'n', w: '-123', v: -123 }, + G2: { t: 's', v: 'more than 3 points' }, + H2: { t: 's', v: 'North' }, + A3: { t: 'n', w: '180596', v: 180596 }, + B3: { t: 'n', w: '2', v: 2 }, + C3: { z: 'm/d/yy', t: 'd', v: '1970-01-01T08:00:00.000Z', w: '1/1/70' }, + D3: { t: 's', v: '9:02' }, + E3: { t: 'n', w: '-57', v: -57 }, + F3: { t: 'n', w: '-122', v: -122 }, + H3: { t: 's', v: 'North' }, + A4: { t: 'n', w: '180713', v: 180713 }, + B4: { t: 'n', w: '3', v: 3 }, + C4: { z: 'm/d/yy', t: 'd', v: '1970-01-01T08:00:00.000Z', w: '1/1/70' }, + D4: { t: 's', v: '9:03' }, + E4: { t: 'n', w: '-56', v: -56 }, + F4: { t: 'n', w: '-121', v: -121 }, + H4: { t: 's', v: 'North' }, + '!ref': 'A1:H9' + }; + + const result = standard_column_utils.getNonStandardColumnNamesFromWorksheet(xlsxWorksheet); + + expect(result).to.eql(['ANTLER CONFIGURATION', 'WIND DIRECTION']); + }); + }); + + describe('getTsnFromRow', () => { + it('returns the tsn', () => { + const row: Record = { + OTHER: 'other', + ITIS_TSN: '123456', + OTHER2: 'other2' + }; + + const result = standard_column_utils.getTsnFromRow(row); + + expect(result).to.equal('123456'); + }); + + it('returns the tsn', () => { + const row: Record = { + OTHER: 'other', + TSN: '123456', + OTHER2: 'other2' + }; + + const result = standard_column_utils.getTsnFromRow(row); + + expect(result).to.equal('123456'); + }); + + it('returns the tsn', () => { + const row: Record = { + OTHER: 'other', + TAXON: '123456', + OTHER2: 'other2' + }; + + const result = standard_column_utils.getTsnFromRow(row); + + expect(result).to.equal('123456'); + }); + + it('returns the tsn', () => { + const row: Record = { + OTHER: 'other', + SPECIES: '123456', + OTHER2: 'other2' + }; + + const result = standard_column_utils.getTsnFromRow(row); + + expect(result).to.equal('123456'); + }); + + it('returns undefined when no known tsn field is present', () => { + const row: Record = { + OTHER: 'other', + OTHER2: 'other2' + }; + + const result = standard_column_utils.getTsnFromRow(row); + + expect(result).to.equal(undefined); + }); + }); + + describe('getCountFromRow', () => { + it('returns the count', () => { + const row: Record = { + OTHER: 'other', + COUNT: '123456', + OTHER2: 'other2' + }; + + const result = standard_column_utils.getCountFromRow(row); + + expect(result).to.equal('123456'); + }); + + it('returns undefined when no known count field is present', () => { + const row: Record = { + OTHER: 'other', + OTHER2: 'other2' + }; + + const result = standard_column_utils.getCountFromRow(row); + + expect(result).to.equal(undefined); + }); + }); + + describe('getDateFromRow', () => { + it('returns the date', () => { + const row: Record = { + OTHER: 'other', + DATE: '123456', + OTHER2: 'other2' + }; + + const result = standard_column_utils.getDateFromRow(row); + + expect(result).to.equal('123456'); + }); + + it('returns undefined when no known date field is present', () => { + const row: Record = { + OTHER: 'other', + OTHER2: 'other2' + }; + + const result = standard_column_utils.getDateFromRow(row); + + expect(result).to.equal(undefined); + }); + }); + + describe('getTimeFromRow', () => { + it('returns the time', () => { + const row: Record = { + OTHER: 'other', + TIME: '123456', + OTHER2: 'other2' + }; + + const result = standard_column_utils.getTimeFromRow(row); + + expect(result).to.equal('123456'); + }); + + it('returns undefined when no known time field is present', () => { + const row: Record = { + OTHER: 'other', + OTHER2: 'other2' + }; + + const result = standard_column_utils.getTimeFromRow(row); + + expect(result).to.equal(undefined); + }); + }); + + describe('getLatitudeFromRow', () => { + it('returns the latitude', () => { + const row: Record = { + OTHER: 'other', + LATITUDE: '123456', + OTHER2: 'other2' + }; + + const result = standard_column_utils.getLatitudeFromRow(row); + + expect(result).to.equal('123456'); + }); + + it('returns the latitude', () => { + const row: Record = { + OTHER: 'other', + LAT: '123456', + OTHER2: 'other2' + }; + + const result = standard_column_utils.getLatitudeFromRow(row); + + expect(result).to.equal('123456'); + }); + + it('returns undefined when no known latitude field is present', () => { + const row: Record = { + OTHER: 'other', + OTHER2: 'other2' + }; + + const result = standard_column_utils.getLatitudeFromRow(row); + + expect(result).to.equal(undefined); + }); + }); + + describe('getLongitudeFromRow', () => { + it('returns the longitude', () => { + const row: Record = { + OTHER: 'other', + LONGITUDE: '123456', + OTHER2: 'other2' + }; + + const result = standard_column_utils.getLongitudeFromRow(row); + + expect(result).to.equal('123456'); + }); + + it('returns the longitude', () => { + const row: Record = { + OTHER: 'other', + LONG: '123456', + OTHER2: 'other2' + }; + + const result = standard_column_utils.getLongitudeFromRow(row); + + expect(result).to.equal('123456'); + }); + + it('returns the longitude', () => { + const row: Record = { + OTHER: 'other', + LON: '123456', + OTHER2: 'other2' + }; + + const result = standard_column_utils.getLongitudeFromRow(row); + + expect(result).to.equal('123456'); + }); + + it('returns the longitude', () => { + const row: Record = { + OTHER: 'other', + LNG: '123456', + OTHER2: 'other2' + }; + + const result = standard_column_utils.getLongitudeFromRow(row); + + expect(result).to.equal('123456'); + }); + + it('returns undefined when no known longitude field is present', () => { + const row: Record = { + OTHER: 'other', + OTHER2: 'other2' + }; + + const result = standard_column_utils.getLongitudeFromRow(row); + + expect(result).to.equal(undefined); + }); + }); +}); diff --git a/api/src/utils/observation-xlsx-utils/standard-column-utils.ts b/api/src/utils/observation-xlsx-utils/standard-column-utils.ts new file mode 100644 index 0000000000..eee8745960 --- /dev/null +++ b/api/src/utils/observation-xlsx-utils/standard-column-utils.ts @@ -0,0 +1,134 @@ +import xlsx from 'xlsx'; +import { getHeadersUpperCase, IXLSXCSVValidator } from '../xlsx-utils/worksheet-utils'; + +// Observation CSV standard column names and aliases +const ITIS_TSN = 'ITIS_TSN'; +const TAXON = 'TAXON'; +const SPECIES = 'SPECIES'; +const TSN = 'TSN'; + +const COUNT = 'COUNT'; + +const DATE = 'DATE'; + +const TIME = 'TIME'; + +const LATITUDE = 'LATITUDE'; +const LAT = 'LAT'; + +const LONGITUDE = 'LONGITUDE'; +const LON = 'LON'; +const LONG = 'LONG'; +const LNG = 'LNG'; + +/** + * An XLSX validation config for the standard columns of an observation CSV. + */ +export const observationStandardColumnValidator: IXLSXCSVValidator = { + columnNames: [ITIS_TSN, COUNT, DATE, TIME, LATITUDE, LONGITUDE], + columnTypes: ['number', 'number', 'date', 'string', 'number', 'number'], + columnAliases: { + ITIS_TSN: [TAXON, SPECIES, TSN], + LATITUDE: [LAT], + LONGITUDE: [LON, LONG, LNG] + } +}; + +/** + * This function pulls out any non-standard columns from a CSV so they can be processed separately. + * + * @param {xlsx.WorkSheet} xlsxWorksheets The worksheet to pull the columns from + * @returns {*} string[] The list of non-standard columns found in the CSV + */ +export function getNonStandardColumnNamesFromWorksheet(xlsxWorksheet: xlsx.WorkSheet): string[] { + const columns = getHeadersUpperCase(xlsxWorksheet); + + let aliasColumns: string[] = []; + // Create a list of all column names and aliases + if (observationStandardColumnValidator.columnAliases) { + aliasColumns = Object.values(observationStandardColumnValidator.columnAliases).flat(); + } + + const standardColumNames = [...observationStandardColumnValidator.columnNames, ...aliasColumns]; + + // Only return column names not in the validation CSV Column validator (ie: only return the non-standard columns) + return columns.filter((column) => !standardColumNames.includes(column)); +} + +/** + * Get the TSN cell value for a given row. + * + * Note: Requires the row headers to be UPPERCASE. + * + * @export + * @param {Record} row + * @return {*} + */ +export function getTsnFromRow(row: Record) { + return row[ITIS_TSN] ?? row[TSN] ?? row[TAXON] ?? row[SPECIES]; +} + +/** + * Get the count cell value for a given row. + * + * Note: Requires the row headers to be UPPERCASE. + * + * @export + * @param {Record} row + * @return {*} + */ +export function getCountFromRow(row: Record) { + return row[COUNT]; +} + +/** + * Get the date cell value for a given row. + * + * Note: Requires the row headers to be UPPERCASE. + * + * @export + * @param {Record} row + * @return {*} + */ +export function getDateFromRow(row: Record) { + return row[DATE]; +} + +/** + * Get the time cell value for a given row. + * + * Note: Requires the row headers to be UPPERCASE. + * + * @export + * @param {Record} row + * @return {*} + */ +export function getTimeFromRow(row: Record) { + return row[TIME]; +} + +/** + * Get the latitude cell value for a given row. + * + * Note: Requires the row headers to be UPPERCASE. + * + * @export + * @param {Record} row + * @return {*} + */ +export function getLatitudeFromRow(row: Record) { + return row[LATITUDE] ?? row[LAT]; +} + +/** + * Get the longitude cell value for a given row. + * + * Note: Requires the row headers to be UPPERCASE. + * + * @export + * @param {Record} row + * @return {*} + */ +export function getLongitudeFromRow(row: Record) { + return row[LONGITUDE] ?? row[LON] ?? row[LONG] ?? row[LNG]; +} diff --git a/api/src/utils/xlsx-utils/worksheet-utils.test.ts b/api/src/utils/xlsx-utils/worksheet-utils.test.ts index bae3ec33ce..f358b99c3e 100644 --- a/api/src/utils/xlsx-utils/worksheet-utils.test.ts +++ b/api/src/utils/xlsx-utils/worksheet-utils.test.ts @@ -2,546 +2,58 @@ import { expect } from 'chai'; import { describe } from 'mocha'; import sinon from 'sinon'; import xlsx from 'xlsx'; -import { - CBMeasurementUnit, - CBQualitativeMeasurementTypeDefinition, - CBQuantitativeMeasurementTypeDefinition, - CritterbaseService -} from '../../services/critterbase-service'; +import { IXLSXCSVValidator } from '../xlsx-utils/worksheet-utils'; import * as worksheet_utils from './worksheet-utils'; -describe('worksheet utils', () => { - describe('isMeasurementCBQualitativeTypeDefinition', () => { - it('returns a CBQualitativeMeasurementTypeDefinition', () => { - const item: CBQualitativeMeasurementTypeDefinition = { - itis_tsn: 1, - taxon_measurement_id: '', - measurement_name: 'Cool measurement', - measurement_desc: '', - options: [ - { - taxon_measurement_id: '', - qualitative_option_id: '', - option_label: '', - option_value: 0, - option_desc: '' - } - ] - }; - const result = worksheet_utils.isMeasurementCBQualitativeTypeDefinition(item); - - expect(result).to.be.true; - }); - it('returns a CBQuantitativeMeasurementTypeDefinition', () => { - const item: CBQuantitativeMeasurementTypeDefinition = { - itis_tsn: 111, - taxon_measurement_id: '', - measurement_name: '', - measurement_desc: '', - min_value: null, - max_value: 500, - unit: CBMeasurementUnit.Enum.centimeter - }; - const result = worksheet_utils.isMeasurementCBQualitativeTypeDefinition(item); - expect(result).to.be.false; - }); - }); - - describe('findMeasurementFromTsnMeasurements', () => { - it('finds no measurement and returns null', () => { - const tsnMap: worksheet_utils.TsnMeasurementMap = { - '123': { - qualitative: [ - { - itis_tsn: 123, - taxon_measurement_id: 'taxon_1', - measurement_name: 'Neck Girth', - measurement_desc: '', - options: [ - { - taxon_measurement_id: 'taxon_1_1', - qualitative_option_id: 'option_1', - option_label: 'Neck Girth', - option_value: 0, - option_desc: '' - } - ] - } - ], - quantitative: [ - { - itis_tsn: 123, - taxon_measurement_id: 'taxon_2', - measurement_name: 'legs', - measurement_desc: '', - min_value: null, - max_value: 4, - unit: CBMeasurementUnit.Enum.centimeter - } - ] - } - }; - const results = worksheet_utils.findMeasurementFromTsnMeasurements('tsn', '', tsnMap); - expect(results).to.be.null; - }); - it('has measurements but no qualitative or quantitative and returns null', () => { - const tsnMap: worksheet_utils.TsnMeasurementMap = { - '123': { - qualitative: [], - quantitative: [] - } - }; - const results = worksheet_utils.findMeasurementFromTsnMeasurements('123', '', tsnMap); - expect(results).to.be.null; - }); - - it('finds a qualitative measurement', () => { - const tsnMap: worksheet_utils.TsnMeasurementMap = { - '123': { - qualitative: [ - { - itis_tsn: 123, - taxon_measurement_id: 'taxon_1', - measurement_name: 'neck_girth', - measurement_desc: '', - options: [ - { - taxon_measurement_id: 'taxon_1_1', - qualitative_option_id: 'option_1', - option_label: 'Neck Girth', - option_value: 0, - option_desc: '' - } - ] - }, - { - itis_tsn: 223, - taxon_measurement_id: 'taxon_2', - measurement_name: 'neck_size', - measurement_desc: '', - options: [ - { - taxon_measurement_id: 'taxon_2_1', - qualitative_option_id: 'option_2', - option_label: 'Big', - option_value: 0, - option_desc: '' - } - ] - } - ], - quantitative: [ - { - itis_tsn: 123, - taxon_measurement_id: 'taxon_2', - measurement_name: 'legs', - measurement_desc: '', - min_value: null, - max_value: 4, - unit: CBMeasurementUnit.Enum.centimeter - } - ] - } - }; - const results = worksheet_utils.findMeasurementFromTsnMeasurements('123', 'neck_girth', tsnMap); - expect(results).to.eql({ - itis_tsn: 123, - taxon_measurement_id: 'taxon_1', - measurement_name: 'neck_girth', - measurement_desc: '', - options: [ - { - taxon_measurement_id: 'taxon_1_1', - qualitative_option_id: 'option_1', - option_label: 'Neck Girth', - option_value: 0, - option_desc: '' - } - ] - }); - }); - - it('finds a quantitative measurement', () => { - const tsnMap: worksheet_utils.TsnMeasurementMap = { - '123': { - qualitative: [ - { - itis_tsn: 123, - taxon_measurement_id: 'taxon_1', - measurement_name: 'neck_girth', - measurement_desc: '', - options: [ - { - taxon_measurement_id: 'taxon_1_1', - qualitative_option_id: 'option_1', - option_label: 'Neck Girth', - option_value: 0, - option_desc: '' - } - ] - }, - { - itis_tsn: 223, - taxon_measurement_id: 'taxon_2', - measurement_name: 'neck_size', - measurement_desc: '', - options: [ - { - taxon_measurement_id: 'taxon_2_1', - qualitative_option_id: 'option_2', - option_label: 'Big', - option_value: 0, - option_desc: '' - } - ] - } - ], - quantitative: [ - { - itis_tsn: 123, - taxon_measurement_id: 'taxon_2', - measurement_name: 'legs', - measurement_desc: '', - min_value: null, - max_value: 4, - unit: CBMeasurementUnit.Enum.centimeter - } - ] - } - }; - const results = worksheet_utils.findMeasurementFromTsnMeasurements('123', 'legs', tsnMap); - expect(results).to.eql({ - itis_tsn: 123, - taxon_measurement_id: 'taxon_2', - measurement_name: 'legs', - measurement_desc: '', - min_value: null, - max_value: 4, - unit: CBMeasurementUnit.Enum.centimeter - }); - }); - }); - - describe('getCBMeasurementsFromTSN', () => { - afterEach(() => { - sinon.restore(); - }); - it('fetch definitions per tsn', async () => { - const fetch = sinon - .stub(CritterbaseService.prototype, 'getTaxonMeasurements') - .resolves({ qualitative: [], quantitative: [] }); - - const service = new CritterbaseService({ keycloak_guid: '', username: '' }); - const results = await worksheet_utils.getCBMeasurementsFromTSN(['tsn', 'tsn1'], service); - expect(fetch).to.be.calledTwice; - expect(results).to.eql({ - tsn: { qualitative: [], quantitative: [] }, - tsn1: { qualitative: [], quantitative: [] } - }); - }); - - it('throws when no measurements are fetched', async () => { - const fetch = sinon - .stub(CritterbaseService.prototype, 'getTaxonMeasurements') - .resolves(null as unknown as { qualitative: []; quantitative: [] }); - - const service = new CritterbaseService({ keycloak_guid: '', username: '' }); - - try { - await worksheet_utils.getCBMeasurementsFromTSN(['tsn', 'tsn1'], service); - expect(fetch).to.be.calledOnce; - expect.fail(); - } catch (error) { - expect((error as Error).message).contains('No measurements found for tsn: tsn'); - } - }); - - it('throws when critterbase is unavailable', async () => { - const fetch = sinon.stub(CritterbaseService.prototype, 'getTaxonMeasurements').rejects(); - - const service = new CritterbaseService({ keycloak_guid: '', username: '' }); - - try { - await worksheet_utils.getCBMeasurementsFromTSN(['tsn', 'tsn1'], service); - expect(fetch).to.be.calledOnce; - expect.fail(); - } catch (error) { - expect((error as Error).message).contains('Error connecting to the Critterbase API:'); - } - }); - }); - - describe('isQualitativeValueValid', () => { - it('qualitative measurement label value is valid', () => { - const measurement: CBQualitativeMeasurementTypeDefinition = { - itis_tsn: 1, - taxon_measurement_id: '', - measurement_name: 'Cool measurement', - measurement_desc: '', - options: [ - { - taxon_measurement_id: 'taxon_1', - qualitative_option_id: 'option_1', - option_label: 'Hind Leg', - option_value: 0, - option_desc: '' - }, - { - taxon_measurement_id: 'taxon_1', - qualitative_option_id: 'option_2', - option_label: 'Front Leg', - option_value: 1, - option_desc: '' - } - ] - }; - const results = worksheet_utils.isQualitativeValueValid('Hind Leg', measurement); - expect(results).to.be.true; - }); - it('qualitative measurement value is valid', () => { - const measurement: CBQualitativeMeasurementTypeDefinition = { - itis_tsn: 1, - taxon_measurement_id: '', - measurement_name: 'Cool measurement', - measurement_desc: '', - options: [ - { - taxon_measurement_id: 'taxon_1', - qualitative_option_id: 'option_1', - option_label: 'Hind Leg', - option_value: 0, - option_desc: '' - }, - { - taxon_measurement_id: 'taxon_1', - qualitative_option_id: 'option_2', - option_label: 'Front Leg', - option_value: 1, - option_desc: '' - } - ] - }; - const results = worksheet_utils.isQualitativeValueValid(0, measurement); - expect(results).to.be.true; - }); - it('qualitative measurement option id is valid', () => { - const measurement: CBQualitativeMeasurementTypeDefinition = { - itis_tsn: 1, - taxon_measurement_id: '', - measurement_name: 'Cool measurement', - measurement_desc: '', - options: [ - { - taxon_measurement_id: 'taxon_1', - qualitative_option_id: 'option_1', - option_label: 'Hind Leg', - option_value: 0, - option_desc: '' - }, - { - taxon_measurement_id: 'taxon_1', - qualitative_option_id: 'option_2', - option_label: 'Front Leg', - option_value: 1, - option_desc: '' - } - ] - }; - const results = worksheet_utils.isQualitativeValueValid('option_2', measurement); - expect(results).to.be.true; - }); - - it('qualitative measurement label value is invalid', () => { - const measurement: CBQualitativeMeasurementTypeDefinition = { - itis_tsn: 1, - taxon_measurement_id: '', - measurement_name: 'Cool measurement', - measurement_desc: '', - options: [ - { - taxon_measurement_id: 'taxon_1', - qualitative_option_id: 'option_1', - option_label: 'Hind Leg', - option_value: 0, - option_desc: '' - }, - { - taxon_measurement_id: 'taxon_1', - qualitative_option_id: 'option_2', - option_label: 'Front Leg', - option_value: 1, - option_desc: '' - } - ] - }; - const results = worksheet_utils.isQualitativeValueValid('Hide Leg', measurement); - expect(results).to.be.false; - }); - it('qualitative measurement value is invalid', () => { - const measurement: CBQualitativeMeasurementTypeDefinition = { - itis_tsn: 1, - taxon_measurement_id: '', - measurement_name: 'Cool measurement', - measurement_desc: '', - options: [ - { - taxon_measurement_id: 'taxon_1', - qualitative_option_id: 'option_1', - option_label: 'Hind Leg', - option_value: 0, - option_desc: '' - }, - { - taxon_measurement_id: 'taxon_1', - qualitative_option_id: 'option_2', - option_label: 'Front Leg', - option_value: 1, - option_desc: '' - } - ] - }; - const results = worksheet_utils.isQualitativeValueValid(2, measurement); - expect(results).to.be.false; - }); - it('qualitative measurement option id is invalid', () => { - const measurement: CBQualitativeMeasurementTypeDefinition = { - itis_tsn: 1, - taxon_measurement_id: '', - measurement_name: 'Cool measurement', - measurement_desc: '', - options: [ - { - taxon_measurement_id: 'taxon_1', - qualitative_option_id: 'option_1', - option_label: 'Hind Leg', - option_value: 0, - option_desc: '' - }, - { - taxon_measurement_id: 'taxon_1', - qualitative_option_id: 'option_2', - option_label: 'Front Leg', - option_value: 1, - option_desc: '' - } - ] - }; - const results = worksheet_utils.isQualitativeValueValid('option_32', measurement); - expect(results).to.be.false; - }); - }); - - describe('isQuantitativeValueValid', () => { - describe('min max range set', () => { - it('should be valid', () => { - const measurement: CBQuantitativeMeasurementTypeDefinition = { - itis_tsn: 123, - taxon_measurement_id: 'taxon_2', - measurement_name: 'legs', - measurement_desc: '', - min_value: 1, - max_value: 4, - unit: CBMeasurementUnit.Enum.centimeter - }; - - const results = worksheet_utils.isQuantitativeValueValid(2, measurement); - expect(results).to.be.true; - }); - - it('should be invalid', () => { - const measurement: CBQuantitativeMeasurementTypeDefinition = { - itis_tsn: 123, - taxon_measurement_id: 'taxon_2', - measurement_name: 'legs', - measurement_desc: '', - min_value: 1, - max_value: 4, - unit: CBMeasurementUnit.Enum.centimeter - }; - - const results = worksheet_utils.isQuantitativeValueValid(5, measurement); - expect(results).to.be.false; - }); - }); - - describe('min range set', () => { - it('should be valid', () => { - const measurement: CBQuantitativeMeasurementTypeDefinition = { - itis_tsn: 123, - taxon_measurement_id: 'taxon_2', - measurement_name: 'legs', - measurement_desc: '', - min_value: 1, - max_value: null, - unit: CBMeasurementUnit.Enum.centimeter - }; - - const results = worksheet_utils.isQuantitativeValueValid(100, measurement); - expect(results).to.be.true; - }); - - it('should be invalid', () => { - const measurement: CBQuantitativeMeasurementTypeDefinition = { - itis_tsn: 123, - taxon_measurement_id: 'taxon_2', - measurement_name: 'legs', - measurement_desc: '', - min_value: 2, - max_value: null, - unit: CBMeasurementUnit.Enum.centimeter - }; - - const results = worksheet_utils.isQuantitativeValueValid(1, measurement); - expect(results).to.be.false; - }); - }); - describe('max range set', () => { - it('should be valid', () => { - const measurement: CBQuantitativeMeasurementTypeDefinition = { - itis_tsn: 123, - taxon_measurement_id: 'taxon_2', - measurement_name: 'legs', - measurement_desc: '', - min_value: null, - max_value: 10, - unit: CBMeasurementUnit.Enum.centimeter - }; - - const results = worksheet_utils.isQuantitativeValueValid(10, measurement); - expect(results).to.be.true; - }); - - it('should be invalid', () => { - const measurement: CBQuantitativeMeasurementTypeDefinition = { - itis_tsn: 123, - taxon_measurement_id: 'taxon_2', - measurement_name: 'legs', - measurement_desc: '', - min_value: null, - max_value: 1000, - unit: CBMeasurementUnit.Enum.centimeter - }; - - const results = worksheet_utils.isQuantitativeValueValid(2000, measurement); - expect(results).to.be.false; - }); - }); - - describe('no range set', () => { - it('should be valid', () => { - const measurement: CBQuantitativeMeasurementTypeDefinition = { - itis_tsn: 123, - taxon_measurement_id: 'taxon_2', - measurement_name: 'legs', - measurement_desc: '', - min_value: null, - max_value: null, - unit: CBMeasurementUnit.Enum.centimeter - }; - - const results = worksheet_utils.isQuantitativeValueValid(10, measurement); - expect(results).to.be.true; - }); +describe('worksheet-utils', () => { + describe('getHeadersUpperCase', () => { + it('returns the column headers in UPPERCASE', () => { + const xlsxWorksheet: xlsx.WorkSheet = { + A1: { t: 's', v: 'Species' }, + B1: { t: 's', v: 'Count' }, + C1: { t: 's', v: 'Date' }, + D1: { t: 's', v: 'Time' }, + E1: { t: 's', v: 'Latitude' }, + F1: { t: 's', v: 'Longitude' }, + G1: { t: 's', v: 'Antler Configuration' }, + H1: { t: 's', v: 'Wind Direction' }, + A2: { t: 'n', w: '180703', v: 180703 }, + B2: { t: 'n', w: '1', v: 1 }, + C2: { z: 'm/d/yy', t: 'd', v: '1970-01-01T08:00:00.000Z', w: '1/1/70' }, + D2: { t: 's', v: '9:01' }, + E2: { t: 'n', w: '-58', v: -58 }, + F2: { t: 'n', w: '-123', v: -123 }, + G2: { t: 's', v: 'more than 3 points' }, + H2: { t: 's', v: 'North' }, + A3: { t: 'n', w: '180596', v: 180596 }, + B3: { t: 'n', w: '2', v: 2 }, + C3: { z: 'm/d/yy', t: 'd', v: '1970-01-01T08:00:00.000Z', w: '1/1/70' }, + D3: { t: 's', v: '9:02' }, + E3: { t: 'n', w: '-57', v: -57 }, + F3: { t: 'n', w: '-122', v: -122 }, + H3: { t: 's', v: 'North' }, + A4: { t: 'n', w: '180713', v: 180713 }, + B4: { t: 'n', w: '3', v: 3 }, + C4: { z: 'm/d/yy', t: 'd', v: '1970-01-01T08:00:00.000Z', w: '1/1/70' }, + D4: { t: 's', v: '9:03' }, + E4: { t: 'n', w: '-56', v: -56 }, + F4: { t: 'n', w: '-121', v: -121 }, + H4: { t: 's', v: 'North' }, + '!ref': 'A1:H9' + }; + + const result = worksheet_utils.getHeadersUpperCase(xlsxWorksheet); + + expect(result).to.eql([ + 'SPECIES', + 'COUNT', + 'DATE', + 'TIME', + 'LATITUDE', + 'LONGITUDE', + 'ANTLER CONFIGURATION', + 'WIND DIRECTION' + ]); }); }); @@ -551,7 +63,7 @@ describe('worksheet utils', () => { }); it('should validate aliases', () => { - const observationCSVColumnValidator: worksheet_utils.IXLSXCSVValidator = { + const observationCSVColumnValidator: IXLSXCSVValidator = { columnNames: ['SPECIES', 'COUNT', 'DATE', 'TIME', 'LATITUDE', 'LONGITUDE'], columnTypes: ['number', 'number', 'date', 'string', 'number', 'number'], columnAliases: { @@ -563,18 +75,18 @@ describe('worksheet utils', () => { const mockWorksheet = {} as unknown as xlsx.WorkSheet; - const getWorksheetHeaderssStub = sinon - .stub(worksheet_utils, 'getWorksheetHeaders') + const getHeadersUpperCaseStub = sinon + .stub(worksheet_utils, 'getHeadersUpperCase') .callsFake(() => ['TAXON', 'COUNT', 'DATE', 'TIME', 'LAT', 'LON']); const result = worksheet_utils.validateWorksheetHeaders(mockWorksheet, observationCSVColumnValidator); - expect(getWorksheetHeaderssStub).to.be.calledOnce; + expect(getHeadersUpperCaseStub).to.be.calledOnce; expect(result).to.equal(true); }); it('should fail for unknown aliases', () => { - const observationCSVColumnValidator: worksheet_utils.IXLSXCSVValidator = { + const observationCSVColumnValidator: IXLSXCSVValidator = { columnNames: ['SPECIES', 'COUNT', 'DATE', 'TIME', 'LATITUDE', 'LONGITUDE'], columnTypes: ['number', 'number', 'date', 'string', 'number', 'number'], columnAliases: { @@ -585,196 +97,14 @@ describe('worksheet utils', () => { const mockWorksheet = {} as unknown as xlsx.WorkSheet; - const getWorksheetHeaderssStub = sinon - .stub(worksheet_utils, 'getWorksheetHeaders') + const getHeadersUpperCaseStub = sinon + .stub(worksheet_utils, 'getHeadersUpperCase') .callsFake(() => ['SPECIES', 'COUNT', 'DATE', 'TIME', 'SOMETHING_LAT', 'LON']); const result = worksheet_utils.validateWorksheetHeaders(mockWorksheet, observationCSVColumnValidator); - expect(getWorksheetHeaderssStub).to.be.calledOnce; + expect(getHeadersUpperCaseStub).to.be.calledOnce; expect(result).to.equal(false); }); }); - - describe('validateMeasurements', () => { - it('no data to validate return true', () => { - const tsnMap: worksheet_utils.TsnMeasurementMap = { - '123': { - qualitative: [ - { - itis_tsn: 123, - taxon_measurement_id: 'taxon_1', - measurement_name: 'Neck Girth', - measurement_desc: '', - options: [ - { - taxon_measurement_id: 'taxon_1_1', - qualitative_option_id: 'option_1', - option_label: 'Neck Girth', - option_value: 0, - option_desc: '' - } - ] - } - ], - quantitative: [ - { - itis_tsn: 123, - taxon_measurement_id: 'taxon_2', - measurement_name: 'legs', - measurement_desc: '', - min_value: null, - max_value: 4, - unit: CBMeasurementUnit.Enum.centimeter - } - ] - } - }; - const data: worksheet_utils.IMeasurementDataToValidate[] = []; - const results = worksheet_utils.validateMeasurements(data, tsnMap); - expect(results).to.be.true; - }); - - it('no measurements returns false', () => { - const tsnMap: worksheet_utils.TsnMeasurementMap = { - '123': { - qualitative: [ - { - itis_tsn: 123, - taxon_measurement_id: 'taxon_1', - measurement_name: 'Neck Girth', - measurement_desc: '', - options: [ - { - taxon_measurement_id: 'taxon_1_1', - qualitative_option_id: 'option_1', - option_label: 'Neck Girth', - option_value: 0, - option_desc: '' - } - ] - } - ], - quantitative: [ - { - itis_tsn: 123, - taxon_measurement_id: 'taxon_2', - measurement_name: 'legs', - measurement_desc: '', - min_value: null, - max_value: 4, - unit: CBMeasurementUnit.Enum.centimeter - } - ] - } - }; - const data: worksheet_utils.IMeasurementDataToValidate[] = [ - { - tsn: '2', - measurement_key: 'taxon_1', - measurement_value: 'option_1' - } - ]; - const results = worksheet_utils.validateMeasurements(data, tsnMap); - expect(results).to.be.false; - }); - - it('data provided is valid', () => { - const tsnMap: worksheet_utils.TsnMeasurementMap = { - '123': { - qualitative: [ - { - itis_tsn: 123, - taxon_measurement_id: 'taxon_1', - measurement_name: 'Neck Girth', - measurement_desc: '', - options: [ - { - taxon_measurement_id: 'taxon_1_1', - qualitative_option_id: 'option_1', - option_label: 'Neck Girth', - option_value: 0, - option_desc: '' - } - ] - } - ], - quantitative: [ - { - itis_tsn: 123, - taxon_measurement_id: 'taxon_2', - measurement_name: 'legs', - measurement_desc: '', - min_value: null, - max_value: 4, - unit: CBMeasurementUnit.Enum.centimeter - } - ] - } - }; - const data: worksheet_utils.IMeasurementDataToValidate[] = [ - { - tsn: '123', - measurement_key: 'taxon_1', - measurement_value: 'option_1' - }, - { - tsn: '123', - measurement_key: 'taxon_2', - measurement_value: 3 - } - ]; - const results = worksheet_utils.validateMeasurements(data, tsnMap); - expect(results).to.be.true; - }); - - it('data provided, no measurements found, returns false', () => { - const tsnMap: worksheet_utils.TsnMeasurementMap = { - '123': { - qualitative: [ - { - itis_tsn: 123, - taxon_measurement_id: 'taxon_1', - measurement_name: 'Neck Girth', - measurement_desc: '', - options: [ - { - taxon_measurement_id: 'taxon_1_1', - qualitative_option_id: 'option_1', - option_label: 'Neck Girth', - option_value: 0, - option_desc: '' - } - ] - } - ], - quantitative: [ - { - itis_tsn: 123, - taxon_measurement_id: 'taxon_2', - measurement_name: 'legs', - measurement_desc: '', - min_value: null, - max_value: 4, - unit: CBMeasurementUnit.Enum.centimeter - } - ] - } - }; - const data: worksheet_utils.IMeasurementDataToValidate[] = [ - { - tsn: '123', - measurement_key: 'tax_1', - measurement_value: 'option_1' - }, - { - tsn: '123', - measurement_key: 'tax_2', - measurement_value: 3 - } - ]; - const results = worksheet_utils.validateMeasurements(data, tsnMap); - expect(results).to.be.false; - }); - }); }); diff --git a/api/src/utils/xlsx-utils/worksheet-utils.ts b/api/src/utils/xlsx-utils/worksheet-utils.ts index 8bf692c9e5..9bba6a5298 100644 --- a/api/src/utils/xlsx-utils/worksheet-utils.ts +++ b/api/src/utils/xlsx-utils/worksheet-utils.ts @@ -1,11 +1,5 @@ import { default as dayjs } from 'dayjs'; import xlsx, { CellObject } from 'xlsx'; -import { ApiGeneralError } from '../../errors/api-error'; -import { - CBQualitativeMeasurementTypeDefinition, - CBQuantitativeMeasurementTypeDefinition, - CritterbaseService -} from '../../services/critterbase-service'; import { getLogger } from '../logger'; import { MediaFile } from '../media/media-file'; import { DEFAULT_XLSX_SHEET_NAME } from '../media/xlsx/xlsx-file'; @@ -20,11 +14,6 @@ export interface IXLSXCSVValidator { columnAliases?: Record; } -export type TsnMeasurementMap = Record< - string, - { qualitative: CBQualitativeMeasurementTypeDefinition[]; quantitative: CBQuantitativeMeasurementTypeDefinition[] } ->; - /** * Returns true if the given cell is a date type cell. * @@ -38,30 +27,13 @@ export const constructXLSXWorkbook = (file: MediaFile, options?: xlsx.ParsingOpt }; /** - * Constructs a CSVWorksheets from the given workbook - * - * @export - * @param {xlsx.WorkBook} workbook - * @return {*} {CSVWorksheets} - */ -export const constructWorksheets = (workbook: xlsx.WorkBook): xlsx.WorkSheet => { - const worksheets: xlsx.WorkSheet = {}; - - Object.entries(workbook.Sheets).forEach(([key, value]) => { - worksheets[key] = value; - }); - - return worksheets; -}; - -/** - * Get the headers for the given worksheet, then transforms them to uppercase. + * Get the UPPERCASE headers (column names) for the given worksheet. * * @export * @param {xlsx.WorkSheet} worksheet * @return {*} {string[]} */ -export const getWorksheetHeaders = (worksheet: xlsx.WorkSheet): string[] => { +export const getHeadersUpperCase = (worksheet: xlsx.WorkSheet): string[] => { const originalRange = getWorksheetRange(worksheet); if (!originalRange) { @@ -89,14 +61,14 @@ export const getWorksheetHeaders = (worksheet: xlsx.WorkSheet): string[] => { }; /** - * Get the headers for the given worksheet, with all values converted to lowercase. + * Get the lowercase headers (column names) for the given worksheet. * * @export * @param {xlsx.WorkSheet} worksheet * @return {*} {string[]} */ export const getHeadersLowerCase = (worksheet: xlsx.WorkSheet): string[] => { - return getWorksheetHeaders(worksheet).map(safeToLowerCase); + return getHeadersUpperCase(worksheet).map(safeToLowerCase); }; /** @@ -108,7 +80,7 @@ export const getHeadersLowerCase = (worksheet: xlsx.WorkSheet): string[] => { * @return {*} {number} */ export const getHeaderIndex = (worksheet: xlsx.WorkSheet, headerName: string): number => { - return getWorksheetHeaders(worksheet).indexOf(headerName); + return getHeadersUpperCase(worksheet).indexOf(headerName); }; /** @@ -128,7 +100,7 @@ export const getWorksheetRows = (worksheet: xlsx.WorkSheet): string[][] => { const rowsToReturn: string[][] = []; for (let i = 1; i <= originalRange.e.r; i++) { - const row = new Array(getWorksheetHeaders(worksheet).length); + const row = new Array(getHeadersUpperCase(worksheet).length); let rowHasValues = false; for (let j = 0; j <= originalRange.e.c; j++) { @@ -156,6 +128,22 @@ export const getWorksheetRows = (worksheet: xlsx.WorkSheet): string[][] => { /** * Return an array of row value arrays. * + * Note: The column headers will be transformed to UPPERCASE. + * + * @example + * [ + * { + * "HEADER1": "value1", + * "HEADER2": "value2", + * "HEADER3": "value3" + * }, + * { + * "HEADER1": "value4", + * "HEADER2": "value5", + * "HEADER3": "value6" + * } + * ] + * * @export * @param {xlsx.WorkSheet} worksheet * @return {*} {Record[]} @@ -169,17 +157,17 @@ export const getWorksheetRowObjects = (worksheet: xlsx.WorkSheet): Record[] = []; const rows = getWorksheetRows(worksheet); - const headers = getWorksheetHeaders(worksheet); + const headers = getHeadersUpperCase(worksheet); - rows.forEach((row: string[]) => { - const rowObject = {}; + for (let i = 0; i < rows.length; i++) { + const rowObject: Record = {}; - headers.forEach((header: string, index: number) => { - rowObject[header] = row[index]; - }); + for (let j = 0; j < headers.length; j++) { + rowObject[headers[j]] = rows[i][j]; + } rowObjectsArray.push(rowObject); - }); + } return rowObjectsArray; }; @@ -195,7 +183,7 @@ export const getWorksheetRowObjects = (worksheet: xlsx.WorkSheet): Record { const { columnNames, columnAliases } = columnValidator; - const worksheetHeaders = getWorksheetHeaders(worksheet); + const worksheetHeaders = getHeadersUpperCase(worksheet); return columnNames.every((expectedHeader) => { return ( @@ -237,6 +225,19 @@ export const validateWorksheetColumnTypes = ( }); }; +/** + * Attempt to get the default worksheet. If the default worksheet is not found, returns the first worksheet. + * + * @param {xlsx.WorkBook} workbook + * @param {string} [defaultSheetNameOverride] Optional override for the default sheet name. + * @return {*} {xlsx.WorkSheet} + */ +export const getDefaultWorksheet = (workbook: xlsx.WorkBook, defaultSheetNameOverride?: string): xlsx.WorkSheet => { + return ( + workbook.Sheets[defaultSheetNameOverride ?? DEFAULT_XLSX_SHEET_NAME] || workbook.Sheets[workbook.SheetNames[0]] + ); +}; + /** * Get a worksheet by name. * @@ -300,312 +301,23 @@ export const prepareWorksheetCells = (worksheet: xlsx.WorkSheet) => { /** * Validates the given CSV file against the given column validator * - * @param {xlsx.WorkSheet} xlsxWorksheets + * @export + * @param {xlsx.WorkSheet} xlsxWorksheet * @param {IXLSXCSVValidator} columnValidator * @return {*} {boolean} - * @memberof ObservationService */ -export function validateCsvFile( - xlsxWorksheets: xlsx.WorkSheet, - columnValidator: IXLSXCSVValidator, - sheet = DEFAULT_XLSX_SHEET_NAME -): boolean { +export function validateCsvFile(xlsxWorksheet: xlsx.WorkSheet, columnValidator: IXLSXCSVValidator): boolean { // Validate the worksheet headers - if (!validateWorksheetHeaders(xlsxWorksheets[sheet], columnValidator)) { + if (!validateWorksheetHeaders(xlsxWorksheet, columnValidator)) { defaultLog.debug({ label: 'validateCsvFile', message: 'Invalid: Headers' }); return false; } // Validate the worksheet column types - if (!validateWorksheetColumnTypes(xlsxWorksheets[sheet], columnValidator)) { + if (!validateWorksheetColumnTypes(xlsxWorksheet, columnValidator)) { defaultLog.debug({ label: 'validateCsvFile', message: 'Invalid: Column types' }); return false; } return true; } - -export interface IMeasurementDataToValidate { - tsn: string; - measurement_key: string; // Column name, Grid table field or measurement_taxon_id to validate - measurement_value: string | number; -} - -/** - * Checks if all passed in measurement data is valid or returns false at first invalid measurement. - * - * @param {IMeasurementDataToValidate[]} data The measurement data to validate - * @param {TsnMeasurementMap} tsnMeasurementMap An object map of measurement definitions from Critterbase organized by TSN numbers - * @returns {*} boolean Results of validation - */ -export function validateMeasurements( - data: IMeasurementDataToValidate[], - tsnMeasurementMap: TsnMeasurementMap -): boolean { - return data.every((item) => { - const measurements = tsnMeasurementMap[item.tsn]; - if (!measurements) { - defaultLog.debug({ label: 'validateMeasurements', message: 'Invalid: No measurements' }); - return false; - } - - // only validate if the column has data - if (!item.measurement_value) { - return true; - } - - // find the correct measurement - if (measurements.qualitative.length > 0) { - const measurement = measurements.qualitative.find( - (measurement) => - measurement.measurement_name.toLowerCase() === item.measurement_key.toLowerCase() || - measurement.taxon_measurement_id === item.measurement_key - ); - if (measurement) { - return isQualitativeValueValid(item.measurement_value, measurement); - } - } - - if (measurements.quantitative.length > 0) { - const measurement = measurements.quantitative.find( - (measurement) => - measurement.measurement_name.toLowerCase() === item.measurement_key.toLowerCase() || - measurement.taxon_measurement_id === item.measurement_key - ); - if (measurement) { - return isQuantitativeValueValid(Number(item.measurement_value), measurement); - } - } - - // Has measurements for tsn - // Has data but no matches found, entry is invalid - defaultLog.debug({ label: 'validateMeasurements', message: 'Invalid', item }); - return false; - }); -} - -/** - * Preps provided work sheet row object data (rows) to be validated against given TsnMeasurementMap - * - * @param {Record[]} rows - * @param {string[]} measurementColumns - * @param {TsnMeasurementMap} tsnMeasurementMap - * @returns {*} boolean - */ -export function validateCsvMeasurementColumns( - rows: Record[], - measurementColumns: string[], - tsnMeasurementMap: TsnMeasurementMap -): boolean { - const mappedData: IMeasurementDataToValidate[] = rows.flatMap((row) => { - return measurementColumns.map((mColumn) => ({ - tsn: String(row['ITIS_TSN'] ?? row['TSN'] ?? row['TAXON'] ?? row['SPECIES']), - measurement_key: mColumn, - measurement_value: row[mColumn] - })); - }); - return validateMeasurements(mappedData, tsnMeasurementMap); -} - -/** - * This function take a value and a measurement to validate against. `CBQuantitativeMeasurementTypeDefinition` can contain - * a range of valid values, so the incoming value is compared against the min max values in the type definition. - * - * @param {number} value The measurement value to validate - * @param {CBQuantitativeMeasurementTypeDefinition} measurement The type definition of the measurement from Critterbase - * @returns {*} Boolean - */ -export function isQuantitativeValueValid(value: number, measurement: CBQuantitativeMeasurementTypeDefinition): boolean { - const min_value = measurement.min_value; - const max_value = measurement.max_value; - - if (min_value && max_value) { - if (min_value <= value && value <= max_value) { - return true; - } - } else { - if (min_value !== null && min_value <= value) { - return true; - } - - if (max_value !== null && value <= max_value) { - return true; - } - - if (min_value === null && max_value === null) { - return true; - } - } - - defaultLog.debug({ label: 'isQuantitativeValueValid', message: 'Invalid', value, measurement }); - return false; -} - -/** - * This function validates the value provided against a Qualitative Measurement. - * As a string, the function will compare the value against known option labels and will return true if any are found. - * As a number, the function will compare the value against the option values (the position or index of the option) and will return true if any are found. - * - * @param {string | number} value the value to validate - * @param {CBQualitativeMeasurementTypeDefinition} measurement The type definition of the measurement from Critterbase - * @returns {*} Boolean - */ -export function isQualitativeValueValid( - value: string | number, - measurement: CBQualitativeMeasurementTypeDefinition -): boolean { - // Check for option value, label OR option uuid - const foundOption = measurement.options.find( - (option) => - option.option_value === Number(value) || - option.option_label.toLowerCase() === String(value).toLowerCase() || - option.qualitative_option_id.toLowerCase() === String(value) - ); - - if (foundOption) { - return true; - } - - defaultLog.debug({ label: 'isQualitativeValueValid', message: 'Invalid', value, measurement }); - return false; -} - -/** - * This function pulls out any measurement (non required columns) from a CSV so they can be processed properly. - * - * @param {xlsx.WorkSheet} xlsxWorksheets The worksheet to pull the columns from - * @param {IXLSXCSVValidator} columnValidator Column validator - * @param {string} sheet The sheet to work on - * @returns {*} string[] The list of measurement columns found in the CSV - */ -export function getMeasurementColumnNameFromWorksheet( - xlsxWorksheets: xlsx.WorkSheet, - columnValidator: IXLSXCSVValidator, - sheet = DEFAULT_XLSX_SHEET_NAME -): string[] { - const columns = getWorksheetHeaders(xlsxWorksheets[sheet]); - let aliasColumns: string[] = []; - // Create a list of all column names and aliases - if (columnValidator.columnAliases) { - aliasColumns = Object.values(columnValidator.columnAliases).flat(); - } - const requiredColumns = [...columnValidator.columnNames, ...aliasColumns]; - return columns - .map((column) => { - // only return column names not in the validation CSV Column validator (extra/measurement columns) - if (!requiredColumns.includes(column)) { - return column; - } - }) - .filter((c): c is string => Boolean(c)); // remove undefined/ nulls from the array -} - -/** - * Fetch all measurements from critter base for TSN numbers found in provided worksheet - * - * @param {xlsx.WorkSheet} xlsxWorksheets The worksheet to pull the columns from - * @param {CritterbaseService} critterBaseService - * @param {string} sheet The sheet to work on - * @returns {*} Promise - */ -export async function getCBMeasurementsFromWorksheet( - xlsxWorksheets: xlsx.WorkSheet, - critterBaseService: CritterbaseService, - sheet = DEFAULT_XLSX_SHEET_NAME -): Promise { - const rows = getWorksheetRowObjects(xlsxWorksheets[sheet]); - const tsns = rows.map((row) => String(row['ITIS_TSN'] ?? row['TSN'] ?? row['TAXON'] ?? row['SPECIES'])); - return getCBMeasurementsFromTSN(tsns, critterBaseService); -} - -/** - * Fetches measurement definitions from critterbase for a given list of TSNs and creates and returns a map with all data fetched - * Throws if a TSN does not return measurements. - * - * @param {string[]} tsns List of TSNs - * @param {CritterbaseService} critterBaseService Critterbase service - * @returns {*} Promise - */ -export async function getCBMeasurementsFromTSN( - tsns: string[], - critterBaseService: CritterbaseService -): Promise { - const tsnMeasurements: TsnMeasurementMap = {}; - try { - for (const tsn of tsns) { - if (!tsnMeasurements[tsn]) { - const measurements = await critterBaseService.getTaxonMeasurements(tsn); - if (!measurements) { - throw new Error(`No measurements found for tsn: ${tsn}`); - } - - tsnMeasurements[tsn] = measurements; - } - } - } catch (error) { - getLogger('utils/xlsx-utils').error({ label: 'getCBMeasurementsFromWorksheet', message: 'error', error }); - throw new ApiGeneralError(`Error connecting to the Critterbase API: ${error}`); - } - return tsnMeasurements; -} - -/** - * Search for a measurement given xlsx column name and tsn id - * - * @param {string} tsn - * @param {string} measurementColumnName - * @param {TsnMeasurementMap} tsnMeasurements - * @returns {*} CBQuantitativeMeasurementTypeDefinition | CBQualitativeMeasurementTypeDefinition | null | undefined - */ -export function findMeasurementFromTsnMeasurements( - tsn: string, - measurementColumnName: string, - tsnMeasurements: TsnMeasurementMap -): CBQuantitativeMeasurementTypeDefinition | CBQualitativeMeasurementTypeDefinition | null | undefined { - const measurements = tsnMeasurements[tsn]; - - if (!measurements) { - // No measurements for tsn - return null; - } - - if (measurements.qualitative.length > 0) { - const qualitativeMeasurement = measurements.qualitative.find( - (measurement) => measurement.measurement_name.toLowerCase() === measurementColumnName.toLowerCase() - ); - - if (qualitativeMeasurement) { - // Found qualitative measurement by column/ measurement name - return qualitativeMeasurement; - } - } - - if (measurements.quantitative.length > 0) { - const quantitativeMeasurement = measurements.quantitative.find( - (measurement) => measurement.measurement_name.toLowerCase() === measurementColumnName.toLowerCase() - ); - - if (quantitativeMeasurement) { - // Found quantitative measurement by column/ measurement name - return quantitativeMeasurement; - } - } - - // No measurements found for tsn - return null; -} - -/** - * Type guard to check if a given item is a `CBQualitativeMeasurementTypeDefinition`. - * - * Qualitative measurements have an `options` property, while quantitative measurements do not. - * - * @export - * @param {(CBQuantitativeMeasurementTypeDefinition | CBQualitativeMeasurementTypeDefinition)} item - * @return {*} {item is CBQualitativeMeasurementTypeDefinition} - */ -export function isMeasurementCBQualitativeTypeDefinition( - item: CBQuantitativeMeasurementTypeDefinition | CBQualitativeMeasurementTypeDefinition -): item is CBQualitativeMeasurementTypeDefinition { - return 'options' in item; -} diff --git a/app/src/App.tsx b/app/src/App.tsx index 6f063d6363..a22693d7ea 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -4,14 +4,13 @@ import AppRouter from 'AppRouter'; import { AuthStateContext, AuthStateContextProvider } from 'contexts/authStateContext'; import { ConfigContext, ConfigContextProvider } from 'contexts/configContext'; import { WebStorageStateStore } from 'oidc-client-ts'; -import React from 'react'; import { AuthProvider, AuthProviderProps } from 'react-oidc-context'; import { BrowserRouter } from 'react-router-dom'; import appTheme from 'themes/appTheme'; import ScrollToTop from 'utils/ScrollToTop'; import { buildUrl } from 'utils/Utils'; -const App: React.FC = () => { +const App = () => { return ( diff --git a/app/src/components/data-grid/GenericGridColumnDefinitions.tsx b/app/src/components/data-grid/GenericGridColumnDefinitions.tsx index acfb69b6e1..e6711ec818 100644 --- a/app/src/components/data-grid/GenericGridColumnDefinitions.tsx +++ b/app/src/components/data-grid/GenericGridColumnDefinitions.tsx @@ -30,7 +30,7 @@ export const GenericDateColDef = (props: { align: 'left', renderCell: (params) => ( - {getFormattedDate(DATE_FORMAT.ShortDateFormatMonthFirst, params.row[field])} + {getFormattedDate(DATE_FORMAT.ShortDateFormatMonthFirst, params.value)} ), renderEditCell: (params) => { @@ -42,7 +42,8 @@ export const GenericDateColDef = (props: { textFieldProps={{ name: params.field, type: 'date', - value: params.row[field], + value: params.value ?? '', + inputProps: { max: '9999-12-31', min: '0001-01-01' }, onChange: (event) => { params.api.setEditCellValue({ id: params.id, diff --git a/app/src/components/data-grid/TextFieldDataGrid.tsx b/app/src/components/data-grid/TextFieldDataGrid.tsx index a7b37361b7..c04fa26059 100644 --- a/app/src/components/data-grid/TextFieldDataGrid.tsx +++ b/app/src/components/data-grid/TextFieldDataGrid.tsx @@ -13,16 +13,18 @@ const TextFieldDataGrid = ({ dataGridProps }: ITextFieldCustomValidation) => { const ref = useRef(); + useEnhancedEffect(() => { if (dataGridProps.hasFocus) { ref.current?.focus(); } }, [dataGridProps.hasFocus]); + return ( `Delete ${count} ${p(count, 'column')}?`, + removeMultipleEnvironmentColumnsDialogText: + 'Are you sure you want to delete these columns? This action cannot be undone.', + removeMultipleEnvironmentColumnsButtonText: 'Delete Columns', + // Save observation records success saveRecordsSuccessSnackbarMessage: 'Observations updated successfully.', // Save observation records error @@ -312,6 +322,15 @@ export const ObservationsTableI18N = { deleteMultipleMeasurementColumnSuccessSnackbarMessage: (count: number) => `Deleted ${count} measurement ${p(count, 'column')} successfully.`, + // Delete environment columns success + deleteSingleEnvironmentColumnSuccessSnackbarMessage: 'Deleted environment column successfully.', + // Delete environment columns error + removeEnvironmentColumnsErrorDialogTitle: 'Error Deleting Environment Columns', + removeEnvironmentColumnsErrorDialogText: + 'An error has occurred while attempting to delete environment columns for this survey. Please try again. If the error persists, please contact your system administrator.', + deleteMultipleEnvironmentColumnSuccessSnackbarMessage: (count: number) => + `Deleted ${count} environment ${p(count, 'column')} successfully.`, + // Import observation records importRecordsSuccessSnackbarMessage: 'Observations imported successfully.', importRecordsErrorDialogTitle: 'Error Importing Observation Records', diff --git a/app/src/constants/session-storage.ts b/app/src/constants/session-storage.ts index f0d88fafad..43e4be8013 100644 --- a/app/src/constants/session-storage.ts +++ b/app/src/constants/session-storage.ts @@ -15,6 +15,13 @@ export const SIMS_OBSERVATIONS_HIDDEN_COLUMNS = 'SIMS_OBSERVATIONS_HIDDEN_COLUMN */ export const SIMS_OBSERVATIONS_MEASUREMENT_COLUMNS = 'SIMS_OBSERVATIONS_MEASUREMENT_COLUMNS'; +/** + * Key used to cache the additional user-defined environment columns added to the observations table. + * + * Should store a JSON stringified `EnvironmentType` object. + */ +export const SIMS_OBSERVATIONS_ENVIRONMENT_COLUMNS = 'SIMS_OBSERVATIONS_ENVIRONMENT_COLUMNS'; + /** * Get a session storage key which is unique to the provided survey id. * diff --git a/app/src/contexts/observationsTableContext.tsx b/app/src/contexts/observationsTableContext.tsx index c09f4d1078..1a13f362db 100644 --- a/app/src/contexts/observationsTableContext.tsx +++ b/app/src/contexts/observationsTableContext.tsx @@ -12,7 +12,11 @@ import { } from '@mui/x-data-grid'; import { GridApiCommunity } from '@mui/x-data-grid/internals'; import { ObservationsTableI18N } from 'constants/i18n'; -import { getSurveySessionStorageKey, SIMS_OBSERVATIONS_MEASUREMENT_COLUMNS } from 'constants/session-storage'; +import { + getSurveySessionStorageKey, + SIMS_OBSERVATIONS_ENVIRONMENT_COLUMNS, + SIMS_OBSERVATIONS_MEASUREMENT_COLUMNS +} from 'constants/session-storage'; import { DialogContext } from 'contexts/dialogContext'; import { isQualitativeMeasurementTypeDefinition, @@ -20,6 +24,7 @@ import { } from 'features/surveys/observations/observations-table/grid-column-definitions/GridColumnDefinitionsUtils'; import { validateObservationTableRow, + validateObservationTableRowEnvironments, validateObservationTableRowMeasurements } from 'features/surveys/observations/observations-table/observation-row-validation/ObservationRowValidationUtils'; import { APIError } from 'hooks/api/useAxios'; @@ -27,67 +32,30 @@ import { IObservationTableRowToSave, SubcountToSave } from 'hooks/api/useObserva import { useBiohubApi } from 'hooks/useBioHubApi'; import { useObservationsContext, useObservationsPageContext, useTaxonomyContext } from 'hooks/useContext'; import { useCritterbaseApi } from 'hooks/useCritterbaseApi'; +import { CBMeasurementSearchByTsnResponse, CBMeasurementType } from 'interfaces/useCritterApi.interface'; +import { IGetSurveyObservationsResponse, ObservationRecord } from 'interfaces/useObservationApi.interface'; +import { EnvironmentType, EnvironmentTypeIds } from 'interfaces/useReferenceApi.interface'; import { - CBMeasurementType, - CBMeasurementValue, - CBQualitativeMeasurementTypeDefinition, - CBQuantitativeMeasurementTypeDefinition -} from 'interfaces/useCritterApi.interface'; -import { IGetSurveyObservationsResponse } from 'interfaces/useObservationApi.interface'; -import { createContext, PropsWithChildren, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; + createContext, + PropsWithChildren, + useCallback, + useContext, + useDeferredValue, + useEffect, + useMemo, + useRef, + useState +} from 'react'; import { firstOrNull } from 'utils/Utils'; import { v4 as uuidv4 } from 'uuid'; import { RowValidationError, TableValidationModel } from '../components/data-grid/DataGridValidationAlert'; +import { SIMS_OBSERVATIONS_HIDDEN_COLUMNS } from '../constants/session-storage'; import { SurveyContext } from './surveyContext'; -export type StandardObservationColumns = { - survey_observation_id: number; - itis_tsn: number | null; - itis_scientific_name: string | null; - survey_sample_site_id: number | null; - survey_sample_method_id: number | null; - survey_sample_period_id: number | null; - count: number | null; - observation_date: Date; - observation_time: string; - latitude: number | null; - longitude: number | null; -}; - -export type SubcountObservationColumns = { - observation_subcount_id: number | null; - subcount: number | null; - qualitative_measurements: { - field: string; - critterbase_taxon_measurement_id: string; - critterbase_measurement_qualitative_option_id: string; - }[]; - quantitative_measurements: { - critterbase_taxon_measurement_id: string; - value: number; - }[]; - [key: string]: any; -}; - -export type TSNMeasurement = { - qualitative: CBQualitativeMeasurementTypeDefinition[]; - quantitative: CBQuantitativeMeasurementTypeDefinition[]; -}; - -export type TSNMeasurementMap = Record; - -export type ObservationRecord = StandardObservationColumns & SubcountObservationColumns; - -export type SupplementaryObservationCountData = { - observationCount: number; -}; - -export type SupplementaryObservationMeasurementData = { - qualitative_measurements: CBQualitativeMeasurementTypeDefinition[]; - quantitative_measurements: CBQuantitativeMeasurementTypeDefinition[]; -}; - -export type SupplementaryObservationData = SupplementaryObservationCountData & SupplementaryObservationMeasurementData; +export type TsnMeasurementTypeDefinitionMap = Record< + string, + CBMeasurementSearchByTsnResponse | Promise +>; export interface IObservationTableRow extends Partial { id: GridRowId; @@ -130,17 +98,17 @@ export type IObservationsTableContext = { */ rowModesModel: GridRowModesModel; /** - * Sets the row modes model. + * Callback that must be provided to the MUI DataGrid component to handle row modes changes. */ - setRowModesModel: React.Dispatch>; + onRowModesModelChange: (model: GridRowModesModel) => void; /** * The column visibility model, which defines which columns are visible or hidden. */ columnVisibilityModel: GridColumnVisibilityModel; /** - * Sets the column visibility model. + * Callback that must be provided to the MUI DataGrid component to handle column visibility changes. */ - setColumnVisibilityModel: React.Dispatch>; + onColumnVisibilityModelChange: (model: GridColumnVisibilityModel) => void; /** * Appends a new blank record to the observation rows */ @@ -162,6 +130,15 @@ export type IObservationsTableContext = { * is successful. */ deleteObservationMeasurementColumns: (measurementIds: string[], onSuccess?: () => void) => void; + /** + * Deletes all of the given environment columns, for all observation records, and removes them from the Observation + * table. + * + * @param {EnvironmentTypeIds} environmentIds The environment ids to delete. + * @param {() => void} [onSuccess] Optional callback that fires after the user confirms the deletion, and the deletion + * is successful. + */ + deleteObservationEnvironmentColumns: (environmentIds: EnvironmentTypeIds, onSuccess?: () => void) => void; /** * discards all changes made to observation records within the Observation Table. Abandons all newly added rows that * have not yet been saved, and reverts all edits to existing rows. @@ -183,6 +160,10 @@ export type IObservationsTableContext = { * Callback that should be called when a row enters edit mode. */ onRowEditStart: (id: GridRowId) => void; + /** + * Callback that must be provided to the MUI DataGrid component to handle row updates. + */ + processRowUpdate: (newRow: IObservationTableRow) => IObservationTableRow; /** * The IDs of the selected observation table rows */ @@ -199,20 +180,6 @@ export type IObservationsTableContext = { * Indicates if the save process has started. */ isSaving: boolean; - /** - * Indicates that the rows have all transitioned to view mode successfully, and the data is about to be, or is in the - * process of being, persisted to the server. - * - * Note: This ref should not be manually updated outside of this context. - */ - _isSavingData: React.MutableRefObject; - /** - * Indicates that the rows in edit mode are transitioning to view mode, which is part of the process of persisting - * the data to the server. - * - * Note: This ref should not be manually updated outside of this context. - */ - _isStoppingEdit: React.MutableRefObject; /** * The state of the validation model */ @@ -225,10 +192,6 @@ export type IObservationsTableContext = { * Reflects the count of total observations for the survey */ observationCount: number; - /** - * Updates the total observation count for the survey - */ - setObservationCount: (observationCount: number) => void; /** * The pagination model, which defines which observation records to fetch and load in the table. */ @@ -253,6 +216,14 @@ export type IObservationsTableContext = { * Sets the user-added measurement columns. */ setMeasurementColumns: React.Dispatch>; + /** + * User-added measurement columns that are not part of the default observation table columns. + */ + environmentColumns: EnvironmentType; + /** + * Sets the user-added environment columns. + */ + setEnvironmentColumns: React.Dispatch>; /** * Used to disable the entire table. */ @@ -324,6 +295,12 @@ export const ObservationsTableContextProvider = (props: IObservationsTableContex // Stores any measurement columns that are not part of the default observation table columns const [measurementColumns, setMeasurementColumns] = useState([]); + // Stores any environment columns that are not part of the default observation table columns + const [environmentColumns, setEnvironmentColumns] = useState({ + qualitative_environments: [], + quantitative_environments: [] + }); + // Internal disabled state for the observations table, should not be used outside of this context const [_isDisabled, setIsDisabled] = useState(false); @@ -333,7 +310,18 @@ export const ObservationsTableContextProvider = (props: IObservationsTableContex }, [_isDisabled, observationsPageContext.isDisabled]); // Column visibility model - const [columnVisibilityModel, setColumnVisibilityModel] = useState({}); + const [columnVisibilityModel, setColumnVisibilityModel] = useState(() => { + // Get initial column visibility model from session storage + const measurementDefinitionsStringified = sessionStorage.getItem( + getSurveySessionStorageKey(surveyId, SIMS_OBSERVATIONS_HIDDEN_COLUMNS) + ); + + if (measurementDefinitionsStringified) { + return JSON.parse(measurementDefinitionsStringified); + } + + return {}; + }); // Pagination model const [paginationModel, setPaginationModel] = useState({ @@ -344,8 +332,8 @@ export const ObservationsTableContextProvider = (props: IObservationsTableContex // Sort model const [sortModel, setSortModel] = useState([{ field: 'observation_date', sort: 'desc' }]); - // TSN Measurement Map - const tsnMeasurementMapRef = useRef({}); + // TSN Measurement Type Definition Map + const tsnMeasurementTypeDefinitionMapRef = useRef({}); /** * Returns true if the given row has a validation error. @@ -382,51 +370,129 @@ export const ObservationsTableContextProvider = (props: IObservationsTableContex }); }, [paginationModel.page, paginationModel.pageSize, refreshObservationsData, sortModel]); + /** + * Callback fired when the column visibility model changes. + * + * Note: Any column not included in the model will default to being visible. + * + * @param {GridColumnVisibilityModel} model + */ + const onColumnVisibilityModelChange = useCallback( + (model: GridColumnVisibilityModel) => { + // Store current visibility model in session storage + sessionStorage.setItem( + getSurveySessionStorageKey(surveyId, SIMS_OBSERVATIONS_HIDDEN_COLUMNS), + JSON.stringify(model) + ); + + // Update the column visibility model in the context + setColumnVisibilityModel(model); + }, + [surveyId] + ); + + /** + * Callback fired when the row modes model changes. + * The row modes model stores the `view` vs `edit` state of the rows. + * + * Note: Any row not included in the model will default to `view` mode. + * + * @param {GridRowModesModel} model + */ + const onRowModesModelChange = useCallback((model: GridRowModesModel) => { + setRowModesModel(() => model); + }, []); + + /** + * Callback fired when a row transitions from `view` mode to `edit` mode. + * + * @param {IObservationTableRow} newRow + * @return {*} + */ + const processRowUpdate = useCallback( + (newRow: IObservationTableRow) => { + if (savedRows.find((row) => row.id === newRow.id)) { + // Update savedRows + setSavedRows((currentSavedRows) => currentSavedRows.map((row) => (row.id === newRow.id ? newRow : row))); + } else { + // Update stagedRows + setStagedRows((currentStagedRows) => currentStagedRows.map((row) => (row.id === newRow.id ? newRow : row))); + } + + return newRow; + }, + [savedRows] + ); + /** * Gets all rows from the table, including values that have been edited in the table. */ const _getRowsWithEditedValues = useCallback((): IObservationTableRow[] => { - const rowValues = Array.from(_muiDataGridApiRef.current.getRowModels?.()?.values()) as IObservationTableRow[]; + return modifiedRowIds.map((modifiedRowId) => { + const row = _muiDataGridApiRef.current.getRow(modifiedRowId); + + // Get the current values from the table for the row + const editRow = _muiDataGridApiRef.current.state.editRows[modifiedRowId]; - return rowValues.map((row) => { - const editRow = _muiDataGridApiRef.current.state.editRows[row.id]; if (!editRow) { return row; } - return Object.entries(editRow).reduce( - (newRow, entry) => ({ ...row, ...newRow, _isModified: true, [entry[0]]: entry[1].value }), - {} - ); + const newRow: Record = { ...row }; + for (const [key, value] of Object.entries(editRow)) { + newRow[key] = value.value; + } + + // Return the row, which now contains the latest values from the table + return newRow; }) as IObservationTableRow[]; - }, [_muiDataGridApiRef]); + }, [_muiDataGridApiRef, modifiedRowIds]); /** - * Fetches measurement definitions from Critterbase for a given itis_tsn number + * Fetches measurement definitions from Critterbase for a given itis_tsn number, caching the responses for subsequent + * calls. + * + * @param {number} tsn + * @return {*} {(Promise)} */ - const tsnMeasurements = useCallback( - async (tsn: number): Promise => { - const currentMap = tsnMeasurementMapRef.current; - if (!currentMap[tsn]) { - const response = await critterbaseApi.xref.getTaxonMeasurements(tsn); + const getTsnMeasurementTypeDefinitionMap = useCallback( + async (tsn: number): Promise => { + const currentMap = tsnMeasurementTypeDefinitionMapRef.current; - currentMap[String(tsn)] = response; - tsnMeasurementMapRef.current = currentMap; + if (currentMap[tsn]) { + // Return cached measurements for tsn + return currentMap[tsn]; } + + // Fetch measurements for tsn + currentMap[String(tsn)] = critterbaseApi.xref.getTaxonMeasurements(tsn).then((response) => response); + + // Update the ref with the new map + tsnMeasurementTypeDefinitionMapRef.current = currentMap; + + // Return the promise return currentMap[tsn]; }, [critterbaseApi.xref] ); /** - * Validates all rows belonging to the table. Returns null if validation passes, otherwise - * returns the validation model + * Validates all rows belonging to the table. + * Returns null if validation passes, otherwise returns the validation model. + * + * @return {*} {(Promise)} */ const _validateRows = useCallback(async (): Promise => { - const rowValues = _getRowsWithEditedValues(); + const rowsWithEditedValues = _getRowsWithEditedValues(); + const tableColumns = _muiDataGridApiRef.current.getAllColumns?.() ?? []; - const requiredColumns: (keyof IObservationTableRow)[] = [ + // Kick off all tsn measurement fetches in parallel + for (const row of rowsWithEditedValues) { + row.itis_tsn && getTsnMeasurementTypeDefinitionMap(row.itis_tsn); + } + + const requiredStandardColumns: (keyof IObservationTableRow)[] = [ 'count', 'latitude', 'longitude', @@ -435,44 +501,37 @@ export const ObservationsTableContextProvider = (props: IObservationsTableContex 'itis_tsn' ]; - const samplingRequiredColumns: (keyof IObservationTableRow)[] = [ - 'survey_sample_site_id', - 'survey_sample_method_id', - 'survey_sample_period_id' - ]; - - // build an array of all the standard non measurement columns - const nonMeasurementColumns: string[] = [ - '__check__', // add check box column to filter out when looking for measurement columns - 'actions', // actions column (trash can) to filter out when looking for measurement columns - ...(requiredColumns as string[]), - ...(samplingRequiredColumns as string[]) - ]; + const validationModel: ObservationTableValidationModel = {}; - // filter all table columns out that do not appear in the nonMeasurementColumns array - const measurementColumns = tableColumns - .filter((tc) => { - return nonMeasurementColumns.indexOf(String(tc.field)) < 0; - }) - .map((item) => item.field); - const validation: ObservationTableValidationModel = {}; - for (const row of rowValues) { + for (const row of rowsWithEditedValues) { // check standard required columns - const standardColumnErrors = validateObservationTableRow(row, requiredColumns, tableColumns); + const standardColumnErrors = validateObservationTableRow(row, requiredStandardColumns, tableColumns); // check any measurement columns found - const measurementErrors = await validateObservationTableRowMeasurements(row, measurementColumns, tsnMeasurements); + const measurementErrors = await validateObservationTableRowMeasurements( + row, + measurementColumns, + getTsnMeasurementTypeDefinitionMap + ); - const totalErrors = [...standardColumnErrors, ...measurementErrors]; + const environmentErrors = await validateObservationTableRowEnvironments(row, environmentColumns); + + const totalErrors = [...standardColumnErrors, ...measurementErrors, ...environmentErrors]; if (totalErrors.length > 0) { - validation[row.id] = totalErrors; + validationModel[row.id] = totalErrors; } } - setValidationModel(validation); + setValidationModel(validationModel); - return Object.keys(validation).length > 0 ? validation : null; - }, [_getRowsWithEditedValues, _muiDataGridApiRef, tsnMeasurements]); + return Object.keys(validationModel).length > 0 ? validationModel : null; + }, [ + _getRowsWithEditedValues, + _muiDataGridApiRef, + environmentColumns, + measurementColumns, + getTsnMeasurementTypeDefinitionMap + ]); /** * Deletes the given records from the server and removes them from the table. @@ -503,26 +562,36 @@ export const ObservationsTableContextProvider = (props: IObservationsTableContex } // Remove deleted row IDs from the validation model - setValidationModel((prevValidationModel) => - allRowIdsToDelete.reduce((newValidationModel, rowId) => { - delete newValidationModel[rowId]; - return newValidationModel; - }, prevValidationModel) - ); + setValidationModel((prevValidationModel) => { + const newValidationModel = { ...prevValidationModel }; + for (const rowIdToDelete of allRowIdsToDelete) { + delete newValidationModel[rowIdToDelete]; + } + return newValidationModel; + }); // Update saved rows, removing any deleted rows - setSavedRows((current) => current.filter((item) => !savedRowIdsToDelete.includes(String(item.id)))); + setSavedRows((currentSavedRows) => + currentSavedRows.filter((savedRow) => !savedRowIdsToDelete.includes(String(savedRow.id))) + ); // Update staged rows, removing any deleted rows - setStagedRows((current) => current.filter((item) => !stagedRowIdsToDelete.includes(String(item.id)))); + setStagedRows((currentStagedRows) => + currentStagedRows.filter((stagedRow) => !stagedRowIdsToDelete.includes(String(stagedRow.id))) + ); // Updated editing rows, removing deleted rows - setModifiedRowIds((current) => current.filter((id) => !allRowIdsToDelete.includes(id))); + setModifiedRowIds((currentModifiedRowIds) => + currentModifiedRowIds.filter((modifiedRowId) => !allRowIdsToDelete.includes(modifiedRowId)) + ); // Updated row modes model, removing deleted rows - setRowModesModel((current) => { - allRowIdsToDelete.forEach((rowId) => delete current[rowId]); - return current; + setRowModesModel((currentRowModesModel) => { + const newRowModesModel = { ...currentRowModesModel }; + for (const rowIdToDelete of allRowIdsToDelete) { + delete newRowModesModel[rowIdToDelete]; + } + return newRowModesModel; }); // Close yes-no dialog @@ -613,6 +682,56 @@ export const ObservationsTableContextProvider = (props: IObservationsTableContex [setYesNoDialog, setSnackbar, biohubApi.observation, projectId, surveyId, setErrorDialog] ); + /** + * Deletes the given records from the server and removes them from the table. + * + * @param {EnvironmentTypeIds} environmentIds The critterbase taxon environment ids to delete. + * @return {*} {Promise} + */ + const _deleteEnvironmentColumns = useCallback( + async (environmentIds: EnvironmentTypeIds): Promise => { + const environmentIdsLength = + environmentIds.qualitative_environments.length + environmentIds.quantitative_environments.length; + + if (!environmentIdsLength) { + return; + } + + try { + // Delete environment columns from the database + await biohubApi.observation.deleteObservationEnvironments(projectId, surveyId, environmentIds); + + // Close yes-no dialog + setYesNoDialog({ open: false }); + + // Show snackbar for successful deletion + setSnackbar({ + snackbarMessage: ( + + {environmentIdsLength === 1 + ? ObservationsTableI18N.deleteSingleEnvironmentColumnSuccessSnackbarMessage + : ObservationsTableI18N.deleteMultipleEnvironmentColumnSuccessSnackbarMessage(environmentIdsLength)} + + ), + open: true + }); + } catch { + // Close yes-no dialog + setYesNoDialog({ open: false }); + + // Show error dialog + setErrorDialog({ + onOk: () => setErrorDialog({ open: false }), + onClose: () => setErrorDialog({ open: false }), + dialogTitle: ObservationsTableI18N.removeEnvironmentColumnsErrorDialogTitle, + dialogText: ObservationsTableI18N.removeEnvironmentColumnsErrorDialogText, + open: true + }); + } + }, + [setYesNoDialog, setSnackbar, biohubApi.observation, projectId, surveyId, setErrorDialog] + ); + /** * Returns all of the rows that have been selected. * @@ -716,6 +835,54 @@ export const ObservationsTableContextProvider = (props: IObservationsTableContex [_deleteMeasurementColumns, setYesNoDialog] ); + /** + * Renders a dialog that prompts the user to delete the given environment columns (from all observation records). + * + * @param {EnvironmentTypeIds} environmentIds The critterbase taxon environment ids to delete. + * @param {() => void} [onSuccess] Optional callback that fires after the user confirms the deletion, and the deletion + * is successful. + * @return {*} + */ + const deleteObservationEnvironmentColumns = useCallback( + (environmentIds: EnvironmentTypeIds, onSuccess?: () => void) => { + const environmentIdsLength = + environmentIds.qualitative_environments.length + environmentIds.quantitative_environments.length; + + if (!environmentIdsLength) { + return; + } + + setYesNoDialog({ + dialogTitle: + environmentIdsLength === 1 + ? ObservationsTableI18N.removeSingleEnvironmentColumnDialogTitle + : ObservationsTableI18N.removeMultipleEnvironmentColumnsDialogTitle(environmentIdsLength), + dialogText: + environmentIdsLength === 1 + ? ObservationsTableI18N.removeSingleEnvironmentColumnDialogText + : ObservationsTableI18N.removeMultipleEnvironmentColumnsDialogText, + yesButtonProps: { + color: 'error', + loading: false + }, + yesButtonLabel: + environmentIdsLength === 1 + ? ObservationsTableI18N.removeSingleEnvironmentColumnButtonText + : ObservationsTableI18N.removeMultipleEnvironmentColumnsButtonText, + noButtonProps: { color: 'primary', variant: 'outlined', disabled: false }, + noButtonLabel: 'Cancel', + open: true, + onYes: async () => { + await _deleteEnvironmentColumns(environmentIds); + onSuccess?.(); + }, + onClose: () => setYesNoDialog({ open: false }), + onNo: () => setYesNoDialog({ open: false }) + }); + }, + [_deleteEnvironmentColumns, setYesNoDialog] + ); + /** * Puts the specified row into edit mode, and adds the row id to the array of modified rows. * @@ -747,14 +914,17 @@ export const ObservationsTableContextProvider = (props: IObservationsTableContex itis_scientific_name: '' }; - // Append new record to initial rows - setStagedRows([...stagedRows, newRecord]); + // Append new record to start of staged rows + setStagedRows([newRecord, ...stagedRows]); // Set edit mode for the new row setRowModesModel((current) => ({ ...current, [id]: { mode: GridRowModes.Edit } })); + + // Add new row id to modified rows array + setModifiedRowIds((current) => [...current, id]); }, [stagedRows]); /** @@ -785,15 +955,7 @@ export const ObservationsTableContextProvider = (props: IObservationsTableContex _isStoppingEdit.current = true; - // Collect the ids of all rows in edit mode - const allEditingIds = Object.keys(_muiDataGridApiRef.current.state.editRows); - - // Remove any row ids that the data grid might still be tracking, but which have been removed from local state - const editingIdsToSave = allEditingIds.filter((id) => - [...savedRows, ...stagedRows].find((row) => String(row.id) === id) - ); - - if (!editingIdsToSave.length) { + if (!modifiedRowIds.length) { // No rows in edit mode, nothing to stop or save _isStoppingEdit.current = false; return; @@ -802,15 +964,12 @@ export const ObservationsTableContextProvider = (props: IObservationsTableContex // Transition all rows in edit mode to view mode setRowModesModel(() => { const newModel: GridRowModesModel = {}; - for (const id of editingIdsToSave) { + for (const id of modifiedRowIds) { newModel[id] = { mode: GridRowModes.View }; } return newModel; }); - - // Store ids of rows that were in edit mode - setModifiedRowIds(editingIdsToSave); - }, [_muiDataGridApiRef, _validateRows, setErrorDialog, savedRows, stagedRows]); + }, [modifiedRowIds, _validateRows, setErrorDialog]); /** * Transition all rows tracked by `modifiedRowIds` to edit mode. @@ -823,8 +982,6 @@ export const ObservationsTableContextProvider = (props: IObservationsTableContex * Transition all rows tracked by `modifiedRowIds` to edit mode. */ const discardChanges = useCallback(() => { - // Remove any rows from the modified rows array - setModifiedRowIds([]); // Remove any newly created rows setStagedRows([]); // Clear any validation errors @@ -837,10 +994,12 @@ export const ObservationsTableContextProvider = (props: IObservationsTableContex } return newModel; }); + // Remove any rows from the modified rows array + setModifiedRowIds([]); }, [modifiedRowIds]); // True if the data grid contains at least 1 unsaved record - const hasUnsavedChanges = modifiedRowIds.length > 0 || stagedRows.length > 0; + const hasUnsavedChanges = useDeferredValue(modifiedRowIds.length > 0 || stagedRows.length > 0); // True if the taxonomy cache is still initializing or the observations data is still loading const isLoading: boolean = useMemo(() => { @@ -906,8 +1065,8 @@ export const ObservationsTableContextProvider = (props: IObservationsTableContex */ const _getMeasurementsToSave = useCallback( (row: ObservationRecord) => { - const qualitative: SubcountToSave['qualitative'] = []; - const quantitative: SubcountToSave['quantitative'] = []; + const qualitative: SubcountToSave['qualitative_measurements'] = []; + const quantitative: SubcountToSave['quantitative_measurements'] = []; // For each measurement column in the data grid for (const measurementDefinition of measurementColumns) { @@ -937,6 +1096,45 @@ export const ObservationsTableContextProvider = (props: IObservationsTableContex [measurementColumns] ); + /** + * Compiles all environment columns and their values for the given row. + * + * @param {ObservationRecord} row + * @return {*} + */ + const _getEnvironmentsToSave = useCallback( + (row: ObservationRecord) => { + const qualitative: SubcountToSave['qualitative_environments'] = []; + const quantitative: SubcountToSave['quantitative_environments'] = []; + + // For each qualitative environment column in the data grid + for (const environmentDefinition of environmentColumns.qualitative_environments) { + // If the row has a non-null/non-undefined value for the column + if (row[String(environmentDefinition.environment_qualitative_id)]) { + qualitative.push({ + environment_qualitative_id: environmentDefinition.environment_qualitative_id, + environment_qualitative_option_id: row[String(environmentDefinition.environment_qualitative_id)] + }); + } + } + + // For each quantitative environment column in the data grid + for (const environmentDefinition of environmentColumns.quantitative_environments) { + // If the row has a non-null/non-undefined value for the column + if (row[String(environmentDefinition.environment_quantitative_id)]) { + quantitative.push({ + environment_quantitative_id: environmentDefinition.environment_quantitative_id, + value: row[String(environmentDefinition.environment_quantitative_id)] + }); + } + } + + // Return the qualitative and quantitative arrays + return { qualitative, quantitative }; + }, + [environmentColumns] + ); + /** * Compiles all subcount columns and their values for the given row. * @@ -947,6 +1145,7 @@ export const ObservationsTableContextProvider = (props: IObservationsTableContex (row: ObservationRecord) => { // Get all populated measurement column values for the row const measurementsToSave = _getMeasurementsToSave(row); + const environmentsToSave = _getEnvironmentsToSave(row); // Return the subcount row to save return { @@ -955,11 +1154,13 @@ export const ObservationsTableContextProvider = (props: IObservationsTableContex // Why?: Currently there is no UI support for setting a subcount value. // See https://apps.nrs.gov.bc.ca/int/jira/browse/SIMSBIOHUB-534 subcount: row.count, - qualitative: measurementsToSave.qualitative, - quantitative: measurementsToSave.quantitative + qualitative_measurements: measurementsToSave.qualitative, + quantitative_measurements: measurementsToSave.quantitative, + qualitative_environments: environmentsToSave.qualitative, + quantitative_environments: environmentsToSave.quantitative }; }, - [_getMeasurementsToSave] + [_getEnvironmentsToSave, _getMeasurementsToSave] ); /** @@ -1043,14 +1244,28 @@ export const ObservationsTableContextProvider = (props: IObservationsTableContex ...acc, [cur.critterbase_taxon_measurement_id]: cur.critterbase_measurement_qualitative_option_id }; - }, {} as CBMeasurementValue), + }, {}), // Reduce the array of quantitative measurements into an object and spread into the row ...subcountRow.quantitative_measurements.reduce((acc, cur) => { return { ...acc, [cur.critterbase_taxon_measurement_id]: cur.value }; - }, {} as CBMeasurementValue) + }, {}), + // Reduce the array of qualitative environments into an object and spread into the row + ...subcountRow.qualitative_environments.reduce((acc, cur) => { + return { + ...acc, + [cur.environment_qualitative_id]: cur.environment_qualitative_option_id + }; + }, {}), + // Reduce the array of quantitative environments into an object and spread into the row + ...subcountRow.quantitative_environments.reduce((acc, cur) => { + return { + ...acc, + [cur.environment_quantitative_id]: cur.value + }; + }, {}) }; }); }); @@ -1069,7 +1284,7 @@ export const ObservationsTableContextProvider = (props: IObservationsTableContex /** * Runs when the observations data is loaded or refreshed. - * Set the measurement columns. + * Set the measurement and environment columns. */ useEffect(() => { if (!observationsData) { @@ -1103,6 +1318,54 @@ export const ObservationsTableContextProvider = (props: IObservationsTableContex // Set measurement columns, including both existing and local storage measurement definitions return [...existingMeasurementDefinitions, ...localStorageMeasurementDefinitions]; }); + + setEnvironmentColumns(() => { + // Existing environment definitions from the observations data + const existingEnvironmentDefinitions = { + qualitative_environments: observationsData.supplementaryObservationData.qualitative_environments, + quantitative_environments: observationsData.supplementaryObservationData.quantitative_environments + }; + + // Get all environment definitions from local storage, if any + const environmentDefinitionsStringified = sessionStorage.getItem( + getSurveySessionStorageKey(surveyId, SIMS_OBSERVATIONS_ENVIRONMENT_COLUMNS) + ); + + let localStorageEnvironmentDefinitions: EnvironmentType = { + qualitative_environments: [], + quantitative_environments: [] + }; + if (environmentDefinitionsStringified) { + localStorageEnvironmentDefinitions = JSON.parse(environmentDefinitionsStringified) as EnvironmentType; + } + + // Remove any duplicate environment definitions that already exist in the observations data + const localStorageEnvironmentQualitativeDefinitions = + localStorageEnvironmentDefinitions.qualitative_environments.filter((item1) => { + return !existingEnvironmentDefinitions.qualitative_environments.some( + (item2) => item2.environment_qualitative_id === item1.environment_qualitative_id + ); + }); + + const localStorageEnvironmentQuantitativeDefinitions = + localStorageEnvironmentDefinitions.quantitative_environments.filter((item1) => { + return !existingEnvironmentDefinitions.quantitative_environments.some( + (item2) => item2.environment_quantitative_id === item1.environment_quantitative_id + ); + }); + + // Set environment columns, including both existing and local storage environment definitions + return { + qualitative_environments: [ + ...existingEnvironmentDefinitions.qualitative_environments, + ...localStorageEnvironmentQualitativeDefinitions + ], + quantitative_environments: [ + ...existingEnvironmentDefinitions.quantitative_environments, + ...localStorageEnvironmentQuantitativeDefinitions + ] + }; + }); }, [observationsData, surveyId]); /** @@ -1223,27 +1486,26 @@ export const ObservationsTableContextProvider = (props: IObservationsTableContex stagedRows, setStagedRows, rowModesModel, - setRowModesModel, + onRowModesModelChange, columnVisibilityModel, - setColumnVisibilityModel, + onColumnVisibilityModelChange, addObservationRecord, saveObservationRecords, deleteObservationRecords, deleteObservationMeasurementColumns, + deleteObservationEnvironmentColumns, discardChanges, refreshObservationRecords, getSelectedObservationRecords, hasUnsavedChanges, onRowEditStart, + processRowUpdate, rowSelectionModel, onRowSelectionModelChange: setRowSelectionModel, isLoading, isSaving, - _isSavingData, - _isStoppingEdit, validationModel, observationCount, - setObservationCount, setPaginationModel, paginationModel, setSortModel, @@ -1251,6 +1513,8 @@ export const ObservationsTableContextProvider = (props: IObservationsTableContex sortModel, measurementColumns, setMeasurementColumns, + environmentColumns, + setEnvironmentColumns, isDisabled, setIsDisabled }), @@ -1259,15 +1523,19 @@ export const ObservationsTableContextProvider = (props: IObservationsTableContex savedRows, stagedRows, rowModesModel, + onRowModesModelChange, columnVisibilityModel, + onColumnVisibilityModelChange, addObservationRecord, saveObservationRecords, deleteObservationRecords, deleteObservationMeasurementColumns, + deleteObservationEnvironmentColumns, discardChanges, refreshObservationRecords, getSelectedObservationRecords, hasUnsavedChanges, + processRowUpdate, rowSelectionModel, isLoading, isSaving, @@ -1277,6 +1545,7 @@ export const ObservationsTableContextProvider = (props: IObservationsTableContex hasError, sortModel, measurementColumns, + environmentColumns, isDisabled ] ); diff --git a/app/src/features/standards/view/components/EnvironmentStandardCard.tsx b/app/src/features/standards/view/components/EnvironmentStandardCard.tsx new file mode 100644 index 0000000000..036309e7c5 --- /dev/null +++ b/app/src/features/standards/view/components/EnvironmentStandardCard.tsx @@ -0,0 +1,75 @@ +import { mdiChevronDown, mdiChevronUp } from '@mdi/js'; +import Icon from '@mdi/react'; +import { Box, Card, Collapse, Paper, Stack, Typography } from '@mui/material'; +import { grey } from '@mui/material/colors'; +import { EnvironmentQualitativeOption } from 'interfaces/useReferenceApi.interface'; +import { useState } from 'react'; + +interface IEnvironmentStandardCard { + label: string; + description?: string; + options?: EnvironmentQualitativeOption[]; + unit?: string; + small?: boolean; +} + +/** + * Card to display environment information. + * + * @return {*} + */ +const EnvironmentStandardCard = (props: IEnvironmentStandardCard) => { + const [isCollapsed, setIsCollapsed] = useState(true); + const { small } = props; + + return ( + setIsCollapsed(!isCollapsed)}> + + + {props.label} + + + + + + + {props.description ? props.description : 'No description'} + + + + {props.options?.map((option) => ( + + + {option.name} + + + {option?.description} + + + ))} + + + + ); +}; + +export default EnvironmentStandardCard; diff --git a/app/src/features/standards/view/components/MeasurementStandardCard.tsx b/app/src/features/standards/view/components/MeasurementStandardCard.tsx index ba3512f9e7..095031a323 100644 --- a/app/src/features/standards/view/components/MeasurementStandardCard.tsx +++ b/app/src/features/standards/view/components/MeasurementStandardCard.tsx @@ -10,6 +10,7 @@ interface IMeasurementStandardCard { description?: string; options?: CBQualitativeOption[]; unit?: string; + small?: boolean; } /** @@ -19,10 +20,11 @@ interface IMeasurementStandardCard { */ const MeasurementStandardCard = (props: IMeasurementStandardCard) => { const [isCollapsed, setIsCollapsed] = useState(true); + const { small } = props; return ( setIsCollapsed(!isCollapsed)}> @@ -43,21 +45,23 @@ const MeasurementStandardCard = (props: IMeasurementStandardCard) => { {props.description ? props.description : 'No description'} - + {props.options?.map((option) => ( + elevation={0} + key={option.qualitative_option_id}> {option.option_label} - + {option?.option_desc} diff --git a/app/src/features/surveys/observations/observations-table/ObservationsTable.tsx b/app/src/features/surveys/observations/observations-table/ObservationsTable.tsx index 495ca0710c..970f794743 100644 --- a/app/src/features/surveys/observations/observations-table/ObservationsTable.tsx +++ b/app/src/features/surveys/observations/observations-table/ObservationsTable.tsx @@ -1,12 +1,9 @@ import { cyan, grey } from '@mui/material/colors'; -import { DataGrid, GridColDef, GridColumnVisibilityModel, GridRowModesModel } from '@mui/x-data-grid'; +import { DataGrid, GridColDef } from '@mui/x-data-grid'; import { SkeletonTable } from 'components/loading/SkeletonLoaders'; -import { getSurveySessionStorageKey, SIMS_OBSERVATIONS_HIDDEN_COLUMNS } from 'constants/session-storage'; import { IObservationTableRow } from 'contexts/observationsTableContext'; -import { SurveyContext } from 'contexts/surveyContext'; import { useObservationsTableContext } from 'hooks/useContext'; import { has } from 'lodash-es'; -import { useCallback, useContext } from 'react'; export interface ISpeciesObservationTableProps { /** @@ -26,69 +23,8 @@ export interface ISpeciesObservationTableProps { } const ObservationsTable = (props: ISpeciesObservationTableProps) => { - const { surveyId } = useContext(SurveyContext); - const observationsTableContext = useObservationsTableContext(); - /** - * Callback fired when the column visibility model changes. - * - * @param {GridColumnVisibilityModel} model - */ - const onColumnVisibilityModelChange = useCallback( - (model: GridColumnVisibilityModel) => { - // Store current visibility model in session storage - sessionStorage.setItem( - getSurveySessionStorageKey(surveyId, SIMS_OBSERVATIONS_HIDDEN_COLUMNS), - JSON.stringify(model) - ); - - // Update the column visibility model in the context - observationsTableContext.setColumnVisibilityModel(model); - }, - [observationsTableContext, surveyId] - ); - - /** - * Callback fired when a row transitions from `view` mode to `edit` mode. - * - * @param {IObservationTableRow} newRow - * @return {*} - */ - const processRowUpdate = useCallback( - (newRow: IObservationTableRow) => { - if (observationsTableContext.savedRows.find((row) => row.id === newRow.id)) { - // Update savedRows - observationsTableContext.setSavedRows((currentSavedRows) => - currentSavedRows.map((row) => (row.id === newRow.id ? newRow : row)) - ); - } else { - // Update stagedRows - observationsTableContext.setStagedRows((currentStagedRows) => - currentStagedRows.map((row) => (row.id === newRow.id ? newRow : row)) - ); - } - - return newRow; - }, - [observationsTableContext] - ); - - /** - * Callback fired when the row modes model changes. - * The row modes model stores the `view` vs `edit` state of the rows. - * - * Note: Any row not included in the model will default to `view` mode. - * - * @param {GridRowModesModel} model - */ - const onRowModesModelChange = useCallback( - (model: GridRowModesModel) => { - observationsTableContext.setRowModesModel(() => model); - }, - [observationsTableContext] - ); - return ( <> {props.isLoading && } @@ -100,13 +36,13 @@ const ObservationsTable = (props: ISpeciesObservationTableProps) => { columns={props.columns} // Column visibility columnVisibilityModel={observationsTableContext.columnVisibilityModel} - onColumnVisibilityModelChange={onColumnVisibilityModelChange} + onColumnVisibilityModelChange={observationsTableContext.onColumnVisibilityModelChange} // Rows - rows={[...observationsTableContext.savedRows, ...observationsTableContext.stagedRows]} - processRowUpdate={processRowUpdate} + rows={[...observationsTableContext.stagedRows, ...observationsTableContext.savedRows]} + processRowUpdate={observationsTableContext.processRowUpdate} // Row modes rowModesModel={observationsTableContext.rowModesModel} - onRowModesModelChange={onRowModesModelChange} + onRowModesModelChange={observationsTableContext.onRowModesModelChange} // Pagination paginationMode="server" rowCount={observationsTableContext.observationCount} @@ -143,8 +79,7 @@ const ObservationsTable = (props: ISpeciesObservationTableProps) => { top: 0, right: 0, width: 100, - height: 55, - background: 'linear-gradient(90deg, rgba(255,255,255,0) 0%, rgba(255,255,255,1) 50%)' + height: 55 }, '& .pinnedColumn': { position: 'sticky', diff --git a/app/src/features/surveys/observations/observations-table/ObservationsTableContainer.tsx b/app/src/features/surveys/observations/observations-table/ObservationsTableContainer.tsx index a6b60e99ad..f2311fb843 100644 --- a/app/src/features/surveys/observations/observations-table/ObservationsTableContainer.tsx +++ b/app/src/features/surveys/observations/observations-table/ObservationsTableContainer.tsx @@ -12,8 +12,9 @@ import Typography from '@mui/material/Typography'; import { GridColDef } from '@mui/x-data-grid'; import DataGridValidationAlert from 'components/data-grid/DataGridValidationAlert'; import { - GenericActionsColDef, GenericDateColDef, + GenericLatitudeColDef, + GenericLongitudeColDef, GenericTimeColDef } from 'components/data-grid/GenericGridColumnDefinitions'; import { IObservationTableRow } from 'contexts/observationsTableContext'; @@ -40,9 +41,12 @@ import { } from 'interfaces/useSamplingSiteApi.interface'; import { useContext } from 'react'; import { getCodesName } from 'utils/Utils'; -import { ConfigureColumnsContainer } from './configure-table/ConfigureColumnsContainer'; +import { ConfigureColumnsButton } from './configure-columns/ConfigureColumnsButton'; import ExportHeadersButton from './export-button/ExportHeadersButton'; -import { getMeasurementColumnDefinitions } from './grid-column-definitions/GridColumnDefinitionsUtils'; +import { + getEnvironmentColumnDefinitions, + getMeasurementColumnDefinitions +} from './grid-column-definitions/GridColumnDefinitionsUtils'; const ObservationComponent = () => { const codesContext = useCodesContext(); @@ -89,6 +93,7 @@ const ObservationComponent = () => { // The column definitions of the columns to render in the observations table const columns: GridColDef[] = [ + // Add standard observation columns to the table TaxonomyColDef({ hasError: observationsTableContext.hasError }), SampleSiteColDef({ sampleSiteOptions, hasError: observationsTableContext.hasError }), SampleMethodColDef({ sampleMethodOptions, hasError: observationsTableContext.hasError }), @@ -96,14 +101,12 @@ const ObservationComponent = () => { ObservationCountColDef({ sampleMethodOptions, hasError: observationsTableContext.hasError }), GenericDateColDef({ field: 'observation_date', headerName: 'Date', hasError: observationsTableContext.hasError }), GenericTimeColDef({ field: 'observation_time', headerName: 'Time', hasError: observationsTableContext.hasError }), - GenericDateColDef({ field: 'latitude', headerName: 'Lat', hasError: observationsTableContext.hasError }), - GenericDateColDef({ field: 'longitude', headerName: 'Long', hasError: observationsTableContext.hasError }), + GenericLatitudeColDef({ field: 'latitude', headerName: 'Lat', hasError: observationsTableContext.hasError }), + GenericLongitudeColDef({ field: 'longitude', headerName: 'Long', hasError: observationsTableContext.hasError }), // Add measurement columns to the table ...getMeasurementColumnDefinitions(observationsTableContext.measurementColumns, observationsTableContext.hasError), - GenericActionsColDef({ - disabled: observationsTableContext.isSaving, - onDelete: observationsTableContext.deleteObservationRecords - }) + // Add environment columns to the table + ...getEnvironmentColumnDefinitions(observationsTableContext.environmentColumns, observationsTableContext.hasError) ]; return ( @@ -157,11 +160,11 @@ const ObservationComponent = () => { /> - - + diff --git a/app/src/features/surveys/observations/observations-table/configure-columns/ConfigureColumnsButton.tsx b/app/src/features/surveys/observations/observations-table/configure-columns/ConfigureColumnsButton.tsx new file mode 100644 index 0000000000..f8ec4f8fd3 --- /dev/null +++ b/app/src/features/surveys/observations/observations-table/configure-columns/ConfigureColumnsButton.tsx @@ -0,0 +1,100 @@ +import { mdiTableEdit } from '@mdi/js'; +import Icon from '@mdi/react'; +import { Button } from '@mui/material'; +import { GridColDef, GridRowModes } from '@mui/x-data-grid'; +import { IObservationTableRow } from 'contexts/observationsTableContext'; +import { useConfigureEnvironmentColumns } from 'features/surveys/observations/observations-table/configure-columns/components/environment/useConfigureEnvironmentColumns'; +import { useConfigureGeneralColumns } from 'features/surveys/observations/observations-table/configure-columns/components/general/useConfigureGeneralColumns'; +import { useConfigureMeasurementColumns } from 'features/surveys/observations/observations-table/configure-columns/components/measurements/useConfigureMeasurementColumns'; +import { useObservationsTableContext } from 'hooks/useContext'; +import { useMemo, useState } from 'react'; +import { ConfigureColumnsDialog } from './components/ConfigureColumnsDialog'; + +export interface IConfigureColumnsButtonProps { + /** + * Controls the disabled state of the component controls. + * + * @type {boolean} + * @memberof IConfigureColumnsProps + */ + disabled: boolean; + /** + * The column definitions of the columns to render in the table. + * + * @type {GridColDef[]} + * @memberof ISpeciesObservationTableProps + */ + columns: GridColDef[]; +} + +/** + * Renders a button that opens a dialog to configure the columns of the observations table. + * + * @param {IConfigureColumnsButtonProps} props + * @return {*} + */ +export const ConfigureColumnsButton = (props: IConfigureColumnsButtonProps) => { + const { disabled, columns } = props; + + const [isOpen, setIsOpen] = useState(false); + + const observationsTableContext = useObservationsTableContext(); + + // The currently hidden fields + const hiddenFields = Object.keys(observationsTableContext.columnVisibilityModel).filter( + (key) => observationsTableContext.columnVisibilityModel[key] === false + ); + + // The array of columns that may be toggled as hidden or visible + const hideableColumns = useMemo(() => { + return columns.filter((column) => column?.hideable); + }, [columns]); + + const measurementColumns = observationsTableContext.measurementColumns; + + const environmentColumns = observationsTableContext.environmentColumns; + + const { onToggleShowHideAll, onToggleColumnVisibility } = useConfigureGeneralColumns({ hideableColumns }); + + const { onAddMeasurementColumns, onRemoveMeasurementColumns } = useConfigureMeasurementColumns(); + + const { onAddEnvironmentColumns, onRemoveEnvironmentColumns } = useConfigureEnvironmentColumns(); + + // 'true' if any row is in edit mode + const isAnyRowInEditMode = useMemo(() => { + return Object.values(observationsTableContext.rowModesModel).some( + (innerObj) => innerObj.mode === GridRowModes.Edit + ); + }, [observationsTableContext.rowModesModel]); + + return ( + <> + + setIsOpen(false)} + open={isOpen} + disabled={disabled || isAnyRowInEditMode} + hiddenFields={hiddenFields} + hideableColumns={hideableColumns} + onToggleShowHideAll={onToggleShowHideAll} + onToggleColumnVisibility={onToggleColumnVisibility} + onRemoveMeasurements={onRemoveMeasurementColumns} + measurementColumns={measurementColumns} + onAddMeasurementColumns={onAddMeasurementColumns} + onRemoveMeasurementColumns={onRemoveMeasurementColumns} + environmentColumns={environmentColumns} + onAddEnvironmentColumns={onAddEnvironmentColumns} + onRemoveEnvironmentColumns={onRemoveEnvironmentColumns} + /> + + ); +}; diff --git a/app/src/features/surveys/observations/observations-table/configure-columns/components/ConfigureColumnsDialog.tsx b/app/src/features/surveys/observations/observations-table/configure-columns/components/ConfigureColumnsDialog.tsx new file mode 100644 index 0000000000..55a02aab10 --- /dev/null +++ b/app/src/features/surveys/observations/observations-table/configure-columns/components/ConfigureColumnsDialog.tsx @@ -0,0 +1,165 @@ +import { LoadingButton } from '@mui/lab'; +import { Dialog, DialogActions, DialogContent, DialogTitle, Typography } from '@mui/material'; +import { GridColDef } from '@mui/x-data-grid'; +import { IObservationTableRow } from 'contexts/observationsTableContext'; +import { ConfigureColumnsPage } from 'features/surveys/observations/observations-table/configure-columns/components/ConfigureColumnsPage'; +import { CBMeasurementType } from 'interfaces/useCritterApi.interface'; +import { EnvironmentType, EnvironmentTypeIds } from 'interfaces/useReferenceApi.interface'; + +interface IConfigureColumnsDialogProps { + /** + * Controls the visibility of the dialog. + * + * @type {boolean} + * @memberof IConfigureColumnsDialogProps + */ + open: boolean; + /** + * Callback fired on closing the dialog. + * + * @memberof IConfigureColumnsDialogProps + */ + onClose: () => void; + /** + * Controls the disabled state of the component controls. + * + * @type {boolean} + * @memberof IConfigureColumnsProps + */ + disabled: boolean; + /** + * The column field names of the hidden columns. + * + * @type {GridColDef[]} + * @memberof IConfigureColumnsProps + */ + hiddenFields: string[]; + /** + * The column definitions of the columns that may be toggled to hidden or visible. + * + * @type {GridColDef[]} + * @memberof IConfigureColumnsProps + */ + hideableColumns: GridColDef[]; + /** + * Callback fired on toggling the visibility of all columns. + * + * @memberof IConfigureColumnsDialogProps + */ + onToggleShowHideAll: () => void; + /** + * Callback fired on toggling the visibility of a column. + * + * @memberof IConfigureColumnsDialogProps + */ + onToggleColumnVisibility: (field: string) => void; + /** + * Callback fired on removing measurements. + * + * @memberof IConfigureColumnsDialogProps + */ + onRemoveMeasurements: (measurementColumnsToRemove: string[]) => void; + /** + * The measurement columns. + * + * @type {CBMeasurementType[]} + * @memberof IConfigureColumnsDialogProps + */ + measurementColumns: CBMeasurementType[]; + /** + * Callback fired on adding measurement columns. + * + * @memberof IConfigureColumnsDialogProps + */ + onAddMeasurementColumns: (measurementColumns: CBMeasurementType[]) => void; + /** + * Callback fired on removing measurement columns. + * + * @memberof IConfigureColumnsDialogProps + */ + onRemoveMeasurementColumns: (fields: string[]) => void; + /** + * The environment columns. + * + * @type {EnvironmentType} + * @memberof IConfigureColumnsDialogProps + */ + environmentColumns: EnvironmentType; + /** + * Callback fired on adding environment columns. + * + * @memberof IConfigureColumnsDialogProps + */ + onAddEnvironmentColumns: (environmentColumns: EnvironmentType) => void; + /** + * Callback fired on removing environment columns. + * + * @memberof IConfigureColumnsDialogProps + */ + onRemoveEnvironmentColumns: (environmentColumnIds: EnvironmentTypeIds) => void; +} + +/** + * Renders a dialog to configure the columns of the observations table. + * + * @param {IConfigureColumnsDialogProps} props + * @return {*} + */ +export const ConfigureColumnsDialog = (props: IConfigureColumnsDialogProps) => { + const { + open, + onClose, + disabled, + hiddenFields, + hideableColumns, + onRemoveMeasurements, + onToggleColumnVisibility, + onToggleShowHideAll, + measurementColumns, + onAddMeasurementColumns, + onRemoveMeasurementColumns, + environmentColumns, + onAddEnvironmentColumns, + onRemoveEnvironmentColumns + } = props; + + return ( + + + Configure Columns + + Customize the columns in your table to upload additional data, such as environmental variables and species + measurements. + + + + + + + + Close + + + + ); +}; diff --git a/app/src/features/surveys/observations/observations-table/configure-columns/components/ConfigureColumnsPage.tsx b/app/src/features/surveys/observations/observations-table/configure-columns/components/ConfigureColumnsPage.tsx new file mode 100644 index 0000000000..2d5f97e20c --- /dev/null +++ b/app/src/features/surveys/observations/observations-table/configure-columns/components/ConfigureColumnsPage.tsx @@ -0,0 +1,224 @@ +import { mdiCog, mdiLeaf, mdiRuler } from '@mdi/js'; +import Icon from '@mdi/react'; +import Button from '@mui/material/Button'; +import Divider from '@mui/material/Divider'; +import Grid from '@mui/material/Grid'; +import ToggleButton from '@mui/material/ToggleButton'; +import ToggleButtonGroup from '@mui/material/ToggleButtonGroup'; +import { GridColDef } from '@mui/x-data-grid'; +import { IObservationTableRow } from 'contexts/observationsTableContext'; +import { ConfigureEnvironmentColumns } from 'features/surveys/observations/observations-table/configure-columns/components/environment/ConfigureEnvironmentColumns'; +import { ConfigureGeneralColumns } from 'features/surveys/observations/observations-table/configure-columns/components/general/ConfigureGeneralColumns'; +import { ConfigureMeasurementColumns } from 'features/surveys/observations/observations-table/configure-columns/components/measurements/ConfigureMeasurementColumns'; +import { CBMeasurementType } from 'interfaces/useCritterApi.interface'; +import { EnvironmentType, EnvironmentTypeIds } from 'interfaces/useReferenceApi.interface'; +import { useState } from 'react'; + +export enum ConfigureColumnsViewEnum { + MEASUREMENTS = 'MEASUREMENTS', + GENERAL = 'GENERAL', + ENVIRONMENT = 'ENVIRONMENT' +} + +export interface IConfigureColumnsPageProps { + /** + * Controls the disabled state of the component controls. + * + * @type {boolean} + * @memberof IConfigureColumnsProps + */ + disabled: boolean; + /** + * The column field names of the hidden columns. + * + * @type {GridColDef[]} + * @memberof IConfigureColumnsProps + */ + hiddenFields: string[]; + /** + * The column definitions of the columns that may be toggled to hidden or visible. + * + * @type {GridColDef[]} + * @memberof IConfigureColumnsProps + */ + hideableColumns: GridColDef[]; + /** + * Callback fired on toggling the visibility of all columns. + * + * @memberof IConfigureColumnsPageProps + */ + onToggleShowHideAll: () => void; + /** + * Callback fired on toggling the visibility of a column. + * + * @memberof IConfigureColumnsPageProps + */ + onToggleColumnVisibility: (field: string) => void; + /** + * Callback fired on removing measurements. + * + * @memberof IConfigureColumnsPageProps + */ + onRemoveMeasurements: (measurementColumnsToRemove: string[]) => void; + /** + * The measurement columns. + * + * @type {CBMeasurementType[]} + * @memberof IConfigureColumnsPageProps + */ + measurementColumns: CBMeasurementType[]; + /** + * Callback fired on adding measurement columns. + * + * @memberof IConfigureColumnsPageProps + */ + onAddMeasurementColumns: (measurementColumns: CBMeasurementType[]) => void; + /** + * Callback fired on removing measurement columns. + * + * @memberof IConfigureColumnsPageProps + */ + onRemoveMeasurementColumns: (fields: string[]) => void; + /** + * The environment columns. + * + * @type {EnvironmentType} + * @memberof IConfigureColumnsPageProps + */ + environmentColumns: EnvironmentType; + /** + * Callback fired on adding environment columns. + * + * @memberof IConfigureColumnsPageProps + */ + onAddEnvironmentColumns: (environmentColumns: EnvironmentType) => void; + /** + * Callback fired on removing environment columns. + * + * @memberof IConfigureColumnsPageProps + */ + onRemoveEnvironmentColumns: (environmentColumnIds: EnvironmentTypeIds) => void; +} + +/** + * Parent component for the configure columns components. + * + * This component manages the state of the active view (tab) and renders the appropriate child component. + * + * @param {IConfigureColumnsPageProps} props + * @return {*} + */ +export const ConfigureColumnsPage = (props: IConfigureColumnsPageProps) => { + const { + disabled, + hiddenFields, + hideableColumns, + onToggleShowHideAll, + onToggleColumnVisibility, + onRemoveMeasurements, + measurementColumns, + onAddMeasurementColumns, + onRemoveMeasurementColumns, + environmentColumns, + onAddEnvironmentColumns, + onRemoveEnvironmentColumns + } = props; + + const [activeView, setActiveView] = useState(ConfigureColumnsViewEnum.GENERAL); + + return ( + + + { + if (!view) { + // An active view must be selected at all times + return; + } + + setActiveView(view); + }} + exclusive + orientation="vertical" + sx={{ + width: '100%', + gap: 1, + '& Button': { + textAlign: 'left', + display: 'flex', + justifyContent: 'flex-start', + py: 1, + px: 2, + border: 'none', + borderRadius: '4px !important', + fontSize: '0.875rem', + fontWeight: 700, + letterSpacing: '0.02rem' + } + }}> + } + disabled={disabled} + value={ConfigureColumnsViewEnum.GENERAL}> + General + + } + disabled={disabled} + value={ConfigureColumnsViewEnum.MEASUREMENTS}> + Measurements + + } + disabled={disabled} + value={ConfigureColumnsViewEnum.ENVIRONMENT}> + Environment + + + + + + {activeView === ConfigureColumnsViewEnum.GENERAL && ( + + )} + {activeView === ConfigureColumnsViewEnum.MEASUREMENTS && ( + + )} + {activeView === ConfigureColumnsViewEnum.ENVIRONMENT && ( + + )} + + + ); +}; diff --git a/app/src/features/surveys/observations/observations-table/configure-columns/components/environment/ConfigureEnvironmentColumns.tsx b/app/src/features/surveys/observations/observations-table/configure-columns/components/environment/ConfigureEnvironmentColumns.tsx new file mode 100644 index 0000000000..4d4b6a26ae --- /dev/null +++ b/app/src/features/surveys/observations/observations-table/configure-columns/components/environment/ConfigureEnvironmentColumns.tsx @@ -0,0 +1,115 @@ +import { mdiTrashCanOutline } from '@mdi/js'; +import Icon from '@mdi/react'; +import { Box, IconButton, Stack, Typography } from '@mui/material'; +import EnvironmentStandardCard from 'features/standards/view/components/EnvironmentStandardCard'; +import { EnvironmentType, EnvironmentTypeIds } from 'interfaces/useReferenceApi.interface'; +import { EnvironmentsSearch } from './search/EnvironmentsSearch'; + +export interface IConfigureEnvironmentColumnsProps { + /** + * The environment columns. + * + * @type {EnvironmentType} + * @memberof IConfigureEnvironmentColumnsProps + */ + environmentColumns: EnvironmentType; + /** + * Callback fired on adding environment columns. + * + * @memberof IConfigureEnvironmentColumnsProps + */ + onAddEnvironmentColumns: (environmentColumns: EnvironmentType) => void; + /** + * Callback fired on removing environment columns. + * + * @memberof IConfigureEnvironmentColumnsProps + */ + onRemoveEnvironmentColumns: (environmentColumnIds: EnvironmentTypeIds) => void; +} + +/** + * Renders a component to configure the environment columns of the observations table. + * + * @param {IConfigureEnvironmentColumnsProps} props + * @return {*} + */ +export const ConfigureEnvironmentColumns = (props: IConfigureEnvironmentColumnsProps) => { + const { environmentColumns, onAddEnvironmentColumns, onRemoveEnvironmentColumns } = props; + + const hasEnvironmentColumns = + environmentColumns.qualitative_environments.length || environmentColumns.quantitative_environments.length; + + return ( + <> + + Configure Environment Columns + + onAddEnvironmentColumns(environmentColumn)} + /> + + {hasEnvironmentColumns ? ( + <> + + Selected environments + + + {environmentColumns.qualitative_environments.map((environment) => ( + + + + + onRemoveEnvironmentColumns({ + qualitative_environments: [environment.environment_qualitative_id], + quantitative_environments: [] + }) + } + data-testid="configure-environment-qualitative-column-remove-button"> + + + + + ))} + {environmentColumns.quantitative_environments.map((environment) => ( + + + + + onRemoveEnvironmentColumns({ + qualitative_environments: [], + quantitative_environments: [environment.environment_quantitative_id] + }) + } + data-testid="configure-environment-quantitative-column-remove-button"> + + + + + ))} + + + ) : ( + + No environmental variables selected + + )} + + + ); +}; diff --git a/app/src/features/surveys/observations/observations-table/configure-columns/components/environment/search/EnvironmentsSearch.tsx b/app/src/features/surveys/observations/observations-table/configure-columns/components/environment/search/EnvironmentsSearch.tsx new file mode 100644 index 0000000000..8194dd930d --- /dev/null +++ b/app/src/features/surveys/observations/observations-table/configure-columns/components/environment/search/EnvironmentsSearch.tsx @@ -0,0 +1,51 @@ +import { EnvironmentsSearchAutocomplete } from 'features/surveys/observations/observations-table/configure-columns/components/environment/search/EnvironmentsSearchAutocomplete'; +import { useBiohubApi } from 'hooks/useBioHubApi'; +import useDataLoader from 'hooks/useDataLoader'; +import { EnvironmentType } from 'interfaces/useReferenceApi.interface'; + +export interface IEnvironmentsSearchProps { + /** + * The selected environments. + * + * @type {EnvironmentType} + * @memberof IEnvironmentsSearchProps + */ + selectedEnvironments: EnvironmentType; + /** + * Callback fired on select options. + * + * @memberof IEnvironmentsSearchProps + */ + onAddEnvironmentColumn: (environmentColumn: EnvironmentType) => void; +} + +/** + * Renders an search input to find and add environments. + * + * @param {IEnvironmentsSearchProps} props + * @return {*} + */ +export const EnvironmentsSearch = (props: IEnvironmentsSearchProps) => { + const { selectedEnvironments, onAddEnvironmentColumn } = props; + + const biohubApi = useBiohubApi(); + + const environmentsDataLoader = useDataLoader(async (searchTerm: string) => + biohubApi.reference.findSubcountEnvironments(searchTerm) + ); + + // Need to process them into 1 array? With a common label? + return ( + { + const response = await environmentsDataLoader.refresh(inputValue); + return { + qualitative_environments: response?.qualitative_environments ?? [], + quantitative_environments: response?.quantitative_environments ?? [] + }; + }} + onAddEnvironmentColumn={onAddEnvironmentColumn} + /> + ); +}; diff --git a/app/src/features/surveys/observations/observations-table/configure-columns/components/environment/search/EnvironmentsSearchAutocomplete.tsx b/app/src/features/surveys/observations/observations-table/configure-columns/components/environment/search/EnvironmentsSearchAutocomplete.tsx new file mode 100644 index 0000000000..fa49a29ad0 --- /dev/null +++ b/app/src/features/surveys/observations/observations-table/configure-columns/components/environment/search/EnvironmentsSearchAutocomplete.tsx @@ -0,0 +1,204 @@ +import { mdiMagnify } from '@mdi/js'; +import Icon from '@mdi/react'; +import Autocomplete from '@mui/material/Autocomplete'; +import Box from '@mui/material/Box'; +import ListItem from '@mui/material/ListItem'; +import Stack from '@mui/material/Stack'; +import TextField from '@mui/material/TextField'; +import Typography from '@mui/material/Typography'; +import { + EnvironmentQualitativeTypeDefinition, + EnvironmentQuantitativeTypeDefinition, + EnvironmentType +} from 'interfaces/useReferenceApi.interface'; +import { debounce } from 'lodash-es'; +import { useMemo, useState } from 'react'; + +export interface IEnvironmentsSearchAutocompleteProps { + /** + * The selected Environments. + * + * @type {EnvironmentType} + * @memberof IEnvironmentsSearchAutocompleteProps + */ + selectedOptions: EnvironmentType; + /** + * An async function that returns an array of options, based on the provided input value. + * + * @memberof IEnvironmentsSearchAutocompleteProps + */ + getOptions: (inputValue: string) => Promise; + /** + * Callback fired on selecting options. + * + * Note: this is not fired until the user un-focuses the component. + * + * @memberof IEnvironmentsSearchAutocompleteProps + */ + onAddEnvironmentColumn: (EnvironmentColumn: EnvironmentType) => void; +} + +/** + * Renders a search input to find and add Environments. + * + * @param {IEnvironmentsSearchAutocompleteProps} props + * @return {*} + */ +export const EnvironmentsSearchAutocomplete = (props: IEnvironmentsSearchAutocompleteProps) => { + const { selectedOptions, getOptions, onAddEnvironmentColumn } = props; + + const [inputValue, setInputValue] = useState(''); + const [options, setOptions] = useState< + (EnvironmentQualitativeTypeDefinition | EnvironmentQuantitativeTypeDefinition)[] + >([]); + + const handleSearch = useMemo( + () => + debounce( + async ( + inputValue: string, + callback: ( + searchedValues: (EnvironmentQualitativeTypeDefinition | EnvironmentQuantitativeTypeDefinition)[] + ) => void + ) => { + const response = await getOptions(inputValue); + callback([...response.qualitative_environments, ...response.quantitative_environments]); + }, + 500 + ), + [getOptions] + ); + + return ( + option.name} + isOptionEqualToValue={(option, value) => { + if ('environment_qualitative_id' in option && 'environment_qualitative_id' in value) { + return option.environment_qualitative_id === value.environment_qualitative_id; + } else if ('environment_quantitative_id' in option && 'environment_quantitative_id' in value) { + return option.environment_quantitative_id === value.environment_quantitative_id; + } + + return false; + }} + filterOptions={(options) => { + if (!selectedOptions?.qualitative_environments.length && !selectedOptions?.quantitative_environments.length) { + return options; + } + + const unselectedOptions = options.filter((option) => { + if ('environment_qualitative_id' in option) { + return !selectedOptions.qualitative_environments.some( + (item) => item.environment_qualitative_id === option.environment_qualitative_id + ); + } else if ('environment_quantitative_id' in option) { + return !selectedOptions.quantitative_environments.some( + (item) => item.environment_quantitative_id === option.environment_quantitative_id + ); + } + + return false; + }); + + return unselectedOptions; + }} + inputMode="search" + inputValue={inputValue} + onInputChange={(_, value, reason) => { + if (reason === 'reset') { + return; + } + + if (reason === 'clear') { + setInputValue(''); + setOptions([]); + return; + } + + setInputValue(value); + handleSearch(value, (newOptions) => { + setOptions(() => newOptions); + }); + }} + value={null} // The selected value is not displayed in the input field or tracked by this component + onChange={(_, value) => { + if (!value) { + return; + } + + onAddEnvironmentColumn({ + qualitative_environments: 'environment_qualitative_id' in value ? [value] : [], + quantitative_environments: 'environment_quantitative_id' in value ? [value] : [] + }); + setInputValue(''); + setOptions([]); + }} + renderOption={(renderProps, renderOption) => { + return ( + + + + + {renderOption.name} + + + {renderOption.description} + + + + + ); + }} + renderInput={(params) => ( + + + + ) + }} + data-testid="environments-autocomplete-input" + aria-label="Find observation Environments" + /> + )} + /> + ); +}; diff --git a/app/src/features/surveys/observations/observations-table/configure-columns/components/environment/useConfigureEnvironmentColumns.tsx b/app/src/features/surveys/observations/observations-table/configure-columns/components/environment/useConfigureEnvironmentColumns.tsx new file mode 100644 index 0000000000..db298f5ba2 --- /dev/null +++ b/app/src/features/surveys/observations/observations-table/configure-columns/components/environment/useConfigureEnvironmentColumns.tsx @@ -0,0 +1,147 @@ +import { getSurveySessionStorageKey, SIMS_OBSERVATIONS_ENVIRONMENT_COLUMNS } from 'constants/session-storage'; +import { useObservationsTableContext, useSurveyContext } from 'hooks/useContext'; +import { EnvironmentType, EnvironmentTypeIds } from 'interfaces/useReferenceApi.interface'; +import { useCallback } from 'react'; + +/** + * Functions for environment column configuration. + * + * @return {*} + */ +export const useConfigureEnvironmentColumns = () => { + const surveyContext = useSurveyContext(); + const observationsTableContext = useObservationsTableContext(); + + /** + * Handles the addition of environment columns to the table. + * + * @param {EnvironmentType} environmentColumnsToAdd + * @return {*} + */ + const onAddEnvironmentColumns = useCallback( + (environmentColumnsToAdd: EnvironmentType) => { + if ( + !environmentColumnsToAdd.qualitative_environments.length && + !environmentColumnsToAdd.quantitative_environments.length + ) { + return; + } + + // Add the environment columns to the table context + observationsTableContext.setEnvironmentColumns((currentColumns) => { + const qualitativeEnvironmentColumnsToAdd = environmentColumnsToAdd.qualitative_environments.filter( + (columnToAdd) => + !currentColumns.qualitative_environments.find( + (currentColumn) => currentColumn.environment_qualitative_id === columnToAdd.environment_qualitative_id + ) + ); + + const quantitativeEnvironmentColumnsToAdd = environmentColumnsToAdd.quantitative_environments.filter( + (columnToAdd) => + !currentColumns.quantitative_environments.find( + (currentColumn) => currentColumn.environment_quantitative_id === columnToAdd.environment_quantitative_id + ) + ); + + const newColumns = { + qualitative_environments: [...currentColumns.qualitative_environments, ...qualitativeEnvironmentColumnsToAdd], + quantitative_environments: [ + ...currentColumns.quantitative_environments, + ...quantitativeEnvironmentColumnsToAdd + ] + }; + + // Store all environment definitions in local storage + sessionStorage.setItem( + getSurveySessionStorageKey(surveyContext.surveyId, SIMS_OBSERVATIONS_ENVIRONMENT_COLUMNS), + JSON.stringify(newColumns) + ); + + return newColumns; + }); + }, + [observationsTableContext, surveyContext.surveyId] + ); + + /** + * Handles the removal of environment columns from the table. + * + * @param {EnvironmentTypeIds} environmentColumnsToRemove + * @return {*} + */ + const onRemoveEnvironmentColumns = useCallback( + (environmentColumnsToRemove: EnvironmentTypeIds) => { + if ( + !environmentColumnsToRemove.qualitative_environments.length && + !environmentColumnsToRemove.quantitative_environments.length + ) { + return; + } + + // Delete the environment columns from the database + observationsTableContext.deleteObservationEnvironmentColumns(environmentColumnsToRemove, () => { + // Remove the environment columns from the table context + observationsTableContext.setEnvironmentColumns((currentColumns) => { + const remainingQualitativeEnvironmentColumns = currentColumns.qualitative_environments.filter( + (currentColumn) => + !environmentColumnsToRemove.qualitative_environments.includes(currentColumn.environment_qualitative_id) + ); + + const remainingQuantitativeEnvironmentColumns = currentColumns.quantitative_environments.filter( + (currentColumn) => + !environmentColumnsToRemove.quantitative_environments.includes(currentColumn.environment_quantitative_id) + ); + + const remainingColumns = { + qualitative_environments: remainingQualitativeEnvironmentColumns, + quantitative_environments: remainingQuantitativeEnvironmentColumns + }; + + // Store all remaining environment definitions in local storage + sessionStorage.setItem( + getSurveySessionStorageKey(surveyContext.surveyId, SIMS_OBSERVATIONS_ENVIRONMENT_COLUMNS), + JSON.stringify(remainingColumns) + ); + + return remainingColumns; + }); + + // Update saved rows, removing any cell values for the deleted columns + observationsTableContext.setSavedRows((currentSavedRows) => { + return currentSavedRows.map((savedRow) => { + for (const columnIdToRemove of environmentColumnsToRemove.qualitative_environments) { + delete savedRow[columnIdToRemove]; + } + + for (const columnIdToRemove of environmentColumnsToRemove.quantitative_environments) { + delete savedRow[columnIdToRemove]; + } + + return savedRow; + }); + }); + + // Update staged rows, removing any cell values for the deleted columns + observationsTableContext.setStagedRows((currentStagedRows) => { + return currentStagedRows.map((stagedRow) => { + for (const columnIdToRemove of environmentColumnsToRemove.qualitative_environments) { + delete stagedRow[columnIdToRemove]; + } + + for (const columnIdToRemove of environmentColumnsToRemove.quantitative_environments) { + delete stagedRow[columnIdToRemove]; + } + + return stagedRow; + }); + }); + }); + }, + [observationsTableContext, surveyContext.surveyId] + ); + + return { + onAddEnvironmentColumns, + onRemoveEnvironmentColumns + }; +}; diff --git a/app/src/features/surveys/observations/observations-table/configure-columns/components/general/ConfigureGeneralColumns.tsx b/app/src/features/surveys/observations/observations-table/configure-columns/components/general/ConfigureGeneralColumns.tsx new file mode 100644 index 0000000000..3ca4654a46 --- /dev/null +++ b/app/src/features/surveys/observations/observations-table/configure-columns/components/general/ConfigureGeneralColumns.tsx @@ -0,0 +1,169 @@ +import Box from '@mui/material/Box'; +import Checkbox from '@mui/material/Checkbox'; +import grey from '@mui/material/colors/grey'; +import FormControlLabel from '@mui/material/FormControlLabel'; +import List from '@mui/material/List'; +import ListItem from '@mui/material/ListItem'; +import ListItemButton from '@mui/material/ListItemButton'; +import ListItemIcon from '@mui/material/ListItemIcon'; +import ListItemText from '@mui/material/ListItemText'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import { GridColDef } from '@mui/x-data-grid'; +import { IObservationTableRow } from 'contexts/observationsTableContext'; +import { GeneralColumnsSecondaryAction } from 'features/surveys/observations/observations-table/configure-columns/components/general/ConfigureGeneralColumnsSecondaryAction'; +import { CBMeasurementType } from 'interfaces/useCritterApi.interface'; +import { EnvironmentType, EnvironmentTypeIds } from 'interfaces/useReferenceApi.interface'; + +export interface IConfigureGeneralColumnsProps { + /** + * Controls the disabled state of the component controls. + * + * @type {boolean} + * @memberof IConfigureColumnsProps + */ + disabled: boolean; + /** + * The column field names of the hidden columns. + * + * @type {GridColDef[]} + * @memberof IConfigureColumnsProps + */ + hiddenFields: string[]; + /** + * The column definitions of the columns that may be toggled to hidden or visible. + * + * @type {GridColDef[]} + * @memberof IConfigureColumnsProps + */ + hideableColumns: GridColDef[]; + /** + * Callback fired on toggling the visibility of all columns. + * + * @memberof IConfigureGeneralColumnsProps + */ + onToggleShowHideAll: () => void; + /** + * Callback fired on toggling the visibility of a column. + * + * @memberof IConfigureGeneralColumnsProps + */ + onToggleColumnVisibility: (field: string) => void; + /** + * Callback fired on removing measurements. + * + * @memberof IConfigureGeneralColumnsProps + */ + onRemoveMeasurements: (measurementColumnsToRemove: string[]) => void; + /** + * The measurement columns. + * + * @type {CBMeasurementType[]} + * @memberof IConfigureGeneralColumnsProps + */ + measurementColumns: CBMeasurementType[]; + /** + * Callback fired on removing environment columns. + * + * @memberof IConfigureGeneralColumnsProps + */ + onRemoveEnvironmentColumns: (environmentColumnIds: EnvironmentTypeIds) => void; + /** + * The environment columns. + * + * @type {EnvironmentType} + * @memberof IConfigureGeneralColumnsProps + */ + environmentColumns: EnvironmentType; +} + +/** + * Renders a list of measurement cards. + * + * @param {IConfigureGeneralColumnsProps} props + * @return {*} + */ +export const ConfigureGeneralColumns = (props: IConfigureGeneralColumnsProps) => { + const { + disabled, + hiddenFields, + hideableColumns, + onToggleShowHideAll, + onToggleColumnVisibility, + onRemoveMeasurements, + measurementColumns, + onRemoveEnvironmentColumns, + environmentColumns + } = props; + + return ( + + + Select Columns to Show + + + 0 && hiddenFields.length < hideableColumns.length} + checked={hiddenFields.length === 0} + onClick={() => onToggleShowHideAll()} + disabled={disabled} + /> + } + label={ + + Show/Hide all + + } + /> + + + + {hideableColumns.map((column) => { + return ( + + } + disablePadding> + onToggleColumnVisibility(column.field)} + disabled={disabled} + sx={{ background: grey[50], borderRadius: '5px' }}> + + + + {column.headerName} + + + ); + })} + + + ); +}; diff --git a/app/src/features/surveys/observations/observations-table/configure-columns/components/general/ConfigureGeneralColumnsSecondaryAction.tsx b/app/src/features/surveys/observations/observations-table/configure-columns/components/general/ConfigureGeneralColumnsSecondaryAction.tsx new file mode 100644 index 0000000000..1090c1591d --- /dev/null +++ b/app/src/features/surveys/observations/observations-table/configure-columns/components/general/ConfigureGeneralColumnsSecondaryAction.tsx @@ -0,0 +1,116 @@ +import { mdiTrashCanOutline } from '@mdi/js'; +import Icon from '@mdi/react'; +import IconButton from '@mui/material/IconButton'; +import { CBMeasurementType } from 'interfaces/useCritterApi.interface'; +import { EnvironmentType, EnvironmentTypeIds } from 'interfaces/useReferenceApi.interface'; + +export interface IGeneralColumnsSecondaryActionProps { + /** + * Controls the disabled state of the component controls. + * + * @type {boolean} + * @memberof IConfigureColumnsProps + */ + disabled: boolean; + /** + * The field name of the column. + * + * @type {string} + * @memberof IGeneralColumnsSecondaryActionProps + */ + field: string; + /** + * Callback fired on removing measurements. + * + * @memberof IGeneralColumnsSecondaryActionProps + */ + onRemoveMeasurements: (measurementColumnsToRemove: string[]) => void; + /** + * The measurement columns. + * + * @type {CBMeasurementType[]} + * @memberof IGeneralColumnsSecondaryActionProps + */ + measurementColumns: CBMeasurementType[]; + /** + * Callback fired on removing environment columns. + * + * @memberof IGeneralColumnsSecondaryActionProps + */ + onRemoveEnvironmentColumns: (environmentColumnIds: EnvironmentTypeIds) => void; + /** + * The environment columns. + * + * @type {EnvironmentType} + * @memberof IGeneralColumnsSecondaryActionProps + */ + environmentColumns: EnvironmentType; +} + +/** + * Renders a secondary action for the general columns. + * + * @param {IGeneralColumnsSecondaryActionProps} props + * @return {*} + */ +export const GeneralColumnsSecondaryAction = (props: IGeneralColumnsSecondaryActionProps) => { + const { field, disabled, measurementColumns, onRemoveMeasurements, environmentColumns, onRemoveEnvironmentColumns } = + props; + + // If the field matches a measurement definition, render the corresponding remove button. + if (measurementColumns.some((item) => item.taxon_measurement_id === field)) { + return ( + onRemoveMeasurements([field])}> + + + ); + } + + // If the field matches a qualitative environment type definition, render the corresponding remove button. + const qualitativeEnvironmentTypeDefinition = environmentColumns.qualitative_environments.find( + (item) => String(item.environment_qualitative_id) === field + ); + if (qualitativeEnvironmentTypeDefinition) { + return ( + + onRemoveEnvironmentColumns({ + qualitative_environments: [qualitativeEnvironmentTypeDefinition.environment_qualitative_id], + quantitative_environments: [] + }) + }> + + + ); + } + + // If the field matches a quantitative environment type definition, render the corresponding remove button. + const quantitativeEnvironmentTypeDefinition = environmentColumns.quantitative_environments.find( + (item) => String(item.environment_quantitative_id) === field + ); + if (quantitativeEnvironmentTypeDefinition) { + return ( + + onRemoveEnvironmentColumns({ + qualitative_environments: [], + quantitative_environments: [quantitativeEnvironmentTypeDefinition.environment_quantitative_id] + }) + }> + + + ); + } + + return <>; +}; diff --git a/app/src/features/surveys/observations/observations-table/configure-columns/components/general/useConfigureGeneralColumns.tsx b/app/src/features/surveys/observations/observations-table/configure-columns/components/general/useConfigureGeneralColumns.tsx new file mode 100644 index 0000000000..9c96c27147 --- /dev/null +++ b/app/src/features/surveys/observations/observations-table/configure-columns/components/general/useConfigureGeneralColumns.tsx @@ -0,0 +1,62 @@ +import { GridColDef, GridColumnVisibilityModel } from '@mui/x-data-grid'; +import { IObservationTableRow } from 'contexts/observationsTableContext'; +import { useObservationsTableContext } from 'hooks/useContext'; + +export interface IUseConfigureGeneralColumnsProps { + hideableColumns: GridColDef[]; +} + +/** + * Functions for general column configuration. + * + * @param {IUseConfigureGeneralColumnsProps} props + * @return {*} + */ +export const useConfigureGeneralColumns = (props: IUseConfigureGeneralColumnsProps) => { + const { hideableColumns } = props; + + const observationsTableContext = useObservationsTableContext(); + + /** + * Handles toggling the visibility of a column. + * + * @param {string} field + */ + const onToggleColumnVisibility = (field: string) => { + // Get the current visibility model for the column + const columnVisibility = observationsTableContext.columnVisibilityModel[field]; + + // If model is undefined, then no visibility model has been set for this column, default to true + const isColumnVisible = columnVisibility === undefined ? true : columnVisibility; + + // Toggle the visibility of the column + observationsTableContext._muiDataGridApiRef.current.setColumnVisibility(field, !isColumnVisible); + }; + + /** + * Handles toggling the visibility of all columns. + */ + const onToggleShowHideAll = () => { + const hasHiddenColumns = Object.values(observationsTableContext.columnVisibilityModel).some( + (item) => item === false + ); + + let model: GridColumnVisibilityModel = {}; + + if (hasHiddenColumns) { + // Some columns currently hidden, show all columns + model = { ...Object.fromEntries(hideableColumns.map((column) => [column.field, true])) }; + } else { + // No columns currently hidden, hide all columns + model = { ...Object.fromEntries(hideableColumns.map((column) => [column.field, false])) }; + } + + // Update the column visibility model + observationsTableContext._muiDataGridApiRef.current.setColumnVisibilityModel(model); + }; + + return { + onToggleColumnVisibility, + onToggleShowHideAll + }; +}; diff --git a/app/src/features/surveys/observations/observations-table/configure-columns/components/measurements/ConfigureMeasurementColumns.tsx b/app/src/features/surveys/observations/observations-table/configure-columns/components/measurements/ConfigureMeasurementColumns.tsx new file mode 100644 index 0000000000..f36b2763e5 --- /dev/null +++ b/app/src/features/surveys/observations/observations-table/configure-columns/components/measurements/ConfigureMeasurementColumns.tsx @@ -0,0 +1,86 @@ +import { mdiTrashCanOutline } from '@mdi/js'; +import Icon from '@mdi/react'; +import { Box, IconButton, Stack, Typography } from '@mui/material'; +import MeasurementStandardCard from 'features/standards/view/components/MeasurementStandardCard'; +import { CBMeasurementType } from 'interfaces/useCritterApi.interface'; +import { MeasurementsSearch } from './search/MeasurementsSearch'; + +export interface IConfigureMeasurementColumnsProps { + /** + * The measurement columns. + * + * @type {CBMeasurementType[]} + * @memberof IConfigureMeasurementColumnsProps + */ + measurementColumns: CBMeasurementType[]; + /** + * Callback fired on adding measurement columns. + * + * @memberof IConfigureMeasurementColumnsProps + */ + onAddMeasurementColumns: (measurementColumns: CBMeasurementType[]) => void; + /** + * Callback fired on removing measurement columns. + * + * @memberof IConfigureMeasurementColumnsProps + */ + onRemoveMeasurementColumns: (fields: string[]) => void; +} + +/** + * Renders a component to configure the measurement columns of the observations table. + * + * @param {IConfigureMeasurementColumnsProps} props + * @return {*} + */ +export const ConfigureMeasurementColumns = (props: IConfigureMeasurementColumnsProps) => { + const { measurementColumns, onAddMeasurementColumns, onRemoveMeasurementColumns } = props; + + return ( + <> + + Configure Measurement Columns + + onAddMeasurementColumns([measurementColumn])} + /> + + {measurementColumns.length ? ( + <> + + Selected measurements + + + {measurementColumns.map((measurement) => ( + + + + onRemoveMeasurementColumns([measurement.taxon_measurement_id])} + data-testid="configure-measurement-column-remove-button"> + + + + + ))} + + + ) : ( + + No measurements selected + + )} + + + ); +}; diff --git a/app/src/features/surveys/observations/observations-table/configure-columns/components/measurements/search/MeasurementsSearch.tsx b/app/src/features/surveys/observations/observations-table/configure-columns/components/measurements/search/MeasurementsSearch.tsx new file mode 100644 index 0000000000..00540e15a4 --- /dev/null +++ b/app/src/features/surveys/observations/observations-table/configure-columns/components/measurements/search/MeasurementsSearch.tsx @@ -0,0 +1,45 @@ +import { MeasurementsSearchAutocomplete } from 'features/surveys/observations/observations-table/configure-columns/components/measurements/search/MeasurementsSearchAutocomplete'; +import { useCritterbaseApi } from 'hooks/useCritterbaseApi'; +import useDataLoader from 'hooks/useDataLoader'; +import { CBMeasurementType } from 'interfaces/useCritterApi.interface'; + +export interface IMeasurementsSearchProps { + /** + * The selected measurements. + * + * @type {CBMeasurementType[]} + * @memberof IMeasurementsSearchProps + */ + selectedMeasurements: CBMeasurementType[]; + /** + * Callback fired on select options. + * + * @memberof IMeasurementsSearchProps + */ + onAddMeasurementColumn: (measurementColumn: CBMeasurementType) => void; +} + +/** + * Renders an search input to find and add measurements. + * + * @param {IMeasurementsSearchProps} props + * @return {*} + */ +export const MeasurementsSearch = (props: IMeasurementsSearchProps) => { + const { selectedMeasurements, onAddMeasurementColumn } = props; + + const critterbaseApi = useCritterbaseApi(); + + const measurementsDataLoader = useDataLoader(critterbaseApi.xref.getMeasurementTypeDefinitionsBySearchTerm); + + return ( + { + const response = await measurementsDataLoader.refresh(inputValue); + return (response && [...response.qualitative, ...response.quantitative]) || []; + }} + onAddMeasurementColumn={onAddMeasurementColumn} + /> + ); +}; diff --git a/app/src/features/surveys/observations/observations-table/configure-table/measurements/search/MeasurementsSearchAutocomplete.tsx b/app/src/features/surveys/observations/observations-table/configure-columns/components/measurements/search/MeasurementsSearchAutocomplete.tsx similarity index 78% rename from app/src/features/surveys/observations/observations-table/configure-table/measurements/search/MeasurementsSearchAutocomplete.tsx rename to app/src/features/surveys/observations/observations-table/configure-columns/components/measurements/search/MeasurementsSearchAutocomplete.tsx index d5a8c71024..dee28f76b8 100644 --- a/app/src/features/surveys/observations/observations-table/configure-table/measurements/search/MeasurementsSearchAutocomplete.tsx +++ b/app/src/features/surveys/observations/observations-table/configure-columns/components/measurements/search/MeasurementsSearchAutocomplete.tsx @@ -1,10 +1,7 @@ import { mdiMagnify } from '@mdi/js'; import Icon from '@mdi/react'; -import CheckBox from '@mui/icons-material/CheckBox'; -import CheckBoxOutlineBlank from '@mui/icons-material/CheckBoxOutlineBlank'; import Autocomplete from '@mui/material/Autocomplete'; import Box from '@mui/material/Box'; -import Checkbox from '@mui/material/Checkbox'; import ListItem from '@mui/material/ListItem'; import Stack from '@mui/material/Stack'; import TextField from '@mui/material/TextField'; @@ -34,7 +31,7 @@ export interface IMeasurementsSearchAutocompleteProps { * * @memberof IMeasurementsSearchAutocompleteProps */ - onSelectOptions: (measurements: CBMeasurementType[]) => void; + onAddMeasurementColumn: (measurementColumn: CBMeasurementType) => void; } /** @@ -43,14 +40,12 @@ export interface IMeasurementsSearchAutocompleteProps { * @param {IMeasurementsSearchAutocompleteProps} props * @return {*} */ -const MeasurementsSearchAutocomplete = (props: IMeasurementsSearchAutocompleteProps) => { - const { selectedOptions, getOptions, onSelectOptions } = props; +export const MeasurementsSearchAutocomplete = (props: IMeasurementsSearchAutocompleteProps) => { + const { selectedOptions, getOptions, onAddMeasurementColumn } = props; const [inputValue, setInputValue] = useState(''); const [options, setOptions] = useState([]); - const [pendingSelectedOptions, setPendingSelectedOptions] = useState([]); - const handleSearch = useMemo( () => debounce(async (inputValue: string, callback: (searchedValues: CBMeasurementType[]) => void) => { @@ -67,10 +62,9 @@ const MeasurementsSearchAutocomplete = (props: IMeasurementsSearchAutocompletePr noOptionsText="No matching options" autoHighlight={true} options={options} - multiple={true} disableCloseOnSelect={true} - blurOnSelect={false} - clearOnBlur={false} + blurOnSelect={true} + clearOnBlur={true} getOptionLabel={(option) => option.measurement_name} isOptionEqualToValue={(option, value) => { return option.taxon_measurement_id === value.taxon_measurement_id; @@ -106,15 +100,13 @@ const MeasurementsSearchAutocomplete = (props: IMeasurementsSearchAutocompletePr setOptions(() => newOptions); }); }} - value={[]} // The selected value is not displayed in the input field or tracked by this component - onChange={(_, option) => { - setPendingSelectedOptions((currentPendingOptions) => { - return [...currentPendingOptions, ...option]; - }); - }} - onClose={() => { - onSelectOptions(pendingSelectedOptions); - setPendingSelectedOptions([]); + value={null} // The selected value is not displayed in the input field or tracked by this component + onChange={(_, value) => { + if (value) { + onAddMeasurementColumn(value); + setInputValue(''); + setOptions([]); + } }} renderOption={(renderProps, renderOption) => { return ( @@ -128,15 +120,6 @@ const MeasurementsSearchAutocomplete = (props: IMeasurementsSearchAutocompletePr {...renderProps} key={renderOption.taxon_measurement_id} data-testid="measurements-autocomplete-option"> - } - checkedIcon={} - checked={pendingSelectedOptions.some( - (option) => option.taxon_measurement_id === renderOption.taxon_measurement_id - )} - value={renderOption.taxon_measurement_id} - color="default" - /> @@ -199,5 +182,3 @@ const MeasurementsSearchAutocomplete = (props: IMeasurementsSearchAutocompletePr /> ); }; - -export default MeasurementsSearchAutocomplete; diff --git a/app/src/features/surveys/observations/observations-table/configure-columns/components/measurements/useConfigureMeasurementColumns.tsx b/app/src/features/surveys/observations/observations-table/configure-columns/components/measurements/useConfigureMeasurementColumns.tsx new file mode 100644 index 0000000000..a2f9210a5c --- /dev/null +++ b/app/src/features/surveys/observations/observations-table/configure-columns/components/measurements/useConfigureMeasurementColumns.tsx @@ -0,0 +1,103 @@ +import { getSurveySessionStorageKey, SIMS_OBSERVATIONS_MEASUREMENT_COLUMNS } from 'constants/session-storage'; +import { useObservationsTableContext, useSurveyContext } from 'hooks/useContext'; +import { CBMeasurementType } from 'interfaces/useCritterApi.interface'; +import { useCallback } from 'react'; + +/** + * Functions for measurement column configuration. + * + * @return {*} + */ +export const useConfigureMeasurementColumns = () => { + const surveyContext = useSurveyContext(); + const observationsTableContext = useObservationsTableContext(); + + /** + * Handles the addition of measurement columns to the table. + * + * @param {CBMeasurementType[]} measurements + * @return {*} + */ + const onAddMeasurementColumns = useCallback( + (measurementColumnsToAdd: CBMeasurementType[]) => { + if (!measurementColumnsToAdd?.length) { + return; + } + + // Add the measurement columns to the table context + observationsTableContext.setMeasurementColumns((currentColumns) => { + const newColumns = measurementColumnsToAdd.filter( + (columnToAdd) => + !currentColumns.find( + (currentColumn) => currentColumn.taxon_measurement_id === columnToAdd.taxon_measurement_id + ) + ); + + // Store all measurement definitions in local storage + sessionStorage.setItem( + getSurveySessionStorageKey(surveyContext.surveyId, SIMS_OBSERVATIONS_MEASUREMENT_COLUMNS), + JSON.stringify([...currentColumns, ...newColumns]) + ); + + return [...currentColumns, ...newColumns]; + }); + }, + [observationsTableContext, surveyContext.surveyId] + ); + + /** + * Handles the removal of measurement columns from the table. + * + * @param {string[]} measurementColumnsToRemove The `field` names of the columns to remove. For measurementColumnsToAdd, this is + * the `taxon_measurement_id`. + */ + const onRemoveMeasurementColumns = useCallback( + (measurementColumnsToRemove: string[]) => { + // Delete the measurement columns from the database + observationsTableContext.deleteObservationMeasurementColumns(measurementColumnsToRemove, () => { + // Remove the measurement columns from the table context + observationsTableContext.setMeasurementColumns((currentColumns) => { + const remainingColumns = currentColumns.filter( + (currentColumn) => !measurementColumnsToRemove.includes(currentColumn.taxon_measurement_id) + ); + + // Store all remaining measurement definitions in local storage + sessionStorage.setItem( + getSurveySessionStorageKey(surveyContext.surveyId, SIMS_OBSERVATIONS_MEASUREMENT_COLUMNS), + JSON.stringify(remainingColumns) + ); + + return remainingColumns; + }); + }); + + // Update saved rows, removing any cell values for the deleted columns + observationsTableContext.setSavedRows((currentSavedRows) => { + return currentSavedRows.map((savedRow) => { + for (const columnIdToRemove of measurementColumnsToRemove) { + delete savedRow[columnIdToRemove]; + } + + return savedRow; + }); + }); + + // Update staged rows, removing any cell values for the deleted columns + observationsTableContext.setStagedRows((currentStagedRows) => { + return currentStagedRows.map((stagedRow) => { + for (const columnIdToRemove of measurementColumnsToRemove) { + delete stagedRow[columnIdToRemove]; + } + + return stagedRow; + }); + }); + }, + [observationsTableContext, surveyContext.surveyId] + ); + + return { + onAddMeasurementColumns, + onRemoveMeasurementColumns + }; +}; diff --git a/app/src/features/surveys/observations/observations-table/configure-table/ConfigureColumns.tsx b/app/src/features/surveys/observations/observations-table/configure-table/ConfigureColumns.tsx deleted file mode 100644 index 5fa3bb30d6..0000000000 --- a/app/src/features/surveys/observations/observations-table/configure-table/ConfigureColumns.tsx +++ /dev/null @@ -1,138 +0,0 @@ -import { mdiCogOutline } from '@mdi/js'; -import Icon from '@mdi/react'; -import IconButton from '@mui/material/IconButton'; -import Popover from '@mui/material/Popover'; -import { GridColDef } from '@mui/x-data-grid'; -import { IObservationTableRow } from 'contexts/observationsTableContext'; -import { CBMeasurementType } from 'interfaces/useCritterApi.interface'; -import { useState } from 'react'; -import { ConfigureColumnsPopoverContent } from './ConfigureColumnsPopoverContent'; - -export interface IConfigureColumnsProps { - /** - * Controls the disabled state of the button. - * - * @type {boolean} - * @memberof IConfigureColumnsProps - */ - disabled: boolean; - /** - * The column field names of the hidden columns. - * - * @type {GridColDef[]} - * @memberof IConfigureColumnsProps - */ - hiddenFields: string[]; - /** - * The column definitions of the columns that may be toggled to hidden or visible. - * - * @type {GridColDef[]} - * @memberof IConfigureColumnsProps - */ - hideableColumns: GridColDef[]; - /** - * Callback fired when a column is toggled to hidden or visible. - * - * @memberof IConfigureColumnsProps - */ - onToggleColumnVisibility: (field: string) => void; - /** - * Callback fired when all columns are toggled to hidden or visible. - * - * @memberof IConfigureColumnsProps - */ - onToggleShowHideAll: () => void; - /** - * Controls the disabled state of the addF measurement column buttons. - * - * @type {boolean} - * @memberof IConfigureColumnsProps - */ - disabledAddMeasurements: boolean; - /** - * Controls the disabled state of the remove measurement column buttons. - * - * @type {boolean} - * @memberof IConfigureColumnsProps - */ - disabledRemoveMeasurements: boolean; - /** - * The measurement columns to render in the table. - * - * @type {CBMeasurementType[]} - * @memberof IConfigureColumnsProps - */ - measurementColumns: CBMeasurementType[]; - /** - * Callback fired when a measurement column is removed. - * - * @memberof IConfigureColumnsProps - */ - onRemoveMeasurements: (measurementFields: string[]) => void; - /** - * Callback fired when a measurement column is added. - * - * @memberof IConfigureColumnsProps - */ - onAddMeasurements: (measurements: CBMeasurementType[]) => void; -} - -/** - * Renders a button, which renders a popover, to manage the observation table columns. - * - * @param {IConfigureColumnsProps} props - * @return {*} - */ -export const ConfigureColumns = (props: IConfigureColumnsProps) => { - const { - disabled, - hiddenFields, - hideableColumns, - onToggleColumnVisibility, - onToggleShowHideAll, - disabledAddMeasurements, - disabledRemoveMeasurements, - measurementColumns, - onRemoveMeasurements, - onAddMeasurements - } = props; - - const [columnVisibilityMenuAnchorEl, setColumnVisibilityMenuAnchorEl] = useState(null); - - return ( - <> - setColumnVisibilityMenuAnchorEl(event.currentTarget)} - title="Configure Columns" - disabled={disabled}> - - - setColumnVisibilityMenuAnchorEl(null)}> - - - - ); -}; diff --git a/app/src/features/surveys/observations/observations-table/configure-table/ConfigureColumnsContainer.tsx b/app/src/features/surveys/observations/observations-table/configure-table/ConfigureColumnsContainer.tsx deleted file mode 100644 index cdab325d5c..0000000000 --- a/app/src/features/surveys/observations/observations-table/configure-table/ConfigureColumnsContainer.tsx +++ /dev/null @@ -1,209 +0,0 @@ -import { GridColDef, GridColumnVisibilityModel, GridRowModes } from '@mui/x-data-grid'; -import { - getSurveySessionStorageKey, - SIMS_OBSERVATIONS_HIDDEN_COLUMNS, - SIMS_OBSERVATIONS_MEASUREMENT_COLUMNS -} from 'constants/session-storage'; -import { IObservationTableRow } from 'contexts/observationsTableContext'; -import { SurveyContext } from 'contexts/surveyContext'; -import { ConfigureColumns } from 'features/surveys/observations/observations-table/configure-table/ConfigureColumns'; -import { useObservationsTableContext } from 'hooks/useContext'; -import { CBMeasurementType } from 'interfaces/useCritterApi.interface'; -import { useCallback, useContext, useEffect, useMemo } from 'react'; - -export interface IConfigureColumnsContainerProps { - /** - * Controls the disabled state of the button. - * - * @type {boolean} - * @memberof IConfigureColumnsContainerProps - */ - disabled: boolean; - /** - * The column definitions of the columns rendered in the observations table - * - * @type {GridColDef[]} - * @memberof IConfigureColumnsContainerProps - */ - columns: GridColDef[]; -} - -/** - * Renders a button, which renders a popover, to manage measurements. - * - * @param {IConfigureColumnsContainerProps} props - * @return {*} - */ -export const ConfigureColumnsContainer = (props: IConfigureColumnsContainerProps) => { - const { disabled, columns } = props; - - const surveyContext = useContext(SurveyContext); - const { surveyId } = surveyContext; - - const observationsTableContext = useObservationsTableContext(); - - // The array of columns that may be toggled as hidden or visible - const hideableColumns = useMemo(() => { - return columns.filter((column) => column?.hideable); - }, [columns]); - - /** - * Toggles visibility for a particular column - */ - const onToggleColumnVisibility = (field: string) => { - // Get the current visibility model for the column - const columnVisibility = observationsTableContext.columnVisibilityModel[field]; - - // If model is undefined, then no visibility model has been set for this column, default to true - const isColumnVisible = columnVisibility === undefined ? true : columnVisibility; - - // Toggle the visibility of the column - observationsTableContext._muiDataGridApiRef.current.setColumnVisibility(field, !isColumnVisible); - }; - - /** - * Toggles whether all columns are hidden or visible. - */ - const onToggleShowHideAll = useCallback(() => { - const hasHiddenColumns = Object.values(observationsTableContext.columnVisibilityModel).some( - (item) => item === true - ); - - let model: GridColumnVisibilityModel = {}; - - if (hasHiddenColumns) { - // Some columns currently hidden, show all columns - model = { ...Object.fromEntries(hideableColumns.map((column) => [column.field, false])) }; - } else { - // No columns currently hidden, hide all columns - model = { ...Object.fromEntries(hideableColumns.map((column) => [column.field, true])) }; - } - - // Store current visibility model in session storage - sessionStorage.setItem( - getSurveySessionStorageKey(surveyId, SIMS_OBSERVATIONS_HIDDEN_COLUMNS), - JSON.stringify(model) - ); - - // Update the column visibility model in the context - observationsTableContext.setColumnVisibilityModel(model); - }, [hideableColumns, observationsTableContext, surveyId]); - - /** - * Handles the removal of measurement columns from the table. - * - * @param {string[]} measurementColumnsToRemove The `field` names of the columns to remove - */ - const onRemoveMeasurements = useCallback( - (measurementColumnsToRemove: string[]) => { - // Delete the measurement columns from the database - observationsTableContext.deleteObservationMeasurementColumns(measurementColumnsToRemove, () => { - // Remove the measurement columns from the table context - observationsTableContext.setMeasurementColumns((currentColumns) => { - const remainingColumns = currentColumns.filter( - (currentColumn) => !measurementColumnsToRemove.includes(currentColumn.taxon_measurement_id) - ); - - // Store all remaining measurement definitions in local storage - sessionStorage.setItem( - getSurveySessionStorageKey(surveyId, SIMS_OBSERVATIONS_MEASUREMENT_COLUMNS), - JSON.stringify(remainingColumns) - ); - - return remainingColumns; - }); - }); - }, - [observationsTableContext, surveyId] - ); - - /** - * Handles the addition of measurement columns to the table. - * - * @param {CBMeasurementType[]} measurements - * @return {*} - */ - const onAddMeasurements = (measurements: CBMeasurementType[]) => { - if (!measurements?.length) { - return; - } - - // Add the measurement columns to the table context - observationsTableContext.setMeasurementColumns((currentColumns) => { - const newColumns = measurements.filter( - (columnToAdd) => - !currentColumns.find( - (currentColumn) => currentColumn.taxon_measurement_id === columnToAdd.taxon_measurement_id - ) - ); - - // Store all measurement definitions in local storage - sessionStorage.setItem( - getSurveySessionStorageKey(surveyId, SIMS_OBSERVATIONS_MEASUREMENT_COLUMNS), - JSON.stringify([...currentColumns, ...newColumns]) - ); - - return [...currentColumns, ...newColumns]; - }); - }; - - /** - * On first mount, load visibility state from session storage, if it exists. - */ - useEffect(() => { - if (Object.keys(observationsTableContext.columnVisibilityModel).length) { - // The column visibility model is not empty (has already been initialized) - return; - } - - // Get visibility model from session storage, if it exists - const stringifiedModel: string | null = sessionStorage.getItem( - getSurveySessionStorageKey(surveyId, SIMS_OBSERVATIONS_HIDDEN_COLUMNS) - ); - - if (!stringifiedModel) { - // No visibility model found in session storage, leave the model in its default initial state - return; - } - - try { - const model: GridColumnVisibilityModel = JSON.parse(stringifiedModel); - - observationsTableContext.setColumnVisibilityModel(model); - } catch { - // An error occurred parsing the visibility model from session storage, do nothing - return; - } - }, [observationsTableContext, surveyId]); - - /** - * Return `true` if any row is in edit mode, `false` otherwise. - * - * @return {*} {boolean} - */ - function isAnyRowInEditMode(): boolean { - return Object.values(observationsTableContext.rowModesModel).some( - (innerObj) => innerObj.mode === GridRowModes.Edit - ); - } - - // The currently hidden fields - const hiddenFields = Object.keys(observationsTableContext.columnVisibilityModel).filter( - (key) => observationsTableContext.columnVisibilityModel[key] === false - ); - - return ( - - ); -}; diff --git a/app/src/features/surveys/observations/observations-table/configure-table/ConfigureColumnsPopoverContent.tsx b/app/src/features/surveys/observations/observations-table/configure-table/ConfigureColumnsPopoverContent.tsx deleted file mode 100644 index 94c63c570f..0000000000 --- a/app/src/features/surveys/observations/observations-table/configure-table/ConfigureColumnsPopoverContent.tsx +++ /dev/null @@ -1,135 +0,0 @@ -import { mdiTrashCanOutline } from '@mdi/js'; -import Icon from '@mdi/react'; -import Box from '@mui/material/Box'; -import Checkbox from '@mui/material/Checkbox'; -import grey from '@mui/material/colors/grey'; -import Divider from '@mui/material/Divider'; -import FormControlLabel from '@mui/material/FormControlLabel'; -import IconButton from '@mui/material/IconButton'; -import List from '@mui/material/List'; -import ListItem from '@mui/material/ListItem'; -import ListItemButton from '@mui/material/ListItemButton'; -import ListItemIcon from '@mui/material/ListItemIcon'; -import ListItemText from '@mui/material/ListItemText'; -import Stack from '@mui/material/Stack'; -import Typography from '@mui/material/Typography'; -import { GridColDef } from '@mui/x-data-grid'; -import { IObservationTableRow } from 'contexts/observationsTableContext'; -import { CBMeasurementType } from 'interfaces/useCritterApi.interface'; -import { MeasurementsButton } from './measurements/dialog/MeasurementsButton'; - -export interface IConfigureColumnsPopoverContentProps { - hideableColumns: GridColDef[]; - hiddenFields: string[]; - onToggleColumnVisibility: (field: string) => void; - onToggledShowHideAll: () => void; - disabledAddMeasurements: boolean; - disabledRemoveMeasurements: boolean; - measurementColumns: CBMeasurementType[]; - onRemoveMeasurements: (measurementFields: string[]) => void; - onAddMeasurements: (measurements: CBMeasurementType[]) => void; -} - -/** - * Renders a list of columns, with controls for managing their visibility and adding/removing measurement columns. - * - * @param {IConfigureColumnsPopoverContentProps} props - * @return {*} - */ -export const ConfigureColumnsPopoverContent = (props: IConfigureColumnsPopoverContentProps) => { - const { - hideableColumns, - hiddenFields, - onToggleColumnVisibility, - onToggledShowHideAll, - measurementColumns, - disabledAddMeasurements, - disabledRemoveMeasurements, - onRemoveMeasurements, - onAddMeasurements - } = props; - - return ( - - - - Configure Observations - - - - - - - - - - 0 && hiddenFields.length < hideableColumns.length} - checked={hiddenFields.length === 0} - onClick={() => onToggledShowHideAll()} - /> - } - label={ - - Show/Hide all - - } - /> - - - - - - {hideableColumns.map((column) => { - return ( - item.taxon_measurement_id === column.field) && ( - onRemoveMeasurements([column.field])}> - - - ) - } - disablePadding> - onToggleColumnVisibility(column.field)} - sx={{ background: grey[50] }}> - - - - {column.headerName} - - - ); - })} - - - ); -}; diff --git a/app/src/features/surveys/observations/observations-table/configure-table/measurements/dialog/MeasurementsButton.tsx b/app/src/features/surveys/observations/observations-table/configure-table/measurements/dialog/MeasurementsButton.tsx deleted file mode 100644 index 8b1eb45def..0000000000 --- a/app/src/features/surveys/observations/observations-table/configure-table/measurements/dialog/MeasurementsButton.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import { mdiPlus } from '@mdi/js'; -import Icon from '@mdi/react'; -import Button from '@mui/material/Button'; -import { MeasurementsDialog } from 'features/surveys/observations/observations-table/configure-table/measurements/dialog/MeasurementsDialog'; -import { CBMeasurementType } from 'interfaces/useCritterApi.interface'; -import { useState } from 'react'; - -export interface IMeasurementsButtonProps { - disabled: boolean; - /** - * The selected measurements. - * - * @type {CBMeasurementType[]} - * @memberof IMeasurementsButtonProps - */ - selectedMeasurements: CBMeasurementType[]; - /** - * Callback fired on save. - * - * @memberof IMeasurementsButtonProps - */ - onAddMeasurements: (measurements: CBMeasurementType[]) => void; -} - -/** - * Renders a dialog to manage measurements, and a button to open the dialog. - * - * @param {IMeasurementsButtonProps} props - * @return {*} - */ -export const MeasurementsButton = (props: IMeasurementsButtonProps) => { - const { disabled, selectedMeasurements, onAddMeasurements } = props; - - const [open, setOpen] = useState(false); - - return ( - <> - - { - onAddMeasurements(measurements); - setOpen(false); - }} - onCancel={() => { - setOpen(false); - }} - /> - - ); -}; diff --git a/app/src/features/surveys/observations/observations-table/configure-table/measurements/dialog/MeasurementsDialog.tsx b/app/src/features/surveys/observations/observations-table/configure-table/measurements/dialog/MeasurementsDialog.tsx deleted file mode 100644 index 9af7e52e05..0000000000 --- a/app/src/features/surveys/observations/observations-table/configure-table/measurements/dialog/MeasurementsDialog.tsx +++ /dev/null @@ -1,109 +0,0 @@ -import Button from '@mui/material/Button'; -import Dialog from '@mui/material/Dialog'; -import DialogActions from '@mui/material/DialogActions'; -import DialogContent from '@mui/material/DialogContent'; -import DialogTitle from '@mui/material/DialogTitle'; -import useTheme from '@mui/material/styles/useTheme'; -import useMediaQuery from '@mui/material/useMediaQuery'; -import { MeasurementsList } from 'features/surveys/observations/observations-table/configure-table/measurements/list/MeasurementsList'; -import { MeasurementsSearch } from 'features/surveys/observations/observations-table/configure-table/measurements/search/MeasurementsSearch'; -import { CBMeasurementType } from 'interfaces/useCritterApi.interface'; -import { useState } from 'react'; - -export interface IMeasurementsDialogProps { - /** - * Controls whether the dialog is open or not. - * - * @type {boolean} - * @memberof IMeasurementsDialogProps - */ - open: boolean; - /** - * The selected measurements. - * - * @type {CBMeasurementType[]} - * @memberof IMeasurementsDialogProps - */ - selectedMeasurements: CBMeasurementType[]; - /** - * Callback fired on save. - * - * @memberof IMeasurementsDialogProps - */ - onSave: (measurements: CBMeasurementType[]) => void; - /** - * Callback fired on cancel. - * - * @memberof IMeasurementsDialogProps - */ - onCancel: () => void; -} - -/** - * Renders a dialog to manage measurements. - * - * @param {IMeasurementsDialogProps} props - * @return {*} - */ -export const MeasurementsDialog = (props: IMeasurementsDialogProps) => { - const { open, selectedMeasurements: initialSelectedMeasurements, onSave, onCancel } = props; - - const theme = useTheme(); - const fullScreen = useMediaQuery(theme.breakpoints.down('sm')); - - const [selectedMeasurements, setSelectedMeasurements] = useState(initialSelectedMeasurements); - - const onRemove = (measurementToRemove: CBMeasurementType) => { - setSelectedMeasurements((currentMeasurements) => - currentMeasurements.filter( - (currentMeasurement) => currentMeasurement.taxon_measurement_id !== measurementToRemove.taxon_measurement_id - ) - ); - }; - - const onSelectOptions = (measurementsToAdd: CBMeasurementType[]) => { - setSelectedMeasurements((currentMeasurements) => - [...currentMeasurements, ...measurementsToAdd].filter( - (item1, index, self) => - index === self.findIndex((item2) => item2.taxon_measurement_id === item1.taxon_measurement_id) - ) - ); - }; - - if (!open) { - return <>; - } - - return ( - - Add Observation Measurements - - - - - - - - - - ); -}; diff --git a/app/src/features/surveys/observations/observations-table/configure-table/measurements/list/MeasurementsList.tsx b/app/src/features/surveys/observations/observations-table/configure-table/measurements/list/MeasurementsList.tsx deleted file mode 100644 index 9797b1dce9..0000000000 --- a/app/src/features/surveys/observations/observations-table/configure-table/measurements/list/MeasurementsList.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import Collapse from '@mui/material/Collapse'; -import Stack from '@mui/material/Stack'; -import { MeasurementsListCard } from 'features/surveys/observations/observations-table/configure-table/measurements/list/MeasurementsListCard'; -import { CBMeasurementType } from 'interfaces/useCritterApi.interface'; -import { TransitionGroup } from 'react-transition-group'; - -export interface IMeasurementsListProps { - /** - * The selected measurements. - * - * @type {CBMeasurementType[]} - * @memberof IMeasurementsListProps - */ - selectedMeasurements: CBMeasurementType[]; - /** - * Callback fired on remove. - * - * @memberof IMeasurementsListProps - */ - onRemove: (measurement: CBMeasurementType) => void; -} - -/** - * Renders a list of measurement cards. - * - * @param {IMeasurementsListProps} props - * @return {*} - */ -export const MeasurementsList = (props: IMeasurementsListProps) => { - const { selectedMeasurements, onRemove } = props; - - return ( - - {selectedMeasurements.map((measurement) => { - return ( - - onRemove(measurement)} /> - - ); - })} - - ); -}; diff --git a/app/src/features/surveys/observations/observations-table/configure-table/measurements/list/MeasurementsListCard.tsx b/app/src/features/surveys/observations/observations-table/configure-table/measurements/list/MeasurementsListCard.tsx deleted file mode 100644 index 6b0b9560ee..0000000000 --- a/app/src/features/surveys/observations/observations-table/configure-table/measurements/list/MeasurementsListCard.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import { mdiTrashCanOutline } from '@mdi/js'; -import Icon from '@mdi/react'; -import Box from '@mui/material/Box'; -import Card from '@mui/material/Card'; -import grey from '@mui/material/colors/grey'; -import IconButton from '@mui/material/IconButton'; -import Stack from '@mui/material/Stack'; -import Typography from '@mui/material/Typography'; -import { CBMeasurementType } from 'interfaces/useCritterApi.interface'; - -export interface IMeasurementsListCardProps { - /** - * The measurement to display. - * - * @type {CBMeasurementType} - * @memberof IMeasurementsListCardProps - */ - measurement: CBMeasurementType; - /** - * Callback fired on remove. - * - * @memberof IMeasurementsListCardProps - */ - onRemove: () => void; -} - -/** - * Renders a single measurement card. - * - * @param {IMeasurementsListCardProps} props - * @return {*} - */ -export const MeasurementsListCard = (props: IMeasurementsListCardProps) => { - const { measurement, onRemove } = props; - - return ( - - - - - {measurement.itis_tsn} - - {/* - {props.measurement.commonNames ? ( - <> - {props.measurement.commonNames}  - - ({measurement.scientificName}) - - - ) : ( - {measurement.scientificName} - )} - */} - - - - {measurement.measurement_name} - - - {measurement.measurement_desc} - - - - - - - - - - ); -}; diff --git a/app/src/features/surveys/observations/observations-table/configure-table/measurements/search/MeasurementsSearch.tsx b/app/src/features/surveys/observations/observations-table/configure-table/measurements/search/MeasurementsSearch.tsx deleted file mode 100644 index aea8b66952..0000000000 --- a/app/src/features/surveys/observations/observations-table/configure-table/measurements/search/MeasurementsSearch.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import Box from '@mui/material/Box'; -import Typography from '@mui/material/Typography'; -import MeasurementsSearchAutocomplete from 'features/surveys/observations/observations-table/configure-table/measurements/search/MeasurementsSearchAutocomplete'; -import { useCritterbaseApi } from 'hooks/useCritterbaseApi'; -import useDataLoader from 'hooks/useDataLoader'; -import { CBMeasurementType } from 'interfaces/useCritterApi.interface'; - -export interface IMeasurementsSearchProps { - /** - * The selected measurements. - * - * @type {CBMeasurementType[]} - * @memberof IMeasurementsSearchProps - */ - selectedMeasurements: CBMeasurementType[]; - /** - * Callback fired on select options. - * - * @memberof IMeasurementsSearchProps - */ - onSelectOptions: (measurements: CBMeasurementType[]) => void; -} - -/** - * Renders an search input to find and add measurements. - * - * @param {IMeasurementsSearchProps} props - * @return {*} - */ -export const MeasurementsSearch = (props: IMeasurementsSearchProps) => { - const { selectedMeasurements, onSelectOptions } = props; - - const critterbaseApi = useCritterbaseApi(); - - const measurementsDataLoader = useDataLoader(critterbaseApi.xref.getMeasurementTypeDefinitionsBySearchTerm); - - return ( - - - Add additional measurements to your observations data. - - { - const response = await measurementsDataLoader.refresh(inputValue); - return (response && [...response.qualitative, ...response.quantitative]) || []; - }} - onSelectOptions={onSelectOptions} - /> - - ); -}; diff --git a/app/src/features/surveys/observations/observations-table/grid-column-definitions/GridColumnDefinitions.tsx b/app/src/features/surveys/observations/observations-table/grid-column-definitions/GridColumnDefinitions.tsx index 3be19e8a8f..06e7e9315c 100644 --- a/app/src/features/surveys/observations/observations-table/grid-column-definitions/GridColumnDefinitions.tsx +++ b/app/src/features/surveys/observations/observations-table/grid-column-definitions/GridColumnDefinitions.tsx @@ -1,6 +1,3 @@ -import { mdiTrashCanOutline } from '@mdi/js'; -import Icon from '@mdi/react'; -import IconButton from '@mui/material/IconButton'; import Typography from '@mui/material/Typography'; import { GridCellParams, GridColDef } from '@mui/x-data-grid'; import AutocompleteDataGridEditCell from 'components/data-grid/autocomplete/AutocompleteDataGridEditCell'; @@ -12,6 +9,10 @@ import TaxonomyDataGridViewCell from 'components/data-grid/taxonomy/TaxonomyData import TextFieldDataGrid from 'components/data-grid/TextFieldDataGrid'; import { IObservationTableRow } from 'contexts/observationsTableContext'; import { CBMeasurementType, CBQualitativeOption } from 'interfaces/useCritterApi.interface'; +import { + EnvironmentQualitativeTypeDefinition, + EnvironmentQuantitativeTypeDefinition +} from 'interfaces/useReferenceApi.interface'; export type ISampleSiteOption = { survey_sample_site_id: number; @@ -42,7 +43,7 @@ export const TaxonomyColDef = (props: { editable: true, hideable: true, flex: 1, - minWidth: 250, + minWidth: 200, disableColumnMenu: true, headerAlign: 'left', align: 'left', @@ -66,11 +67,11 @@ export const SampleSiteColDef = (props: { return { field: 'survey_sample_site_id', - headerName: 'Sampling Site', + headerName: 'Site', editable: true, hideable: true, flex: 1, - minWidth: 250, + minWidth: 180, disableColumnMenu: true, headerAlign: 'left', align: 'left', @@ -109,11 +110,11 @@ export const SampleMethodColDef = (props: { return { field: 'survey_sample_method_id', - headerName: 'Sampling Method', + headerName: 'Method', editable: true, hideable: true, flex: 1, - minWidth: 250, + minWidth: 180, disableColumnMenu: true, headerAlign: 'left', align: 'left', @@ -156,11 +157,11 @@ export const SamplePeriodColDef = (props: { return { field: 'survey_sample_period_id', - headerName: 'Sampling Period', + headerName: 'Period', editable: true, hideable: true, flex: 0, - width: 250, + minWidth: 180, disableColumnMenu: true, headerAlign: 'left', align: 'left', @@ -262,31 +263,6 @@ export const ObservationCountColDef = (props: { }; }; -export const ObservationActionsColDef = (props: { - disabled: boolean; - onDelete: (observationRecords: IObservationTableRow[]) => void; -}): GridColDef => { - return { - field: 'actions', - headerName: '', - type: 'actions', - width: 70, - disableColumnMenu: true, - resizable: false, - cellClassName: 'pinnedColumn', - getActions: (params) => [ - { - props.onDelete([params.row]); - }} - disabled={props.disabled} - key={`actions[${params.id}].handleDeleteRow`}> - - - ] - }; -}; - export const ObservationQuantitativeMeasurementColDef = (props: { measurement: CBMeasurementType; hasError: (params: GridCellParams) => boolean; @@ -354,7 +330,90 @@ export const ObservationQualitativeMeasurementColDef = (props: { hideable: true, sortable: false, flex: 1, - minWidth: Math.min(300, Math.max(250, measurement.measurement_name.length * 10 + 20)), + minWidth: Math.min(300, Math.max(180, measurement.measurement_name.length * 10 + 20)), + disableColumnMenu: true, + headerAlign: 'left', + align: 'left', + renderCell: (params) => { + return ( + + ); + }, + renderEditCell: (params) => { + return ( + + ); + } + }; +}; + +export const ObservationQuantitativeEnvironmentColDef = (props: { + environment: EnvironmentQuantitativeTypeDefinition; + hasError: (params: GridCellParams) => boolean; +}): GridColDef => { + const { environment, hasError } = props; + return { + field: String(environment.environment_quantitative_id), + headerName: environment.name, + editable: true, + hideable: true, + sortable: false, + type: 'number', + minWidth: Math.min(300, Math.max(110, environment.name.length * 10 + 20)), + disableColumnMenu: true, + headerAlign: 'right', + align: 'right', + renderCell: (params) => ( + + {params.value} + + ), + renderEditCell: (params) => { + const error = hasError(params); + + return ( + { + if (!/^\d{0,7}$/.test(event.target.value)) { + // If the value is not a number, return + return; + } + + params.api.setEditCellValue({ + id: params.id, + field: params.field, + value: event.target.value + }); + }, + error + }} + /> + ); + } + }; +}; + +export const ObservationQualitativeEnvironmentColDef = (props: { + environment: EnvironmentQualitativeTypeDefinition; + hasError: (params: GridCellParams) => boolean; +}): GridColDef => { + const { environment, hasError } = props; + + const qualitativeOptions = environment.options.map((item) => ({ + label: item.name, + value: item.environment_qualitative_option_id + })); + return { + field: String(environment.environment_qualitative_id), + headerName: environment.name, + editable: true, + hideable: true, + sortable: false, + flex: 1, + minWidth: Math.min(300, Math.max(180, environment.name.length * 10 + 20)), disableColumnMenu: true, headerAlign: 'left', align: 'left', diff --git a/app/src/features/surveys/observations/observations-table/grid-column-definitions/GridColumnDefinitionsUtils.tsx b/app/src/features/surveys/observations/observations-table/grid-column-definitions/GridColumnDefinitionsUtils.tsx index 0555f70233..b62e27b7be 100644 --- a/app/src/features/surveys/observations/observations-table/grid-column-definitions/GridColumnDefinitionsUtils.tsx +++ b/app/src/features/surveys/observations/observations-table/grid-column-definitions/GridColumnDefinitionsUtils.tsx @@ -1,7 +1,9 @@ import { GridCellParams, GridColDef } from '@mui/x-data-grid'; import { IObservationTableRow } from 'contexts/observationsTableContext'; import { + ObservationQualitativeEnvironmentColDef, ObservationQualitativeMeasurementColDef, + ObservationQuantitativeEnvironmentColDef, ObservationQuantitativeMeasurementColDef } from 'features/surveys/observations/observations-table/grid-column-definitions/GridColumnDefinitions'; import { @@ -9,6 +11,11 @@ import { CBQualitativeMeasurementTypeDefinition, CBQuantitativeMeasurementTypeDefinition } from 'interfaces/useCritterApi.interface'; +import { + EnvironmentQualitativeTypeDefinition, + EnvironmentQuantitativeTypeDefinition, + EnvironmentType +} from 'interfaces/useReferenceApi.interface'; /** * Asserts the measurement is a quantitative measurement type definition. @@ -38,44 +45,27 @@ export const isQualitativeMeasurementTypeDefinition = ( }; /** - * Given a quantitative measurement type definition, returns a measurement column. + * Asserts the environment is a quantitative environment type definition. * - * @param {CBQuantitativeMeasurementTypeDefinition} measurement - * @param {(params: GridCellParams) => boolean} hasError - * @return {*} + * @param {(EnvironmentQualitativeTypeDefinition | EnvironmentQuantitativeTypeDefinition)} environment + * @return {*} {environment is EnvironmentQuantitativeTypeDefinition} */ -export const getQuantitativeMeasurementColumn = ( - measurement: CBQuantitativeMeasurementTypeDefinition, - hasError: (params: GridCellParams) => boolean -) => { - return { - measurement: measurement, - colDef: ObservationQuantitativeMeasurementColDef({ - measurement: measurement, - hasError: hasError - }) - }; +export const isQuantitativeEnvironmentTypeDefinition = ( + environment: EnvironmentQualitativeTypeDefinition | EnvironmentQuantitativeTypeDefinition +): environment is EnvironmentQuantitativeTypeDefinition => { + return (environment as EnvironmentQuantitativeTypeDefinition).environment_quantitative_id !== undefined; }; /** - * Given a qualitative measurement type definition, returns a measurement column. + * Asserts the environment is a qualitative environment type definition. * - * @param {CBQualitativeMeasurementTypeDefinition} measurement - * @param {(params: GridCellParams) => boolean} hasError - * @return {*} + * @param {(EnvironmentQualitativeTypeDefinition | EnvironmentQuantitativeTypeDefinition)} environment + * @return {*} {environment is EnvironmentQualitativeTypeDefinition} */ -export const getQualitativeMeasurementColumn = ( - measurement: CBQualitativeMeasurementTypeDefinition, - hasError: (params: GridCellParams) => boolean -) => { - return { - measurement: measurement, - colDef: ObservationQualitativeMeasurementColDef({ - measurement: measurement, - measurementOptions: measurement.options, - hasError: hasError - }) - }; +export const isQualitativeEnvironmentTypeDefinition = ( + environment: EnvironmentQualitativeTypeDefinition | EnvironmentQuantitativeTypeDefinition +): environment is EnvironmentQualitativeTypeDefinition => { + return (environment as EnvironmentQualitativeTypeDefinition).environment_qualitative_id !== undefined; }; export const getMeasurementColumnDefinitions = ( @@ -105,3 +95,29 @@ export const getMeasurementColumnDefinitions = ( } return colDefs; }; + +export const getEnvironmentColumnDefinitions = ( + environments: EnvironmentType, + hasError: (params: GridCellParams) => boolean +): GridColDef[] => { + const colDefs: GridColDef[] = []; + for (const environment of environments.quantitative_environments) { + colDefs.push( + ObservationQuantitativeEnvironmentColDef({ + environment: environment, + hasError: hasError + }) + ); + } + + for (const environment of environments.qualitative_environments) { + colDefs.push( + ObservationQualitativeEnvironmentColDef({ + environment: environment, + hasError: hasError + }) + ); + } + + return colDefs; +}; diff --git a/app/src/features/surveys/observations/observations-table/import-obsevations/ImportObservationsButton.tsx b/app/src/features/surveys/observations/observations-table/import-obsevations/ImportObservationsButton.tsx index b71460b6d4..fc54eaaab5 100644 --- a/app/src/features/surveys/observations/observations-table/import-obsevations/ImportObservationsButton.tsx +++ b/app/src/features/surveys/observations/observations-table/import-obsevations/ImportObservationsButton.tsx @@ -42,6 +42,14 @@ export interface IImportObservationsButtonProps { * @memberof IImportObservationsButtonProps */ onFinish?: () => void; + /** + * Options to pass to the process csv submission endpoint. + * + * @type {{ + * surveySamplePeriodId?: number; + * }} + * @memberof IImportObservationsButtonProps + */ } export const ImportObservationsButton = (props: IImportObservationsButtonProps) => { diff --git a/app/src/features/surveys/observations/observations-table/observation-row-validation/ObservationRowValidationUtils.ts b/app/src/features/surveys/observations/observations-table/observation-row-validation/ObservationRowValidationUtils.ts index 7e7e6dfa48..949b4d779a 100644 --- a/app/src/features/surveys/observations/observations-table/observation-row-validation/ObservationRowValidationUtils.ts +++ b/app/src/features/surveys/observations/observations-table/observation-row-validation/ObservationRowValidationUtils.ts @@ -1,34 +1,43 @@ import { GridColDef } from '@mui/x-data-grid'; -import { IObservationTableRow, ObservationRowValidationError, TSNMeasurement } from 'contexts/observationsTableContext'; +import { IObservationTableRow, ObservationRowValidationError } from 'contexts/observationsTableContext'; import dayjs from 'dayjs'; import { + CBMeasurementSearchByTsnResponse, + CBMeasurementType, CBQualitativeMeasurementTypeDefinition, - CBQualitativeOption, CBQuantitativeMeasurementTypeDefinition } from 'interfaces/useCritterApi.interface'; +import { EnvironmentType } from 'interfaces/useReferenceApi.interface'; /** * Validates a given observation table row against the given measurement columns. * * @param {IObservationTableRow} row The observation table row to validate - * @param {string[]} measurementColumns A list of the measurement columns to validate - * @param {(tsn: number) => Promise} getTSNMeasurements A function that fetches measurement definitions from Critterbase based on the row itis_tsn value + * @param {CBMeasurementType[]} measurementColumns A list of the measurement column definitions to validate against + * @param {(tsn: number) => Promise} getTsnMeasurementTypeDefinitionMap A function that fetches measurement definitions from Critterbase based on the row itis_tsn value * @returns {*} Promise */ export const validateObservationTableRowMeasurements = async ( row: IObservationTableRow, - measurementColumns: string[], - getTSNMeasurements: (tsn: number) => Promise + measurementColumns: CBMeasurementType[], + getTsnMeasurementTypeDefinitionMap: (tsn: number) => Promise ): Promise => { const measurementErrors: ObservationRowValidationError[] = []; - // no taxon or measurements, nothing to validate - if (!row.itis_tsn || !measurementColumns.length) { + + if (!row.itis_tsn) { + // The row has no taxon, therefore we cannot validate the measurements, which are dependent on the taxon + return []; + } + + if (!measurementColumns.length) { + // The row has no measurements, nothing to validate return []; } - // Fetch measurement definitions for the provided itis_tsn - const measurements = await getTSNMeasurements(Number(row.itis_tsn)); - if (!measurements) { + const taxonMeasurements = await getTsnMeasurementTypeDefinitionMap(Number(row.itis_tsn)); + + if (!taxonMeasurements) { + // This taxon has no valid measurements, return an error return [ { field: 'itis_tsn', @@ -37,114 +46,203 @@ export const validateObservationTableRowMeasurements = async ( ]; } - // go through each measurement on the table and validate against the measurement definition from Critterbase + // For each measurement column in the table, validate its value against the measurement definition from Critterbase measurementColumns.forEach((measurementColumn) => { - const data = row[measurementColumn]; - if (data) { - const foundQualitative = measurements.qualitative.find( - (q: CBQualitativeMeasurementTypeDefinition) => q.taxon_measurement_id === measurementColumn + const cellValue = row[measurementColumn.taxon_measurement_id]; + + if (!cellValue) { + // The cell is empty, no need to validate, continue to the next measurement + return; + } + + // Check if the measurement applies to this taxon, and if so, validate the value + if ( + taxonMeasurements.qualitative.find( + (qualitative) => qualitative.taxon_measurement_id === measurementColumn.taxon_measurement_id + ) + ) { + // This measurement applies to this taxon, validate that the value is one of the valid options for this measurement + const error = _validateQualitativeCell( + (measurementColumn as CBQualitativeMeasurementTypeDefinition).taxon_measurement_id, + (measurementColumn as CBQualitativeMeasurementTypeDefinition).options.map( + (option) => option.qualitative_option_id + ), + String(cellValue) ); - if (foundQualitative) { - const error = _validateQualitativeMeasurement(measurementColumn, String(data), foundQualitative.options); - if (error) { - measurementErrors.push(error); - } + + if (error) { + // The value is not a valid option for this measurement, return an error + measurementErrors.push(error); } - const foundQuantitative = measurements.quantitative.find( - (q: CBQuantitativeMeasurementTypeDefinition) => q.taxon_measurement_id === measurementColumn + // The value is valid, continue to the next measurement + return; + } else if ( + taxonMeasurements.quantitative.find( + (quantitative) => quantitative.taxon_measurement_id === measurementColumn.taxon_measurement_id + ) + ) { + // This measurement applies to this taxon, validate that the value is within the valid range for this measurement + const error = _validateQuantitativeCell( + (measurementColumn as CBQuantitativeMeasurementTypeDefinition).taxon_measurement_id, + (measurementColumn as CBQuantitativeMeasurementTypeDefinition).min_value, + (measurementColumn as CBQuantitativeMeasurementTypeDefinition).max_value, + Number(cellValue) ); - if (foundQuantitative) { - const error = _validateQuantitativeMeasurement( - measurementColumn, - Number(data), - foundQuantitative.min_value, - foundQuantitative.max_value - ); - - if (error) { - measurementErrors.push(error); - } - } - // A measurement column has data but no measurements were found for the itis_tsn - if (!foundQualitative && !foundQuantitative) { - measurementErrors.push({ - field: measurementColumn, - message: `Invalid measurement set for taxon.` - }); + if (error) { + // The value is outside of the valid range for this measurement, return an error + measurementErrors.push(error); } + + // The value is valid, continue to the next measurement + return; } + + // The cell value is not empty, but the measurement does not apply to this taxon, return an error + measurementErrors.push({ + field: measurementColumn.taxon_measurement_id, + message: `Invalid measurement set for taxon.` + }); }); return measurementErrors; }; /** - * This validates if the provided option UUID exists within the given list of CBQualitativeOption. - * Returns null if the option is valid or an ObservationRowValidationError if it is not found + * Validates a given observation table row against the given measurement columns. * - * @param {string} field The column key for the data being validated - * @param {string} value The options UUID to look for - * @param {CBQualitativeOption[]} options - * @returns {*} ObservationRowValidationError | null + * @param {IObservationTableRow} row The observation table row to validate + * @param {EnvironmentType} environmentColumns A list of the environment column definitions to validate against + * @return {*} {Promise} + */ +export const validateObservationTableRowEnvironments = async ( + row: IObservationTableRow, + environmentColumns: EnvironmentType +): Promise => { + const environmentErrors: ObservationRowValidationError[] = []; + + if (!environmentColumns.qualitative_environments.length && !environmentColumns.quantitative_environments.length) { + // The row has no environments, nothing to validate + return []; + } + + // For each environment column in the table, validate its value against the environment definition + environmentColumns.qualitative_environments.forEach((environmentColumn) => { + const cellValue = row[environmentColumn.environment_qualitative_id]; + + if (!cellValue) { + // The cell is empty, no need to validate, continue to the next environment + return; + } + + const error = _validateQualitativeCell( + String(environmentColumn.environment_qualitative_id), + environmentColumn.options.map((option) => String(option.environment_qualitative_option_id)), + String(cellValue) + ); + + if (error) { + // The value is not a valid option for this environment, return an error + environmentErrors.push(error); + } + }); + + // For each environment column in the table, validate its value against the environment definition + environmentColumns.quantitative_environments.forEach((environmentColumn) => { + const cellValue = row[environmentColumn.environment_quantitative_id]; + + if (!cellValue) { + // The cell is empty, no need to validate, continue to the next environment + return; + } + + const error = _validateQuantitativeCell( + String(environmentColumn.environment_quantitative_id), + environmentColumn.min, + environmentColumn.max, + Number(cellValue) + ); + + if (error) { + // The value is outside of the valid range for this environment, return an error + environmentErrors.push(error); + } + }); + + return environmentErrors; +}; + +/** + * Validates any qualitative cell value against the provided options. + * If the value does not match any of the valid options, an error is returned. + * + * @param {string} field the id of the column + * @param {string[]} optionIds the valid option ids for the column + * @param {string} cellValue the value of the cell to validate (expected to match one of the option ids) + * @return {*} {(ObservationRowValidationError | null)} */ -const _validateQualitativeMeasurement = ( +const _validateQualitativeCell = ( field: string, - value: string, - options: CBQualitativeOption[] + optionIds: string[], + cellValue: string ): ObservationRowValidationError | null => { - const foundOption = options.find((op) => op.qualitative_option_id === value); + const matchingOption = optionIds.includes(cellValue); - if (!foundOption) { - // found measurement, no option, no bueno + if (!matchingOption) { + // The cellValue does not match any of the valid options for this measurement return { field, - message: 'Invalid option selected for taxon.' + message: 'Invalid option selected.' }; } + + // The cellValue is valid return null; }; /** - * Validates if a given value is in between min and max values from a Quantitative Measurement definition in Critterbase. - * Returns null if the value is valid or a ObservationRowValidationError describing that the value is out of the valid range. + * Validates any quantitative cell value against the provided min and max values. + * If the value is outside of the valid range, an error is returned. * - * @param {string} field The column key for the data being validated - * @param {number} value The number to validate - * @param {number | null} minValue The min value provided by the measurement definition from Critterbase - * @param {number | null} maxValue The max value provided by the measurement definition from Critterbase - * @returns {*} ObservationRowValidationError | null + * @param {string} field the id of the column + * @param {(number | null)} minValue the minimum value for the column + * @param {(number | null)} maxValue the maximum value for the column + * @param {number} cellValue the value of the cell to validate (expected to be within the min and max values, if set) + * @return {*} {(ObservationRowValidationError | null)} */ -const _validateQuantitativeMeasurement = ( +const _validateQuantitativeCell = ( field: string, - value: number, minValue: number | null, - maxValue: number | null + maxValue: number | null, + cellValue: number ): ObservationRowValidationError | null => { - if (minValue && maxValue) { - if (minValue <= value && value <= maxValue) { - return null; - } - } else { - if (minValue !== null && minValue <= value) { - return null; - } + if (minValue !== null && maxValue !== null && (cellValue < minValue || cellValue > maxValue)) { + // Both min and max values are set and the cell value is outside of the valid range + return { + field, + message: `Value provided is outside of the valid range [${minValue}, ${maxValue}]` + }; + } - if (maxValue !== null && value <= maxValue) { - return null; - } + if (minValue !== null && cellValue < minValue) { + // Only the min value is set and the cell value is less than the min value + return { + field, + message: `Value provided is less than the minimum value ${minValue}` + }; + } - if (minValue === null && maxValue === null) { - return null; - } + if (maxValue !== null && cellValue > maxValue) { + // Only the max value is set and the cell value is greater than the max value + return { + field, + message: `Value provided is greater than the maximum value ${maxValue}` + }; } - // Measurement values are invalid, create an error and return - return { - field, - message: `Value provided is outside of the valid range [${minValue}, ${maxValue}]` - }; + // The cell value is within the valid range or no range is set + return null; }; /** diff --git a/app/src/hooks/api/useObservationApi.ts b/app/src/hooks/api/useObservationApi.ts index e6564eadc6..b2b7542146 100644 --- a/app/src/hooks/api/useObservationApi.ts +++ b/app/src/hooks/api/useObservationApi.ts @@ -1,26 +1,34 @@ import { AxiosInstance, AxiosProgressEvent, CancelTokenSource } from 'axios'; + import { + IGetSurveyObservationsGeometryResponse, + IGetSurveyObservationsResponse, ObservationRecord, StandardObservationColumns, SupplementaryObservationCountData -} from 'contexts/observationsTableContext'; -import { - IGetSurveyObservationsGeometryResponse, - IGetSurveyObservationsResponse } from 'interfaces/useObservationApi.interface'; +import { EnvironmentTypeIds } from 'interfaces/useReferenceApi.interface'; import { ApiPaginationRequestOptions } from 'types/misc'; export interface SubcountToSave { observation_subcount_id: number | null; subcount: number | null; - qualitative: { + qualitative_measurements: { measurement_id: string; measurement_option_id: string; }[]; - quantitative: { + quantitative_measurements: { measurement_id: string; measurement_value: number; }[]; + qualitative_environments: { + environment_qualitative_id: string; + environment_qualitative_option_id: string; + }[]; + quantitative_environments: { + environment_quantitative_id: string; + value: number; + }[]; } export interface IObservationTableRowToSave { @@ -59,7 +67,7 @@ const useObservationApi = (axios: AxiosInstance) => { * @param {number} projectId * @param {number} surveyId * @param {ApiPaginationRequestOptions} [pagination] - * @return {*} {Promise} + * @return {*} {Promise} */ const getObservationRecords = async ( projectId: number, @@ -94,7 +102,7 @@ const useObservationApi = (axios: AxiosInstance) => { * @param {number} projectId * @param {number} surveyId * @param {ApiPaginationRequestOptions} [pagination] - * @return {*} {Promise} + * @return {*} {Promise} */ const getObservationRecord = async ( projectId: number, @@ -234,6 +242,30 @@ const useObservationApi = (axios: AxiosInstance) => { return data; }; + /** + * Deletes all of the observation environments, from all observation records, having the given environment_id. + * + * @param {number} projectId + * @param {number} surveyId + * @param {string[]} environmentIds The environment ids to delete. + * @return {*} {Promise} + */ + const deleteObservationEnvironments = async ( + projectId: number, + surveyId: number, + environmentIds: EnvironmentTypeIds + ): Promise => { + const { data } = await axios.post( + `/api/project/${projectId}/survey/${surveyId}/observations/environments/delete`, + { + environment_qualitative_id: environmentIds.qualitative_environments, + environment_quantitative_id: environmentIds.quantitative_environments + } + ); + + return data; + }; + return { insertUpdateObservationRecords, getObservationRecords, @@ -241,6 +273,7 @@ const useObservationApi = (axios: AxiosInstance) => { getObservationsGeometry, deleteObservationRecords, deleteObservationMeasurements, + deleteObservationEnvironments, uploadCsvForImport, processCsvSubmission }; diff --git a/app/src/hooks/api/useReferenceApi.ts b/app/src/hooks/api/useReferenceApi.ts new file mode 100644 index 0000000000..3bc44f35af --- /dev/null +++ b/app/src/hooks/api/useReferenceApi.ts @@ -0,0 +1,28 @@ +import { AxiosInstance } from 'axios'; +import { EnvironmentType } from 'interfaces/useReferenceApi.interface'; + +/** + * Returns a set of supported api methods for working with reference data. + * + * @param {AxiosInstance} axios + * @return {*} object whose properties are supported api methods. + */ +const useReferenceApi = (axios: AxiosInstance) => { + /** + * Finds subcount environments by search term. + * + * @param {string} searchTerm + * @return {*} {Promise} + */ + const findSubcountEnvironments = async (searchTerm: string): Promise => { + const { data } = await axios.get(`/api/reference/search/environment?searchTerm=${searchTerm}`); + + return data; + }; + + return { + findSubcountEnvironments + }; +}; + +export default useReferenceApi; diff --git a/app/src/hooks/useBioHubApi.ts b/app/src/hooks/useBioHubApi.ts index 456b73bcff..246aa00384 100644 --- a/app/src/hooks/useBioHubApi.ts +++ b/app/src/hooks/useBioHubApi.ts @@ -1,4 +1,5 @@ import axios from 'axios'; +import useReferenceApi from 'hooks/api/useReferenceApi'; import { useConfigContext } from 'hooks/useContext'; import { useMemo } from 'react'; import useAdminApi from './api/useAdminApi'; @@ -57,6 +58,8 @@ export const useBiohubApi = () => { const standards = useStandardsApi(apiAxios); + const reference = useReferenceApi(apiAxios); + return useMemo( () => ({ project, @@ -73,7 +76,8 @@ export const useBiohubApi = () => { spatial, funding, samplingSite, - standards + standards, + reference }), // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/app/src/interfaces/useObservationApi.interface.ts b/app/src/interfaces/useObservationApi.interface.ts index f7c0f14b6b..18ee4f4d19 100644 --- a/app/src/interfaces/useObservationApi.interface.ts +++ b/app/src/interfaces/useObservationApi.interface.ts @@ -1,4 +1,11 @@ -import { StandardObservationColumns, SupplementaryObservationData } from 'contexts/observationsTableContext'; +import { + CBQualitativeMeasurementTypeDefinition, + CBQuantitativeMeasurementTypeDefinition +} from 'interfaces/useCritterApi.interface'; +import { + EnvironmentQualitativeTypeDefinition, + EnvironmentQuantitativeTypeDefinition +} from 'interfaces/useReferenceApi.interface'; import { ApiPaginationResponseParams } from 'types/misc'; export interface IGetSurveyObservationsResponse { surveyObservations: ObservationRecordWithSamplingAndSubcountData[]; @@ -20,6 +27,50 @@ type ObservationSamplingData = { survey_sample_period_start_datetime: string | null; }; +export type StandardObservationColumns = { + survey_observation_id: number; + itis_tsn: number | null; + itis_scientific_name: string | null; + survey_sample_site_id: number | null; + survey_sample_method_id: number | null; + survey_sample_period_id: number | null; + count: number | null; + observation_date: Date; + observation_time: string; + latitude: number | null; + longitude: number | null; +}; + +export type SubcountObservationColumns = { + observation_subcount_id: number | null; + subcount: number | null; + qualitative_measurements: { + field: string; + critterbase_taxon_measurement_id: string; + critterbase_measurement_qualitative_option_id: string; + }[]; + quantitative_measurements: { + critterbase_taxon_measurement_id: string; + value: number; + }[]; + [key: string]: any; +}; + +export type ObservationRecord = StandardObservationColumns & SubcountObservationColumns; + +export type SupplementaryObservationCountData = { + observationCount: number; +}; + +export type SupplementaryObservationMeasurementData = { + qualitative_measurements: CBQualitativeMeasurementTypeDefinition[]; + quantitative_measurements: CBQuantitativeMeasurementTypeDefinition[]; + qualitative_environments: EnvironmentQualitativeTypeDefinition[]; + quantitative_environments: EnvironmentQuantitativeTypeDefinition[]; +}; + +export type SupplementaryObservationData = SupplementaryObservationCountData & SupplementaryObservationMeasurementData; + type ObservationSubCountQualitativeMeasurementRecord = { observation_subcount_id: number; critterbase_taxon_measurement_id: string; @@ -52,6 +103,40 @@ type ObservationSubcountQuantitativeMeasurementObject = Pick< 'critterbase_taxon_measurement_id' | 'value' >; +type ObservationSubCountQualitativeEnvironmentRecord = { + observation_subcount_qualitative_environment_id: number; + observation_subcount_id: number; + environment_qualitative_id: string; + environment_qualitative_option_id: string; + create_date: string; + create_user: number; + update_date: string | null; + update_user: number | null; + revision_count: number; +}; + +type ObservationSubCountQuantitativeEnvironmentRecord = { + observation_subcount_quantitative_environment_id: number; + observation_subcount_id: number; + environment_quantitative_id: string; + value: number; + create_date: string; + create_user: number; + update_date: string | null; + update_user: number | null; + revision_count: number; +}; + +type ObservationSubcountQualitativeEnvironmentObject = Pick< + ObservationSubCountQualitativeEnvironmentRecord, + 'environment_qualitative_id' | 'environment_qualitative_option_id' +>; + +type ObservationSubcountQuantitativeEnvironmentObject = Pick< + ObservationSubCountQuantitativeEnvironmentRecord, + 'observation_subcount_quantitative_environment_id' | 'environment_quantitative_id' | 'value' +>; + type ObservationSubcountRecord = { observation_subcount_id: number; survey_observation_id: number; @@ -68,6 +153,8 @@ type ObservationSubcountObject = { subcount: ObservationSubcountRecord['subcount']; qualitative_measurements: ObservationSubcountQualitativeMeasurementObject[]; quantitative_measurements: ObservationSubcountQuantitativeMeasurementObject[]; + qualitative_environments: ObservationSubcountQualitativeEnvironmentObject[]; + quantitative_environments: ObservationSubcountQuantitativeEnvironmentObject[]; }; type ObservationSubcountsObject = { diff --git a/app/src/interfaces/useReferenceApi.interface.ts b/app/src/interfaces/useReferenceApi.interface.ts new file mode 100644 index 0000000000..b4ab9f522c --- /dev/null +++ b/app/src/interfaces/useReferenceApi.interface.ts @@ -0,0 +1,49 @@ +/** + * A qualitative environment unit. + */ +export type EnvironmentUnit = 'millimeter' | 'centimeter' | 'meter' | 'milligram' | 'gram' | 'kilogram'; + +/** + * A quantitative environment type definition. + */ +export type EnvironmentQuantitativeTypeDefinition = { + environment_quantitative_id: string; + name: string; + description: string | null; + min: number | null; + max: number | null; + unit: EnvironmentUnit | null; +}; + +/** + * A qualitative environment option definition (ie. drop-down option). + */ +export type EnvironmentQualitativeOption = { + environment_qualitative_option_id: string; + environment_qualitative_id: string; + name: string; + description: string | null; +}; + +/** + * A qualitative environment type definition. + */ +export type EnvironmentQualitativeTypeDefinition = { + environment_qualitative_id: string; + name: string; + description: string | null; + options: EnvironmentQualitativeOption[]; +}; + +/** + * Mixed environment columns type definition. + */ +export type EnvironmentType = { + qualitative_environments: EnvironmentQualitativeTypeDefinition[]; + quantitative_environments: EnvironmentQuantitativeTypeDefinition[]; +}; + +export type EnvironmentTypeIds = { + qualitative_environments: EnvironmentQualitativeTypeDefinition['environment_qualitative_id'][]; + quantitative_environments: EnvironmentQuantitativeTypeDefinition['environment_quantitative_id'][]; +}; diff --git a/database/src/migrations/20240417000000_obsevation_environment_tables.ts b/database/src/migrations/20240417000000_obsevation_environment_tables.ts new file mode 100644 index 0000000000..05cfce503d --- /dev/null +++ b/database/src/migrations/20240417000000_obsevation_environment_tables.ts @@ -0,0 +1,283 @@ +import { Knex } from 'knex'; + +/** + * Create new tables: + * - environment_quantitative + * - environment_qualitative + * - environment_qualitative_option + * - observation_subcount_quantitative_environment + * - observation_subcount_qualitative_environment + * + * @export + * @param {Knex} knex + * @return {*} {Promise} + */ +export async function up(knex: Knex): Promise { + await knex.raw(` + ---------------------------------------------------------------------------------------- + -- Create environment lookup tables + ---------------------------------------------------------------------------------------- + + SET SEARCH_PATH=biohub, public; + + CREATE TYPE environment_unit AS ENUM ( + 'millimeter', + 'centimeter', + 'meter', + 'milligram', + 'gram', + 'kilogram', + 'percent', + 'celsius', + 'ppt', + 'SCF', + 'degrees', + 'pH' + ); + + ---------------------------------------------------------------------------------------- + + CREATE TABLE environment_quantitative ( + environment_quantitative_id uuid DEFAULT public.gen_random_uuid(), + name varchar(100) NOT NULL, + description varchar(250), + min numeric, + max numeric, + unit environment_unit, + 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 environment_quantitative_pk PRIMARY KEY (environment_quantitative_id) + ); + + COMMENT ON TABLE environment_quantitative IS 'Quantitative environment attributes.'; + COMMENT ON COLUMN environment_quantitative.environment_quantitative_id IS 'System generated surrogate primary key identifier.'; + COMMENT ON COLUMN environment_quantitative.name IS 'The name of the environment attribute.'; + COMMENT ON COLUMN environment_quantitative.description IS 'The description of the environment attribute.'; + COMMENT ON COLUMN environment_quantitative.min IS 'The minimum allowed value (inclusive).'; + COMMENT ON COLUMN environment_quantitative.max IS 'The maximum allowed value (inclusive).'; + COMMENT ON COLUMN environment_quantitative.unit IS 'The unit of measure for the value.'; + COMMENT ON COLUMN environment_quantitative.record_end_date IS 'Record level end date.'; + COMMENT ON COLUMN environment_quantitative.create_date IS 'The datetime the record was created.'; + COMMENT ON COLUMN environment_quantitative.create_user IS 'The id of the user who created the record as identified in the system user table.'; + COMMENT ON COLUMN environment_quantitative.update_date IS 'The datetime the record was updated.'; + COMMENT ON COLUMN environment_quantitative.update_user IS 'The id of the user who updated the record as identified in the system user table.'; + COMMENT ON COLUMN environment_quantitative.revision_count IS 'Revision count used for concurrency control.'; + + -- Add unique end-date key constraint + CREATE UNIQUE INDEX environment_quantitative_nuk1 ON environment_quantitative(name, (record_end_date IS NULL)) WHERE record_end_date IS NULL; + + ---------------------------------------------------------------------------------------- + + CREATE TABLE environment_qualitative ( + environment_qualitative_id uuid DEFAULT public.gen_random_uuid(), + name varchar(100) NOT NULL, + description varchar(400), + 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 environment_qualitative_pk PRIMARY KEY (environment_qualitative_id) + ); + + COMMENT ON TABLE environment_qualitative IS 'Qualitative environment attributes.'; + COMMENT ON COLUMN environment_qualitative.environment_qualitative_id IS 'System generated surrogate primary key identifier.'; + COMMENT ON COLUMN environment_qualitative.name IS 'The name of the environment attribute.'; + COMMENT ON COLUMN environment_qualitative.description IS 'The description of the environment attribute.'; + COMMENT ON COLUMN environment_qualitative.record_end_date IS 'Record level end date.'; + COMMENT ON COLUMN environment_qualitative.create_date IS 'The datetime the record was created.'; + COMMENT ON COLUMN environment_qualitative.create_user IS 'The id of the user who created the record as identified in the system user table.'; + COMMENT ON COLUMN environment_qualitative.update_date IS 'The datetime the record was updated.'; + COMMENT ON COLUMN environment_qualitative.update_user IS 'The id of the user who updated the record as identified in the system user table.'; + COMMENT ON COLUMN environment_qualitative.revision_count IS 'Revision count used for concurrency control.'; + + -- Add unique end-date key constraint + CREATE UNIQUE INDEX environment_qualitative_nuk1 ON environment_qualitative(name, (record_end_date IS NULL)) WHERE record_end_date IS NULL; + + ---------------------------------------------------------------------------------------- + + CREATE TABLE environment_qualitative_option ( + environment_qualitative_option_id uuid DEFAULT public.gen_random_uuid(), + environment_qualitative_id uuid NOT NULL, + name varchar(100) NOT NULL, + description varchar(400), + 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 environment_qualitative_option_pk PRIMARY KEY (environment_qualitative_option_id) + ); + + COMMENT ON TABLE environment_qualitative_option IS 'Quantitative environment attribute options.'; + COMMENT ON COLUMN environment_qualitative_option.environment_qualitative_option_id IS 'System generated surrogate primary key identifier.'; + COMMENT ON COLUMN environment_qualitative_option.environment_qualitative_id IS 'Foreign key to the environment_qualitative table.'; + COMMENT ON COLUMN environment_qualitative_option.name IS 'The name of the option.'; + COMMENT ON COLUMN environment_qualitative_option.description IS 'The description of the option.'; + COMMENT ON COLUMN environment_qualitative_option.record_end_date IS 'Record level end date.'; + COMMENT ON COLUMN environment_qualitative_option.create_date IS 'The datetime the record was created.'; + COMMENT ON COLUMN environment_qualitative_option.create_user IS 'The id of the user who created the record as identified in the system user table.'; + COMMENT ON COLUMN environment_qualitative_option.update_date IS 'The datetime the record was updated.'; + COMMENT ON COLUMN environment_qualitative_option.update_user IS 'The id of the user who updated the record as identified in the system user table.'; + COMMENT ON COLUMN environment_qualitative_option.revision_count IS 'Revision count used for concurrency control.'; + + -- Add unique end-date key constraint (don't allow 2 records with the same environment_qualitative_id and name and a NULL record_end_date) + CREATE UNIQUE INDEX environment_qualitative_option_nuk1 ON environment_qualitative_option(environment_qualitative_id, name, (record_end_date IS NULL)) WHERE record_end_date IS NULL; + + -- Add unique composite key constraint + ALTER TABLE environment_qualitative_option + ADD CONSTRAINT environment_qualitative_option_uk1 + UNIQUE (environment_qualitative_option_id, environment_qualitative_id); + + ---------------------------------------------------------------------------------------- + -- Create subocunt environment tables + ---------------------------------------------------------------------------------------- + + CREATE TABLE observation_subcount_quantitative_environment ( + observation_subcount_quantitative_environment_id integer GENERATED ALWAYS AS IDENTITY (START WITH 1 INCREMENT BY 1), + observation_subcount_id integer NOT NULL, + environment_quantitative_id uuid NOT NULL, + value numeric 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 observation_subcount_quantitative_environment_pk PRIMARY KEY (observation_subcount_quantitative_environment_id) + ); + + COMMENT ON TABLE observation_subcount_quantitative_environment IS 'This table is intended to track quantitative environments applied to a particular observation_subcount.'; + COMMENT ON COLUMN observation_subcount_quantitative_environment.observation_subcount_quantitative_environment_id IS 'System generated surrogate primary key identifier.'; + COMMENT ON COLUMN observation_subcount_quantitative_environment.observation_subcount_id IS 'Foreign key to the observation_subcount table.'; + COMMENT ON COLUMN observation_subcount_quantitative_environment.environment_quantitative_id IS 'Foreign key to the environment_quantitative table.'; + COMMENT ON COLUMN observation_subcount_quantitative_environment.value IS 'Quantitative data value.'; + COMMENT ON COLUMN observation_subcount_quantitative_environment.create_date IS 'The datetime the record was created.'; + COMMENT ON COLUMN observation_subcount_quantitative_environment.create_user IS 'The id of the user who created the record as identified in the system user table.'; + COMMENT ON COLUMN observation_subcount_quantitative_environment.update_date IS 'The datetime the record was updated.'; + COMMENT ON COLUMN observation_subcount_quantitative_environment.update_user IS 'The id of the user who updated the record as identified in the system user table.'; + COMMENT ON COLUMN observation_subcount_quantitative_environment.revision_count IS 'Revision count used for concurrency control.'; + + -- Add unique constraint + CREATE UNIQUE INDEX observation_subcount_quantitative_environment_uk1 ON observation_subcount_quantitative_environment(observation_subcount_id, environment_quantitative_id); + + -- Add foreign key constraint + ALTER TABLE observation_subcount_quantitative_environment + ADD CONSTRAINT observation_subcount_quantitative_environment_fk1 + FOREIGN KEY (observation_subcount_id) + REFERENCES observation_subcount(observation_subcount_id); + + ALTER TABLE observation_subcount_quantitative_environment + ADD CONSTRAINT observation_subcount_quantitative_environment_fk2 + FOREIGN KEY (environment_quantitative_id) + REFERENCES environment_quantitative(environment_quantitative_id); + + -- Add indexes for foreign keys + CREATE INDEX observation_subcount_quantitative_environment_idx1 ON observation_subcount_quantitative_environment(observation_subcount_id); + + CREATE INDEX observation_subcount_quantitative_environment_idx2 ON observation_subcount_quantitative_environment(environment_quantitative_id); + + ---------------------------------------------------------------------------------------- + + CREATE TABLE observation_subcount_qualitative_environment ( + observation_subcount_qualitative_environment_id integer GENERATED ALWAYS AS IDENTITY (START WITH 1 INCREMENT BY 1), + observation_subcount_id integer NOT NULL, + environment_qualitative_id uuid NOT NULL, + environment_qualitative_option_id uuid 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 observation_subcount_qualitative_environment_pk PRIMARY KEY (observation_subcount_qualitative_environment_id) + ); + + COMMENT ON TABLE observation_subcount_qualitative_environment IS 'This table is intended to track qualitative environments applied to a particular observation_subcount.'; + COMMENT ON COLUMN observation_subcount_qualitative_environment.observation_subcount_qualitative_environment_id IS 'System generated surrogate primary key identifier.'; + COMMENT ON COLUMN observation_subcount_qualitative_environment.observation_subcount_id IS 'Foreign key to the observation_subcount table.'; + COMMENT ON COLUMN observation_subcount_qualitative_environment.environment_qualitative_id IS 'Foreign key to the environment_qualitative table.'; + COMMENT ON COLUMN observation_subcount_qualitative_environment.environment_qualitative_option_id IS 'Foreign key to the environment_qualitative_option table.'; + COMMENT ON COLUMN observation_subcount_qualitative_environment.create_date IS 'The datetime the record was created.'; + COMMENT ON COLUMN observation_subcount_qualitative_environment.create_user IS 'The id of the user who created the record as identified in the system user table.'; + COMMENT ON COLUMN observation_subcount_qualitative_environment.update_date IS 'The datetime the record was updated.'; + COMMENT ON COLUMN observation_subcount_qualitative_environment.update_user IS 'The id of the user who updated the record as identified in the system user table.'; + COMMENT ON COLUMN observation_subcount_qualitative_environment.revision_count IS 'Revision count used for concurrency control.'; + + -- Add unique constraint + CREATE UNIQUE INDEX observation_subcount_qualitative_environment_uk1 ON observation_subcount_qualitative_environment(observation_subcount_id, environment_qualitative_id, environment_qualitative_option_id); + + -- Add foreign key constraint + ALTER TABLE observation_subcount_qualitative_environment + ADD CONSTRAINT observation_subcount_qualitative_environment_fk1 + FOREIGN KEY (observation_subcount_id) + REFERENCES observation_subcount(observation_subcount_id); + + ALTER TABLE observation_subcount_qualitative_environment + ADD CONSTRAINT observation_subcount_qualitative_environment_fk2 + FOREIGN KEY (environment_qualitative_id) + REFERENCES environment_qualitative(environment_qualitative_id); + + ALTER TABLE observation_subcount_qualitative_environment + ADD CONSTRAINT observation_subcount_qualitative_environment_fk3 + FOREIGN KEY (environment_qualitative_option_id) + REFERENCES environment_qualitative_option(environment_qualitative_option_id); + + -- Foreign key on both environment_qualitative_id and environment_qualitative_option_id of + -- environment_qualitative_option to ensure that the combination of those ids in this table has a valid match. + ALTER TABLE observation_subcount_qualitative_environment + ADD CONSTRAINT observation_subcount_qualitative_environment_fk4 + FOREIGN KEY (environment_qualitative_id, environment_qualitative_option_id) + REFERENCES environment_qualitative_option(environment_qualitative_id, environment_qualitative_option_id); + + -- Add indexes for foreign keys + CREATE INDEX observation_subcount_qualitative_environment_idx1 ON observation_subcount_qualitative_environment(observation_subcount_id); + + CREATE INDEX observation_subcount_qualitative_environment_idx2 ON observation_subcount_qualitative_environment(environment_qualitative_id); + + CREATE INDEX observation_subcount_qualitative_environment_idx3 ON observation_subcount_qualitative_environment(environment_qualitative_option_id); + + ---------------------------------------------------------------------------------------- + -- Create audit/journal triggers + ---------------------------------------------------------------------------------------- + + CREATE TRIGGER audit_environment_quantitative BEFORE INSERT OR UPDATE OR DELETE ON biohub.environment_quantitative FOR EACH ROW EXECUTE PROCEDURE tr_audit_trigger(); + CREATE TRIGGER journal_environment_quantitative AFTER INSERT OR UPDATE OR DELETE ON biohub.environment_quantitative FOR EACH ROW EXECUTE PROCEDURE tr_journal_trigger(); + + CREATE TRIGGER audit_environment_qualitative BEFORE INSERT OR UPDATE OR DELETE ON biohub.environment_qualitative FOR EACH ROW EXECUTE PROCEDURE tr_audit_trigger(); + CREATE TRIGGER journal_environment_qualitative AFTER INSERT OR UPDATE OR DELETE ON biohub.environment_qualitative FOR EACH ROW EXECUTE PROCEDURE tr_journal_trigger(); + + CREATE TRIGGER audit_environment_qualitative_option BEFORE INSERT OR UPDATE OR DELETE ON biohub.environment_qualitative_option FOR EACH ROW EXECUTE PROCEDURE tr_audit_trigger(); + CREATE TRIGGER journal_environment_qualitative_option AFTER INSERT OR UPDATE OR DELETE ON biohub.environment_qualitative_option FOR EACH ROW EXECUTE PROCEDURE tr_journal_trigger(); + + CREATE TRIGGER audit_observation_subcount_quantitative_environment BEFORE INSERT OR UPDATE OR DELETE ON biohub.observation_subcount_quantitative_environment FOR EACH ROW EXECUTE PROCEDURE tr_audit_trigger(); + CREATE TRIGGER journal_observation_subcount_quantitative_environment AFTER INSERT OR UPDATE OR DELETE ON biohub.observation_subcount_quantitative_environment FOR EACH ROW EXECUTE PROCEDURE tr_journal_trigger(); + + CREATE TRIGGER audit_observation_subcount_qualitative_environment BEFORE INSERT OR UPDATE OR DELETE ON biohub.observation_subcount_qualitative_environment FOR EACH ROW EXECUTE PROCEDURE tr_audit_trigger(); + CREATE TRIGGER journal_observation_subcount_qualitative_environment AFTER INSERT OR UPDATE OR DELETE ON biohub.observation_subcount_qualitative_environment FOR EACH ROW EXECUTE PROCEDURE tr_journal_trigger(); + + ---------------------------------------------------------------------------------------- + -- Create views + ---------------------------------------------------------------------------------------- + + SET SEARCH_PATH=biohub_dapi_v1; + + CREATE OR REPLACE VIEW environment_quantitative AS SELECT * FROM biohub.environment_quantitative; + + CREATE OR REPLACE VIEW environment_qualitative AS SELECT * FROM biohub.environment_qualitative; + + CREATE OR REPLACE VIEW environment_qualitative_option AS SELECT * FROM biohub.environment_qualitative_option; + + CREATE OR REPLACE VIEW observation_subcount_quantitative_environment AS SELECT * FROM biohub.observation_subcount_quantitative_environment; + + CREATE OR REPLACE VIEW observation_subcount_qualitative_environment AS SELECT * FROM biohub.observation_subcount_qualitative_environment; + `); +} + +export async function down(knex: Knex): Promise { + await knex.raw(``); +} diff --git a/database/src/migrations/20240417000001_populate_obsevation_environment_tables.ts b/database/src/migrations/20240417000001_populate_obsevation_environment_tables.ts new file mode 100644 index 0000000000..40dd3633e8 --- /dev/null +++ b/database/src/migrations/20240417000001_populate_obsevation_environment_tables.ts @@ -0,0 +1,2343 @@ +import { Knex } from 'knex'; + +/** + * Populate lookup values for the environment_quantitative, environment_qualitative, and + * environment_qualitative_option tables. + * + * @export + * @param {Knex} knex + * @return {*} {Promise} + */ +export async function up(knex: Knex): Promise { + await knex.raw(` + ---------------------------------------------------------------------------------------- + -- Populate lookup tables. + ---------------------------------------------------------------------------------------- + + SET SEARCH_PATH=biohub, public; + + INSERT INTO environment_quantitative + ( + name, + description, + min, + max, + unit + ) + VALUES + ( + 'Snow cover', + 'The percent snow cover.', + 0, + 100, + 'percent' + ), + ( + 'Air temperature', + 'The ambient air temperature.', + -50, + 200, + 'celsius' + ), + ( + 'Water temperature', + 'The water temperature.', + -50, + 200, + 'celsius' + ), + ( + 'Vegetation cover', + 'The percent vegetation cover.', + 0, + 100, + 'percent' + ), + ( + 'Previous 48 hour Air Temperature', + 'The air temperature during the previous 48 hours.', + -50, + 200, + 'celsius' + ), + ( + 'Rainfall over 24 hours', + 'The amount of rainfall that fell within the last 24 hours.', + 0, + 200, + 'millimeter' + ), + ( + 'Rainfall over 48 hours', + 'The amount of rain that fell within the last 48 hours.', + 0, + 400, + 'millimeter' + ), + ( + 'Sea Surface Temperature', + 'The sea water surface temperature.', + 0, + 30, + 'celsius' + ), + ( + 'Swell Height', + 'The current swell height.', + 0, + 20, + 'meter' + ), + ( + 'Sea Surface Salinity', + 'The sea water surface salinity.', + 32, + 38, + 'ppt' + ), + ( + 'Wavelet Height', + 'The current wavelet height.', + 0, + 100, + 'centimeter' + ), + ( + 'Ground Temperature', + 'The ground surface temperature.', + -50, + 100, + 'celsius' + ), + ( + 'Turbidity', + 'The turbidity of the water measured with a secchi disk or other instrument.', + 0, + 100, + 'centimeter' + ), + ( + 'Temperature Variance', + $$The water temperature variance.$$, + 0, + 50, + 'celsius' + ), + ( + 'Sightability Correction Factor', + 'Sightability Correction Factor (SCF) is a quantitative coefficient which is estimated or derived and applied to a sample-based count in order to adjust for visibility or sightability bias of the observers.', + 0, + 1, + 'SCF' + ), + ( + 'Elevation', + 'The elevation of the site.', + -1000, + 5000, + 'meter' + ), + ( + 'Slope', + 'The slope gradient.', + 0, + 100, + 'percent' + ), + ( + 'Aspect', + 'The orientation of the slope.', + 0, + 360, + 'degrees' + ), + ( + 'Rooting Zone Coarse Fragment', + 'The particle size distribution within the mineral portion of the rooting zone.', + 0, + 100, + 'percent' + ), + ( + 'Root Restiriction Depth', + 'The depth of the layer that restricts root penetration.', + 0, + 100, + 'centimeter' + ), + ( + 'Soil pH', + 'Concentration of hydrogen ions in the mineral soil.', + 0, + 14, + 'pH' + ), + ( + 'Crown Closure', + 'The percentage of the ground surface covered when the crowns are projected vertically.', + 0, + 100, + 'percent' + ), + ( + 'Percent Cover', + 'The percentage of the ground surface, of a plot or area occupied, covered when a species above-ground-vegetation is projected vertically onto the ground.', + 0, + 100, + 'percent' + ), + ( + 'Snow Cover', + 'Percentage indicating the extent of snow cover on the ground.', + 0, + 100, + 'percent' + ); + + ---------------------------------------------------------------------------------------- + + INSERT INTO environment_qualitative + ( + name, + description + ) + VALUES + ( + 'Wind direction', + 'The compass direction of the wind.' + ), + ( + 'Wind speed', + 'The wind speed category.' + ), + ( + 'Precipitation', + 'The precipitation category.' + ), + ( + 'Previous 48 hour Wind Speed', + 'The wind speed during the previous 48 hours.' + ), + ( + 'Previous 48 hour Precipitation', + 'The type of precipitation that occurred during the preceding 48 hours.' + ), + ( + 'Cloud Type', + 'The type of clouds e.g. ST.' + ), + ( + 'Cloud Cover', + 'The extent of cloud cover at the start, or end, of sampling.' + ), + ( + 'Cloud Ceiling', + 'The height of cloud cover relative to trees and ridges.' + ), + ( + 'Previous 48 hour Cloud Cover', + 'The extent of cloud cover during the previous 48 hours.' + ), + ( + 'Snow Depth', + 'The depth of snow.' + ), + ( + 'Snow Cover', + 'The extent of snow cover on the ground.' + ), + ( + 'Time Since 5cm Snow', + 'The number of days since 5 cm of snow fell.' + ), + ( + 'Sea Wind Condition', + 'The strength of the wind over the sea, using the beaufort scale.' + ), + ( + 'Tide Direction', + 'The tide direction.' + ), + ( + 'Ground Moisture', + 'Ground moisture class.' + ), + ( + 'Leaf Moisture', + 'Leaf moisture class.' + ), + ( + 'Lunar Phase', + 'Lunar phase class.' + ), + ( + 'Soil Moisture Regime', + 'The moisture class of the soil.' + ), + ( + 'Soil Nutrient Regime', + 'The nutrient class of the soil.' + ), + ( + 'Rooting Zone Soil Texture', + 'The size distribution of the primary mineral particles - 2mm diameter or less.' + ), + ( + 'Meso Slope Position', + 'The position of the site relative to the localized catchment area.' + ), + ( + 'Structual Stage', + 'The appearance of a stand or community using the characteristic life form and certain physical attributes.' + ), + ( + 'Terrain Texture 1 - Upper', + 'The 1st terrain texture of the upper stratigraphic layer.' + ), + ( + 'Terrain Texture 2 - Upper', + 'The 2nd terrain texture of the upper stratigraphic layer.' + ), + ( + 'Terrain Texture 3 - Upper', + 'The 3rd terrain texture of the upper stratigraphic layer.' + ), + ( + 'Surficial Material 1 - Upper', + 'The 1st surficial material of the upper stratigraphic layer.' + ), + ( + 'Surficial Material 2 - Upper', + 'The 2nd surficial material of the upper stratigraphic layer.' + ), + ( + 'Surficial Material 3 - Upper', + 'The 3rd surficial material of the upper stratigraphic layer.' + ), + ( + 'Surface Expression 1 - Upper', + 'The 1st surface expression of the upper stratigraphic layer.' + ), + ( + 'Surface Expression 2 - Upper', + 'The 2nd surface expression of the upper stratigraphic layer.' + ), + ( + 'Surface Expression 3 - Upper', + 'The 3rd surface expression of the upper stratigraphic layer.' + ), + ( + 'Geomorphological Process 1 - Upper', + 'The 1st geomorphological process of the upper stratigraphic layer.' + ), + ( + 'Geomorphological Process 2 - Upper', + 'The 2nd geomorphological process of the upper stratigraphic layer.' + ), + ( + 'Geomorphological Process 3 - Upper', + 'The 3rd geomorphological process of the upper stratigraphic layer.' + ), + ( + 'Soil Drainage', + 'The speed and extent to which water is removed from a mineral soil.' + ), + ( + 'Humus Form', + 'The structure of the humus.' + ), + ( + 'Root Restriction Layer', + 'The type of layer that prevents the penetration of roots.' + ), + ( + 'Vegetation Layer', + 'The vegetation layer that the plant species was found in.' + ); + + ---------------------------------------------------------------------------------------- + + INSERT INTO environment_qualitative_option + ( + environment_qualitative_id, + name, + description + ) + VALUES + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Wind direction'), + 'North', + 'North direction.' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Wind direction'), + 'Northeast', + 'Northeast direction.' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Wind direction'), + 'Northwest', + 'Northwest direction.' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Wind direction'), + 'East', + 'East direction.' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Wind direction'), + 'South', + 'South direction.' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Wind direction'), + 'Southeast', + 'Southeast direction.' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Wind direction'), + 'Southwest', + 'Southwest direction.' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Wind direction'), + 'West', + 'West direction.' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Wind speed'), + 'Still air', + 'No detectable wind (0-5 km/hour).' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Wind speed'), + 'Weak', + 'Leaves rustle (6-12 km/hour).' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Wind speed'), + 'Moderate', + 'Leaves and twigs constantly move (13-19 km/hour).' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Wind speed'), + 'Strong', + 'Small branches move, dust rises (20-29 km/hour).' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Wind speed'), + 'Very strong', + 'Small trees sway (30-39 km/hour).' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Wind speed'), + 'Intense', + 'Large branches moving, wind whistling (>39 km/hour).' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Precipitation'), + 'None', + 'No precipitation.' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Precipitation'), + 'Misty drizzle', + 'No distinct rain drops but can dampen clothing.' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Precipitation'), + 'Drizzle', + $$Fine rain drops (< 0.5 mm diameter), visible on ground.$$ + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Precipitation'), + 'Light Rain', + $$Puddles not forming quickly, < 2.5 mm rain/hour.$$ + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Precipitation'), + 'Heavy Rain', + $$Puddles form quickly, > 2.5 mm rain/hour.$$ + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Precipitation'), + 'Light snow', + 'Snow falling but not accumulating.' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Precipitation'), + 'Heavy snow', + 'Snow accumulating on the ground.' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Precipitation'), + 'Hail', + 'Solid ice falling.' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Previous 48 hour Wind Speed'), + 'Prev - Calm', + $$Less than 2 km/h.$$ + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Previous 48 hour Wind Speed'), + 'Prev - Light Air', + $$2-5 km/h.$$ + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Previous 48 hour Wind Speed'), + 'Prev - Light Breeze', + $$Leaves rustle (6-12km/h).$$ + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Previous 48 hour Wind Speed'), + 'Prev Gentle Breeze', + $$Leaves and twigs constantly move (13-19 km/h).$$ + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Previous 48 hour Wind Speed'), + 'Prev Moderate Breeze', + 'Small branches move, dust rises (20 - 29 km/h).' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Previous 48 hour Wind Speed'), + 'Prev Fresh Breeze', + 'Small trees sway (30 - 39 km/h).' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Previous 48 hour Wind Speed'), + 'Prev Strong Breeze', + 'Large branches moving wind whistling (40 - 50 km/h).' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Previous 48 hour Precipitation'), + 'Prev No Precipitation', + 'No precipitation.' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Previous 48 hour Precipitation'), + 'Prev Foggy', + 'Reduced visibility like a cloud.' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Previous 48 hour Precipitation'), + 'Prev Misty Drizzle', + 'No distinct rain drops but can dampen clothing.' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Previous 48 hour Precipitation'), + 'Prev Drizzle', + 'Finee rain drops <0.5mm diameter visible on ground.' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Previous 48 hour Precipitation'), + 'Prev Light Rain', + 'Puddles not forming quickly < 2.5mm rain per hour.' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Previous 48 hour Precipitation'), + 'Prev Hard Rain', + 'Puddles form quickly > 2.5mm rain per hour.' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Previous 48 hour Precipitation'), + 'Previous Snow', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Previous 48 hour Precipitation'), + 'Previous Snow - Light', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Previous 48 hour Precipitation'), + 'Previous Snow - Heavy', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Cloud Type'), + 'Stratus', + 'Low continuous-cover clouds.' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Cloud Type'), + 'Nimbostratus', + 'Low heavy rain clouds.' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Cloud Type'), + 'Stratocumulus', + 'Low fluffy clouds.' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Cloud Type'), + 'Cumulus', + 'Big tall fluffy clouds.' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Cloud Type'), + 'Altocumulus', + 'Mid altitude fluffy clouds.' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Cloud Type'), + 'Atostratus', + 'Mid altitude continuous clouds.' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Cloud Type'), + 'Cirrocumulus', + 'High altitude bands of fluffy clouds.' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Cloud Type'), + 'Cirrus', + 'Very high altitude wispy clouds.' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Cloud Cover'), + 'Clear', + 'Clear sky no clouds.' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Cloud Cover'), + 'Scattered (<50%)', + 'Scattered clouds covering less than 50% of the sky.' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Cloud Cover'), + 'Scattered (>50%)', + 'Scattered clouds covering more than 50% of the sky.' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Cloud Cover'), + 'Unbroken Clouds', + 'Unbroken cloud cover.' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Cloud Ceiling'), + 'Very High', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Cloud Ceiling'), + 'High', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Cloud Ceiling'), + 'Above Ridge Tops', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Cloud Ceiling'), + 'Below Ridge Tops', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Cloud Ceiling'), + 'Above Tree Tops', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Cloud Ceiling'), + 'Below Tree Tops', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Previous 48 hour Cloud Cover'), + 'Clear', + 'Clear sky; no clouds.' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Previous 48 hour Cloud Cover'), + 'Scattered (<50%)', + 'Scattered clouds covering less than 50% of sky.' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Previous 48 hour Cloud Cover'), + 'Scattered (>50%)', + 'Scattered clouds covering more than 50% of sky.' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Previous 48 hour Cloud Cover'), + 'Unbroken clouds', + 'Unbroken cloud cover.' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Snow Depth'), + '0cm', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Snow Depth'), + '1-5cm', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Snow Depth'), + '6-25cm', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Snow Depth'), + '26-50cm', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Snow Depth'), + '51-75cm', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Snow Depth'), + '76-100cm', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Snow Depth'), + '101-150cm', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Snow Depth'), + '>150cm', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Snow Cover'), + '0%', + '0% of the ground covered.' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Snow Cover'), + '1-5%', + '1-5% of ground covered.' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Snow Cover'), + '6-25%', + '6-25% of the ground covered.' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Snow Cover'), + '26-50%', + '26-50% of the ground covered.' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Snow Cover'), + '51-75%', + '51-75% of the ground covered.' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Snow Cover'), + '76-100%', + '76-100% of the ground covered.' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Time Since 5cm Snow'), + '< 1/2 day', + 'Less than half a day since it snowed last.' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Time Since 5cm Snow'), + '< 3 days', + 'Less than 3 days since it snowed last.' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Time Since 5cm Snow'), + '< 14 days', + 'Less than 14 days since it last snowed.' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Time Since 5cm Snow'), + 'NR', + 'Not recorded because information is no value.' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Sea Wind Condition'), + 'Calm', + 'Calm 0-1 knots, sea like a mirror.' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Sea Wind Condition'), + 'Light Air', + 'Light Air, 1-3 knots, 1/4 ft waves, ripples with appearance of scales, no foam crests.' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Sea Wind Condition'), + 'Light Breeze', + 'Light Breeze, 4-6 knots, 1/3 ft. waves, small wavelets, crests of glassy appearance not breaking.' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Sea Wind Condition'), + 'Gentle Breeze', + 'Gentle Breeze, 7-10 knots, 2 ft. waves, large wavelets, crests begin to break, scattered whitecaps.' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Sea Wind Condition'), + 'Moderate Breeze', + 'Moderate Breeze, 11-16 knots, 4 ft waves, small waves, becoming longer, numerous whitecaps.' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Sea Wind Condition'), + 'Fresh Breeze', + 'Fresh Breeze, 17-21 knots, 16 ft waves, moderate waves, taking longer form, many whitecaps, some spray.' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Sea Wind Condition'), + 'Strong Breeze', + 'Strong Breeze, 22-27 knots, 10 ft waves, longer waves forming, whitecaps everywhere, more spray.' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Sea Wind Condition'), + 'Near Gale', + 'Near Gale, 28-32 knots, 14 ft waves.' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Sea Wind Condition'), + 'Gale', + 'Gale, 34-40 knots, 18 ft waves.' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Sea Wind Condition'), + 'Strong Gale', + 'Strong Gale, 41-47 knots, 23 ft waves.' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Sea Wind Condition'), + 'Storm', + 'Storm, 48-55 knots, 29 ft waves' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Sea Wind Condition'), + 'Violent Storm', + 'Violent Storm, 53-63 knots, 37 ft waves' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Sea Wind Condition'), + 'Hurricane', + 'Hurricane, 64-71 knots, 45 ft waves.' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Tide Direction'), + 'High', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Tide Direction'), + 'Intermediate Ebb', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Tide Direction'), + 'Intermediate Flood', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Tide Direction'), + 'Low', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Ground Moisture'), + 'Dry', + 'No apparent moisture on ground/vegetation. Surface litter is dry and will not stain fingers when rubbed.' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Ground Moisture'), + 'Moist', + 'Moisture is not apparent on ground/vegetation, but soil is moist. Surface litter will stain fingers when rubbed but no water is apparent when soil/litter is squeezed.' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Ground Moisture'), + 'Wet', + 'Moisture is apparent on ground/vegetation water is observed if soil/litter is squeezed.' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Leaf Moisture'), + 'Dry', + 'No moisture nor droplets detected on leaves surfaces.' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Leaf Moisture'), + 'Moist', + 'Moisture and/or droplets detected on leaves surfaces.' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Lunar Phase'), + 'New Moon', + 'The moon is dark. Also called dark moon.' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Lunar Phase'), + 'Waxing Crescent', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Lunar Phase'), + 'First Quarter', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Lunar Phase'), + 'Waxing Gibbous', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Lunar Phase'), + 'Full Moon', + 'The entire illuminated portion of the moon is visible.' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Lunar Phase'), + 'Waning Gibbous', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Lunar Phase'), + 'Third Quarter', + 'Also called half moon, and is waning.' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Lunar Phase'), + 'Waning Crescent', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Soil Moisture Regime'), + 'Very xeric', + 'Water supply removed very rapidly in relation to supply. Soil is moist for a negligible time after precipitation.' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Soil Moisture Regime'), + 'Xeric', + 'Water removed very rapidly in relation to supply; soilis moidt for brief periods following precipitation.' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Soil Moisture Regime'), + 'Subxeric', + 'Water removed rapidly in relation to supply; soil is moist for short periods following precipitation.' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Soil Moisture Regime'), + 'Submesic', + 'Water removed readily in relation to supply; water available for moderately short periods following precipitation.' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Soil Moisture Regime'), + 'Mesic', + 'Water removed somewhat slowly in relation to supply; soil may remain moist for a significant, but sometimes short period of the year. Available soil moisture reflects climatic inputs.' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Soil Moisture Regime'), + 'Subhygric', + 'Water removed slowly enough to keep soil wet for a significant part of growing season; some temporary seepage and possibly mottling below 20 cm.' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Soil Moisture Regime'), + 'Hygric', + 'Water removed slowly enough to keep soil wet for most of growing season; permanent seepage and mottling; gleyed colours common.' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Soil Moisture Regime'), + 'Subhydric', + 'Water removed slowly enough to keep water table at or near surface for most of year; gleyed mineral or organic soils; permanent seepage < 30 cm below surface.' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Soil Moisture Regime'), + 'Hydric', + 'Water removed so slowly that water table is at or above soil surface all year; gleyed mineral or organic soils.' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Rooting Zone Soil Texture'), + 'Clayey', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Rooting Zone Soil Texture'), + 'Loamy', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Rooting Zone Soil Texture'), + 'Organic', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Rooting Zone Soil Texture'), + 'Sandy', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Rooting Zone Soil Texture'), + 'Silty', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Meso Slope Position'), + 'Crest', + 'The generally convex uppermost portion of a hill; usually convex in all directions with no distinct aspect.' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Meso Slope Position'), + 'Upper slope', + 'The generally convex upper portion of the slope immediately below the crest of a hill; has a specific aspect.' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Meso Slope Position'), + 'Middle slope', + 'Area between the upper and lower slope; the surface profile is generally neither distinctly concave nor convex; has a straight or somewhat sigmoid surface profile with a specific aspect.' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Meso Slope Position'), + 'Lower Slope', + 'The area toward the base of a slope; generally has a concave surface profile with a specific aspect.' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Meso Slope Position'), + 'Toe', + 'The area demarcated from the lower slope by an abrupt decrease in slope gradient; seepage is typically present.' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Meso Slope Position'), + 'Depression', + 'Any area concave in all directions; may be at the base of a mesoscale slope or in a generally level area.' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Meso Slope Position'), + 'Level', + 'Any level meso-scale area not immediately adjacent to a meso-scale slope; the surface profile is generally horizontal and straight with no significant aspect.' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Meso Slope Position'), + 'Gully', + 'An area in a double toe slope position where the receiving area is also sloped (perpendicular to the toe slopes).' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Structual Stage'), + 'Non-vegetated/sparse', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Structual Stage'), + 'Non-vegetated', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Structual Stage'), + 'Sparse', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Structual Stage'), + 'Bryoid', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Structual Stage'), + 'Herb', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Structual Stage'), + 'Forb-dominated', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Structual Stage'), + 'Graminoid-dominated', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Structual Stage'), + 'Aquatic', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Structual Stage'), + 'Dwarf shrub', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Structual Stage'), + 'Shrub/herb', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Structual Stage'), + 'Low shrub', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Structual Stage'), + 'Tall shrub', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Structual Stage'), + 'Pole/Sapling', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Structual Stage'), + 'Young Forest', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Structual Stage'), + 'Mature Forest', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Structual Stage'), + 'Old Forest', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Terrain Texture 1 - Upper'), + 'Blocks', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Terrain Texture 1 - Upper'), + 'Boulders', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Terrain Texture 1 - Upper'), + 'Clay', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Terrain Texture 1 - Upper'), + 'Mixed fragments', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Terrain Texture 1 - Upper'), + 'Fabric', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Terrain Texture 1 - Upper'), + 'Gravel', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Terrain Texture 1 - Upper'), + 'Humic', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Terrain Texture 1 - Upper'), + 'Cobble', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Terrain Texture 1 - Upper'), + 'Mud', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Terrain Texture 1 - Upper'), + 'Pebbles', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Terrain Texture 1 - Upper'), + 'Rubble', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Terrain Texture 1 - Upper'), + 'Sand', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Terrain Texture 1 - Upper'), + 'Mesic', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Terrain Texture 1 - Upper'), + 'Angular', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Terrain Texture 1 - Upper'), + 'Shells', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Terrain Texture 1 - Upper'), + 'Silt', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Terrain Texture 2 - Upper'), + 'Blocks', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Terrain Texture 2 - Upper'), + 'Boulders', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Terrain Texture 2 - Upper'), + 'Clay', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Terrain Texture 2 - Upper'), + 'Mixed fragments', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Terrain Texture 2 - Upper'), + 'Fabric', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Terrain Texture 2 - Upper'), + 'Gravel', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Terrain Texture 2 - Upper'), + 'Humic', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Terrain Texture 2 - Upper'), + 'Cobble', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Terrain Texture 2 - Upper'), + 'Mud', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Terrain Texture 2 - Upper'), + 'Pebbles', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Terrain Texture 2 - Upper'), + 'Rubble', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Terrain Texture 2 - Upper'), + 'Sand', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Terrain Texture 2 - Upper'), + 'Mesic', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Terrain Texture 2 - Upper'), + 'Angular', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Terrain Texture 2 - Upper'), + 'Shells', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Terrain Texture 2 - Upper'), + 'Silt', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Terrain Texture 3 - Upper'), + 'Blocks', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Terrain Texture 3 - Upper'), + 'Boulders', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Terrain Texture 3 - Upper'), + 'Clay', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Terrain Texture 3 - Upper'), + 'Mixed fragments', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Terrain Texture 3 - Upper'), + 'Fabric', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Terrain Texture 3 - Upper'), + 'Gravel', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Terrain Texture 3 - Upper'), + 'Humic', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Terrain Texture 3 - Upper'), + 'Cobble', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Terrain Texture 3 - Upper'), + 'Mud', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Terrain Texture 3 - Upper'), + 'Pebbles', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Terrain Texture 3 - Upper'), + 'Rubble', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Terrain Texture 3 - Upper'), + 'Sand', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Terrain Texture 3 - Upper'), + 'Mesic', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Terrain Texture 3 - Upper'), + 'Angular', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Terrain Texture 3 - Upper'), + 'Shells', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Terrain Texture 3 - Upper'), + 'Silt', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Surficial Material 1 - Upper'), + 'Anthopogenic', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Surficial Material 1 - Upper'), + 'Colluvium', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Surficial Material 1 - Upper'), + 'Weathered bedrock', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Surficial Material 1 - Upper'), + 'Eolian', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Surficial Material 1 - Upper'), + 'Fluvial', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Surficial Material 1 - Upper'), + 'Glaciofluvial', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Surficial Material 1 - Upper'), + 'Ice', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Surficial Material 1 - Upper'), + 'Lacustrine', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Surficial Material 1 - Upper'), + 'Glaciolacustrine', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Surficial Material 1 - Upper'), + 'Morainal', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Surficial Material 1 - Upper'), + 'Organic', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Surficial Material 1 - Upper'), + 'Bedrock', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Surficial Material 1 - Upper'), + 'Undifferentiated', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Surficial Material 1 - Upper'), + 'Volcanic', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Surficial Material 1 - Upper'), + 'Marine', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Surficial Material 1 - Upper'), + 'Glaciomarine', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Surficial Material 2 - Upper'), + 'Anthopogenic', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Surficial Material 2 - Upper'), + 'Colluvium', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Surficial Material 2 - Upper'), + 'Weathered bedrock', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Surficial Material 2 - Upper'), + 'Eolian', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Surficial Material 2 - Upper'), + 'Fluvial', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Surficial Material 2 - Upper'), + 'Glaciofluvial', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Surficial Material 2 - Upper'), + 'Ice', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Surficial Material 2 - Upper'), + 'Lacustrine', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Surficial Material 2 - Upper'), + 'Glaciolacustrine', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Surficial Material 2 - Upper'), + 'Morainal', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Surficial Material 2 - Upper'), + 'Organic', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Surficial Material 2 - Upper'), + 'Bedrock', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Surficial Material 2 - Upper'), + 'Undifferentiated', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Surficial Material 2 - Upper'), + 'Volcanic', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Surficial Material 2 - Upper'), + 'Marine', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Surficial Material 2 - Upper'), + 'Glaciomarine', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Surficial Material 3 - Upper'), + 'Anthopogenic', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Surficial Material 3 - Upper'), + 'Colluvium', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Surficial Material 3 - Upper'), + 'Weathered bedrock', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Surficial Material 3 - Upper'), + 'Eolian', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Surficial Material 3 - Upper'), + 'Fluvial', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Surficial Material 3 - Upper'), + 'Glaciofluvial', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Surficial Material 3 - Upper'), + 'Ice', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Surficial Material 3 - Upper'), + 'Lacustrine', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Surficial Material 3 - Upper'), + 'Glaciolacustrine', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Surficial Material 3 - Upper'), + 'Morainal', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Surficial Material 3 - Upper'), + 'Organic', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Surficial Material 3 - Upper'), + 'Bedrock', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Surficial Material 3 - Upper'), + 'Undifferentiated', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Surficial Material 3 - Upper'), + 'Volcanic', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Surficial Material 3 - Upper'), + 'Marine', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Surficial Material 3 - Upper'), + 'Glaciomarine', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Surface Expression 1 - Upper'), + 'Moderate Slope', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Surface Expression 1 - Upper'), + 'Blanket', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Surface Expression 1 - Upper'), + 'Cone(s)', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Surface Expression 1 - Upper'), + 'Depression(s)', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Surface Expression 1 - Upper'), + 'Fan(s)', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Surface Expression 1 - Upper'), + 'Hummock(s)', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Surface Expression 1 - Upper'), + 'Gentle slope', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Surface Expression 1 - Upper'), + 'Moderately steep slope', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Surface Expression 1 - Upper'), + 'Rolling', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Surface Expression 1 - Upper'), + 'Plain', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Surface Expression 1 - Upper'), + 'Ridge(s)', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Surface Expression 1 - Upper'), + 'Steep slope', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Surface Expression 1 - Upper'), + 'Terrace(s)', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Surface Expression 1 - Upper'), + 'Undulating', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Surface Expression 1 - Upper'), + 'Veneer', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Surface Expression 1 - Upper'), + 'Mantle of variable thickness', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Surface Expression 1 - Upper'), + 'Thin Veneer', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Surface Expression 2 - Upper'), + 'Moderate Slope', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Surface Expression 2 - Upper'), + 'Blanket', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Surface Expression 2 - Upper'), + 'Cone(s)', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Surface Expression 2 - Upper'), + 'Depression(s)', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Surface Expression 2 - Upper'), + 'Fan(s)', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Surface Expression 2 - Upper'), + 'Hummock(s)', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Surface Expression 2 - Upper'), + 'Gentle slope', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Surface Expression 2 - Upper'), + 'Moderately steep slope', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Surface Expression 2 - Upper'), + 'Rolling', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Surface Expression 2 - Upper'), + 'Plain', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Surface Expression 2 - Upper'), + 'Ridge(s)', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Surface Expression 2 - Upper'), + 'Steep slope', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Surface Expression 2 - Upper'), + 'Terrace(s)', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Surface Expression 2 - Upper'), + 'Undulating', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Surface Expression 2 - Upper'), + 'Veneer', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Surface Expression 2 - Upper'), + 'Mantle of variable thickness', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Surface Expression 2 - Upper'), + 'Thin Veneer', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Surface Expression 3 - Upper'), + 'Moderate Slope', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Surface Expression 3 - Upper'), + 'Blanket', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Surface Expression 3 - Upper'), + 'Cone(s)', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Surface Expression 3 - Upper'), + 'Depression(s)', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Surface Expression 3 - Upper'), + 'Fan(s)', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Surface Expression 3 - Upper'), + 'Hummock(s)', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Surface Expression 3 - Upper'), + 'Gentle slope', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Surface Expression 3 - Upper'), + 'Moderately steep slope', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Surface Expression 3 - Upper'), + 'Rolling', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Surface Expression 3 - Upper'), + 'Plain', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Surface Expression 3 - Upper'), + 'Ridge(s)', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Surface Expression 3 - Upper'), + 'Steep slope', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Surface Expression 3 - Upper'), + 'Terrace(s)', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Surface Expression 3 - Upper'), + 'Undulating', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Surface Expression 3 - Upper'), + 'Veneer', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Surface Expression 3 - Upper'), + 'Mantle of variable thickness', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Surface Expression 3 - Upper'), + 'Thin Veneer', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Geomorphological Process 1 - Upper'), + 'Avalanches', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Geomorphological Process 1 - Upper'), + 'Braiding', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Geomorphological Process 1 - Upper'), + 'Cryoturbation', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Geomorphological Process 1 - Upper'), + 'Deflation', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Geomorphological Process 1 - Upper'), + 'Channeled', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Geomorphological Process 1 - Upper'), + 'Slow Mass', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Geomorphological Process 1 - Upper'), + 'Kettle', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Geomorphological Process 1 - Upper'), + 'Irregular channel', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Geomorphological Process 1 - Upper'), + 'Anastamosing channel', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Geomorphological Process 1 - Upper'), + 'Karst', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Geomorphological Process 1 - Upper'), + 'Surface seepage', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Geomorphological Process 1 - Upper'), + 'Meandering channels', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Geomorphological Process 1 - Upper'), + 'Nivation', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Geomorphological Process 1 - Upper'), + 'Piping', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Geomorphological Process 1 - Upper'), + 'Rapid mass movement', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Geomorphological Process 1 - Upper'), + 'Solifluction', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Geomorphological Process 1 - Upper'), + 'Inundation', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Geomorphological Process 1 - Upper'), + 'Gully erosion', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Geomorphological Process 1 - Upper'), + 'Washing', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Geomorphological Process 1 - Upper'), + 'Permafrost', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Geomorphological Process 1 - Upper'), + 'Periglacial processes', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Geomorphological Process 2 - Upper'), + 'Avalanches', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Geomorphological Process 2 - Upper'), + 'Braiding', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Geomorphological Process 2 - Upper'), + 'Cryoturbation', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Geomorphological Process 2 - Upper'), + 'Deflation', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Geomorphological Process 2 - Upper'), + 'Channeled', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Geomorphological Process 2 - Upper'), + 'Slow Mass', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Geomorphological Process 2 - Upper'), + 'Kettle', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Geomorphological Process 2 - Upper'), + 'Irregular channel', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Geomorphological Process 2 - Upper'), + 'Anastamosing channel', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Geomorphological Process 2 - Upper'), + 'Karst', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Geomorphological Process 2 - Upper'), + 'Surface seepage', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Geomorphological Process 2 - Upper'), + 'Meandering channels', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Geomorphological Process 2 - Upper'), + 'Nivation', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Geomorphological Process 2 - Upper'), + 'Piping', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Geomorphological Process 2 - Upper'), + 'Rapid mass movement', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Geomorphological Process 2 - Upper'), + 'Solifluction', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Geomorphological Process 2 - Upper'), + 'Inundation', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Geomorphological Process 2 - Upper'), + 'Gully erosion', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Geomorphological Process 2 - Upper'), + 'Washing', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Geomorphological Process 2 - Upper'), + 'Permafrost', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Geomorphological Process 2 - Upper'), + 'Periglacial processes', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Geomorphological Process 3 - Upper'), + 'Avalanches', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Geomorphological Process 3 - Upper'), + 'Braiding', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Geomorphological Process 3 - Upper'), + 'Cryoturbation', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Geomorphological Process 3 - Upper'), + 'Deflation', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Geomorphological Process 3 - Upper'), + 'Channeled', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Geomorphological Process 3 - Upper'), + 'Slow Mass', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Geomorphological Process 3 - Upper'), + 'Kettle', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Geomorphological Process 3 - Upper'), + 'Irregular channel', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Geomorphological Process 3 - Upper'), + 'Anastamosing channel', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Geomorphological Process 3 - Upper'), + 'Karst', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Geomorphological Process 3 - Upper'), + 'Surface seepage', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Geomorphological Process 3 - Upper'), + 'Meandering channels', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Geomorphological Process 3 - Upper'), + 'Nivation', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Geomorphological Process 3 - Upper'), + 'Piping', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Geomorphological Process 3 - Upper'), + 'Rapid mass movement', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Geomorphological Process 3 - Upper'), + 'Solifluction', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Geomorphological Process 3 - Upper'), + 'Inundation', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Geomorphological Process 3 - Upper'), + 'Gully erosion', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Geomorphological Process 3 - Upper'), + 'Washing', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Geomorphological Process 3 - Upper'), + 'Permafrost', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Soil Drainage'), + 'Very rapidly drained', + 'Water is removed from the soil so slowly that the water table remains at or near the surface for most of the time the soil is not frozen. Groundwater flow and subsurface flow are the major water sources. Precipitation is less important, except where there is a perched water table with precipitation exceeding evapotranspiration. Typically associated with wetlands.' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Soil Drainage'), + 'Rapidly drained', + 'Water is removed from the soil so slowly that the water table remains at or near the surface for most of the time the soil is not frozen. Groundwater flow and subsurface flow are the major water sources. Precipitation is less important, except where there is a perched water table with precipitation exceeding evapotranspiration. Typically associated with wetlands.' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Soil Drainage'), + 'Well drained', + 'Water is removed from the soil so slowly that the water table remains at or near the surface for most of the time the soil is not frozen. Groundwater flow and subsurface flow are the major water sources. Precipitation is less important, except where there is a perched water table with precipitation exceeding evapotranspiration. Typically associated with wetlands.' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Soil Drainage'), + 'Moderately well drained', + 'Water is removed from the soil somewhat slowly in relation to supply because of imperviousness or lack of gradient. Precipitation is the dominant water source in medium- to fine- textured soils; precipitation and significant additions by subsurface flow are necessary in coarse-textured soils.' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Soil Drainage'), + 'Imperfectly drained', + 'Water is removed from the soil sufficiently slowly in relation to supply to keep the soil wet for a significant part of the growing season. Excess water moves slowly downward if precipitation is the major source. If subsurface water or groundwater (or both) is the main source, the flow rate may vary but the soil remains wet for a significant part of the growing season.' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Soil Drainage'), + 'Poorly drained', + 'Water is removed so slowly in relation to supply that the soil remains wet for much of the time that it is not frozen. Excess water is evident in the soil for a large part of the time. Subsurface or groundwater flow (or both), in addition to precipitation, are the main water sources. A perched water table may be present. Soils are generally mottled and/or gleyed.' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Soil Drainage'), + 'Very poorly drained', + 'Water is removed so slowly in relation to supply that the soil remains wet for much of the time that it is not frozen. Excess water is evident in the soil for a large part of the time. Subsurface or groundwater flow (or both), in addition to precipitation, are the main water sources. A perched water table may be present. Soils are generally mottled and/or gleyed.' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Humus Form'), + 'Mor', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Humus Form'), + 'Hemimor', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Humus Form'), + 'Humimor', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Humus Form'), + 'Resimor', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Humus Form'), + 'Lignomor', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Humus Form'), + 'Hydromor', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Humus Form'), + 'Fibrimor', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Humus Form'), + 'Mesimor', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Humus Form'), + 'Moder', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Humus Form'), + 'Mormoder', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Humus Form'), + 'Leptomoder', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Humus Form'), + 'Mullmoder', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Humus Form'), + 'Lignomoder', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Humus Form'), + 'Hydromoder', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Humus Form'), + 'Saprimoder', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Humus Form'), + 'Mull', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Humus Form'), + 'Vermimull', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Humus Form'), + 'Rhizomull', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Humus Form'), + 'Hydromull', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Root Restriction Layer'), + 'Strongly cemented horizon', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Root Restriction Layer'), + 'Clay pan or restriction due to fines', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Root Restriction Layer'), + 'Compacted morainal material', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Root Restriction Layer'), + 'Lithic Contact', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Root Restriction Layer'), + 'Excessive Moisture', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Root Restriction Layer'), + 'Excessive accumulation of chemicals', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Root Restriction Layer'), + 'Permafrost', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Root Restriction Layer'), + 'No root restriction evident', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Vegetation Layer'), + 'Tree layer', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Vegetation Layer'), + 'Shrub layer', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Vegetation Layer'), + 'Herb layer', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Vegetation Layer'), + 'Moss layer', + '' + ), + ( + (SELECT environment_qualitative_id FROM environment_qualitative WHERE name = 'Vegetation Layer'), + 'Epiphyte layer', + '' + ) + ; + `); +} + +export async function down(knex: Knex): Promise { + await knex.raw(``); +} diff --git a/database/src/seeds/01_db_system_users.ts b/database/src/seeds/01_db_system_users.ts index 67ac390ac1..c78ce33b5a 100644 --- a/database/src/seeds/01_db_system_users.ts +++ b/database/src/seeds/01_db_system_users.ts @@ -138,6 +138,16 @@ const systemUsers: SystemUserSeed[] = [ given_name: 'Macgregor', family_name: 'Aubertin-Young', email: 'macgregor.aubertin-young@gov.bc.ca' + }, + { + identifier: 'anthomps', + type: SYSTEM_IDENTITY_SOURCE.IDIR, + role_name: SYSTEM_USER_ROLE_NAME.SYSTEM_ADMINISTRATOR, + user_guid: '543C3CE2F4DE472DB3A569FD0024B244', + display_name: 'Thompson, Andrew WLRS:EX', + given_name: 'Andrew', + family_name: 'Thompson', + email: 'andrew.thompson@gov.bc.ca' } ]; From 566cc77c628369eaf2eba4325691d15967f6e449 Mon Sep 17 00:00:00 2001 From: Mac Deluca <99926243+MacQSL@users.noreply.github.com> Date: Fri, 31 May 2024 09:00:35 -0700 Subject: [PATCH 14/31] SIMSBIOHUB-542 Project Regions (#1290) - Survey study areas now map to NRM regions for project list --- .../repositories/region-repository.test.ts | 27 +++- api/src/repositories/region-repository.ts | 84 +++++++++++ .../survey-location-repository.ts | 40 ++++-- api/src/services/region-service.test.ts | 34 +++-- api/src/services/region-service.ts | 43 +++--- api/src/services/survey-service.test.ts | 9 +- api/src/services/survey-service.ts | 32 ++--- .../components/data-grid/StyledDataGrid.tsx | 5 +- app/src/constants/regions.ts | 38 +++++ .../projects/create/CreateProjectPage.tsx | 1 + .../projects/list/ProjectsListPage.tsx | 19 ++- .../create/form/SamplingMethodForm.tsx | 136 +++++++++--------- .../20240524000000_spatial_optimizations.ts | 39 +++++ 13 files changed, 374 insertions(+), 133 deletions(-) create mode 100644 app/src/constants/regions.ts create mode 100644 database/src/migrations/20240524000000_spatial_optimizations.ts diff --git a/api/src/repositories/region-repository.test.ts b/api/src/repositories/region-repository.test.ts index 112c691c7f..2518713f0a 100644 --- a/api/src/repositories/region-repository.test.ts +++ b/api/src/repositories/region-repository.test.ts @@ -5,7 +5,7 @@ import sinon from 'sinon'; import sinonChai from 'sinon-chai'; import { ApiExecuteSQLError } from '../errors/api-error'; import { getMockDBConnection } from '../__mocks__/db'; -import { RegionRepository } from './region-repository'; +import { RegionRepository, REGION_FEATURE_CODE } from './region-repository'; chai.use(sinonChai); @@ -122,4 +122,29 @@ describe('RegionRepository', () => { } }); }); + + describe('getIntersectingRegionsFromFeatures', () => { + it('should run without issue', async () => { + const mockDBConnection = getMockDBConnection(); + const repo = new RegionRepository(mockDBConnection); + const knexStub = sinon.stub(mockDBConnection, 'knex').returns({ rows: [true] } as any); + + const response = await repo.getIntersectingRegionsFromFeatures([], REGION_FEATURE_CODE.WHSE_ADMIN_BOUNDARY); + expect(knexStub).to.be.called; + expect(response).to.eql([true]); + }); + + it('should throw an error when SQL fails', async () => { + const mockDBConnection = getMockDBConnection(); + const repo = new RegionRepository(mockDBConnection); + sinon.stub(mockDBConnection, 'knex').throws(); + + try { + await repo.getIntersectingRegionsFromFeatures([], REGION_FEATURE_CODE.WHSE_ADMIN_BOUNDARY); + expect.fail(); + } catch (error) { + expect((error as ApiExecuteSQLError).message).to.be.eql('Failed to execute get intersecting regions SQL'); + } + }); + }); }); diff --git a/api/src/repositories/region-repository.ts b/api/src/repositories/region-repository.ts index e1b3efe235..747145db59 100644 --- a/api/src/repositories/region-repository.ts +++ b/api/src/repositories/region-repository.ts @@ -1,10 +1,19 @@ +import { Feature } from 'geojson'; import SQL from 'sql-template-strings'; import { z } from 'zod'; import { getKnex } from '../database/db'; import { ApiExecuteSQLError } from '../errors/api-error'; import { RegionDetails } from '../services/bcgw-layer-service'; +import { getLogger } from '../utils/logger'; import { BaseRepository } from './base-repository'; +const defaultLog = getLogger('region-repository'); + +export const enum REGION_FEATURE_CODE { + NATURAL_RESOURCE_REGION = 'FM90000010', // NRM + WHSE_ADMIN_BOUNDARY = 'AR10100000' // ENV +} + export const IRegion = z.object({ region_id: z.number(), region_name: z.string(), @@ -18,7 +27,14 @@ export const IRegion = z.object({ geojson: z.any() }); +/** + * Excluding spatial fields for performance + * + */ +export const RegionNoSpatial = IRegion.omit({ geometry: true, geography: true, geojson: true }); + export type IRegion = z.infer; +export type IRegionNoSpatial = z.infer; /** * A repository class for accessing region data. @@ -110,4 +126,72 @@ export class RegionRepository extends BaseRepository { ]); } } + + /** + * Get intersecting regions from a list of features + * Optionally provide feature code to filter available intersections + * + * @async + * @param {Feature[]} features - List of geometry features + * @param {REGION_FEATURE_CODE} [featureCode] - Feature code of region lookup table + * @returns {Promise} - List of regions + */ + async getIntersectingRegionsFromFeatures( + features: Feature[], + featureCode?: REGION_FEATURE_CODE + ): Promise { + const knex = getKnex(); + const queryBuilder = knex + .queryBuilder() + .select('region_id', 'region_name', 'org_unit', 'org_unit_name', 'feature_code', 'feature_name', 'object_id') + .from('region_lookup'); + + /** + * Optional filter for region lookup table + * example: only NRM regions + */ + if (featureCode) { + queryBuilder.where({ feature_code: featureCode }); + } + + /** + * From a list of features return intersecting lookup regions + */ + queryBuilder.andWhere((query) => { + for (const feature of features) { + const regionName = feature?.properties?.['REGION_NAME']; + /** + * note: If a spatial (bcgw) layer is selected, the ST_Intersects will return all surrounding layers. + * This will try and query the region_lookup table using the region name instead. + */ + if (regionName) { + query.orWhereILike({ region_name: regionName }); + } else { + query.orWhereRaw(`public.ST_Intersects(public.ST_GeomFromGeoJSON(?), geometry)`, [ + JSON.stringify(feature.geometry) // geometry needs to be stringified before PostGis can use + ]); + } + } + }); + + try { + const response = await this.connection.knex(queryBuilder); + + /** + * Additional logging incase region mapping not fully successfull + */ + if (features.length !== response.rowCount) { + defaultLog.info({ + label: 'getIntersectingRegionsFromFeatures', + message: 'unable to map a feature to NRM region -> less regions than features' + }); + } + + return response.rows; + } catch (error) { + throw new ApiExecuteSQLError('Failed to execute get intersecting regions SQL', [ + 'RegionRepository->getIntersectingRegionsFromFeatures' + ]); + } + } } diff --git a/api/src/repositories/survey-location-repository.ts b/api/src/repositories/survey-location-repository.ts index e4b02c0c1b..581b71d272 100644 --- a/api/src/repositories/survey-location-repository.ts +++ b/api/src/repositories/survey-location-repository.ts @@ -29,11 +29,11 @@ export class SurveyLocationRepository extends BaseRepository { const sqlStatement = SQL` INSERT INTO survey_location ( survey_id, - name, - description, + name, + description, geojson, geography - ) + ) VALUES ( ${surveyId}, ${data.name}, @@ -56,9 +56,9 @@ export class SurveyLocationRepository extends BaseRepository { */ async updateSurveyLocation(data: PostSurveyLocationData): Promise { const sqlStatement = SQL` - UPDATE + UPDATE survey_location - SET + SET name = ${data.name}, description = ${data.description}, geojson = ${JSON.stringify(data.geojson)}, @@ -67,7 +67,7 @@ export class SurveyLocationRepository extends BaseRepository { public.ST_SetSRID(`.append(generateGeometryCollectionSQL(data.geojson)).append(`, 4326) ) ) - WHERE + WHERE survey_location_id = ${data.survey_location_id}; `); @@ -83,15 +83,15 @@ export class SurveyLocationRepository extends BaseRepository { */ async getSurveyLocationsData(surveyId: number): Promise { const sqlStatement = SQL` - SELECT - survey_id, + SELECT + survey_id, survey_location_id, - name, - description, + name, + description, geometry, geography, - geojson, - revision_count + geojson, + revision_count FROM survey_location WHERE @@ -106,12 +106,24 @@ export class SurveyLocationRepository extends BaseRepository { * Deletes a survey location for a given survey location id * * @param surveyLocationId - * @returns {*} Promise + * @returns {*} Promise * @memberof SurveyLocationRepository */ async deleteSurveyLocation(surveyLocationId: number): Promise { const sql = SQL` - DELETE FROM survey_location WHERE survey_location_id = ${surveyLocationId} RETURNING *;`; + DELETE FROM survey_location + WHERE + survey_location_id = ${surveyLocationId} + RETURNING + survey_location_id, + survey_id, + name, + description, + geometry, + geography, + geojson, + revision_count; + `; const response = await this.connection.sql(sql, SurveyLocationRecord); if (!response?.rowCount) { diff --git a/api/src/services/region-service.test.ts b/api/src/services/region-service.test.ts index 21a1b12a9b..f8f1325945 100644 --- a/api/src/services/region-service.test.ts +++ b/api/src/services/region-service.test.ts @@ -2,7 +2,7 @@ import chai, { expect } from 'chai'; import { describe } from 'mocha'; import sinon from 'sinon'; import sinonChai from 'sinon-chai'; -import { RegionRepository } from '../repositories/region-repository'; +import { IRegion, RegionRepository, REGION_FEATURE_CODE } from '../repositories/region-repository'; import { getMockDBConnection } from '../__mocks__/db'; import { BcgwLayerService } from './bcgw-layer-service'; import { RegionService } from './region-service'; @@ -14,6 +14,17 @@ describe('RegionRepository', () => { sinon.restore(); }); + describe('constructor', () => { + it('should initialize all dependencies', () => { + const mockDBConnection = getMockDBConnection(); + const service = new RegionService(mockDBConnection); + + expect(service.connection).to.eql(mockDBConnection); + expect(service.regionRepository).to.be.instanceof(RegionRepository); + expect(service.bcgwLayerService).to.be.instanceof(BcgwLayerService); + }); + }); + describe('searchRegionWithDetails', () => { it('should run without issue', async () => { const mockDBConnection = getMockDBConnection(); @@ -56,7 +67,7 @@ describe('RegionRepository', () => { const service = new RegionService(mockDBConnection); const addStub = sinon.stub(RegionRepository.prototype, 'addRegionsToSurvey').resolves(); - await service.addRegionsToSurvey(1, []); + await service.refreshSurveyRegions(1, []); expect(addStub).to.be.called; }); }); @@ -74,6 +85,7 @@ describe('RegionRepository', () => { const response = await service.getUniqueRegionsForFeatures([]); expect(search).to.be.called; + expect(search).to.be.calledWith([], mockDBConnection); expect(response[0]).to.be.eql({ regionName: 'cool name', sourceLayer: 'BCGW:Layer' @@ -81,19 +93,21 @@ describe('RegionRepository', () => { }); }); - describe('addRegionsToSurveyFromFeatures', () => { + describe('insertRegionsIntoSurveyFromFeatures', () => { it('should run without issue', async () => { const mockDBConnection = getMockDBConnection(); const service = new RegionService(mockDBConnection); - const getUniqueStub = sinon.stub(RegionService.prototype, 'getUniqueRegionsForFeatures').resolves(); - const searchStub = sinon.stub(RegionService.prototype, 'searchRegionWithDetails').resolves(); - const addRegionStub = sinon.stub(RegionService.prototype, 'addRegionsToSurvey').resolves(); - await service.addRegionsToSurveyFromFeatures(1, []); + const getIntersectingRegions = sinon + .stub(RegionRepository.prototype, 'getIntersectingRegionsFromFeatures') + .resolves([{ region_id: 1 }] as unknown as IRegion[]); + + const refreshSurveyRegionsStub = sinon.stub(RegionService.prototype, 'refreshSurveyRegions').resolves(); + + await service.insertRegionsIntoSurveyFromFeatures(1, []); - expect(getUniqueStub).to.be.called; - expect(searchStub).to.be.called; - expect(addRegionStub).to.be.called; + expect(getIntersectingRegions).to.be.calledWith([], REGION_FEATURE_CODE.NATURAL_RESOURCE_REGION); + expect(refreshSurveyRegionsStub).to.be.calledWith(1, [1]); }); }); }); diff --git a/api/src/services/region-service.ts b/api/src/services/region-service.ts index 36927365f3..4a365cdc32 100644 --- a/api/src/services/region-service.ts +++ b/api/src/services/region-service.ts @@ -1,31 +1,40 @@ import { Feature } from 'geojson'; import { IDBConnection } from '../database/db'; -import { IRegion, RegionRepository } from '../repositories/region-repository'; +import { IRegion, RegionRepository, REGION_FEATURE_CODE } from '../repositories/region-repository'; import { BcgwLayerService, RegionDetails } from './bcgw-layer-service'; import { DBService } from './db-service'; export class RegionService extends DBService { regionRepository: RegionRepository; + bcgwLayerService: BcgwLayerService; constructor(connection: IDBConnection) { super(connection); this.regionRepository = new RegionRepository(connection); + this.bcgwLayerService = new BcgwLayerService(); } /** - * Adds regions to a given survey based on a list of features. - * This function will fist find a unique list of region details and use that list of details - * to search the region lookup table for corresponding regions, then links regions to the survey + * Adds NRM regions to a given survey based on a list of features, + * business requires all features to be mapped to an intersecting NRM regions. + * Note: This method will delete all regions in the survey before adding the new regions. * * @param {number} surveyId * @param {Feature[]} features + * @returns {Promise} */ - async addRegionsToSurveyFromFeatures(surveyId: number, features: Feature[]): Promise { - const regionDetails = await this.getUniqueRegionsForFeatures(features); - const regions: IRegion[] = await this.searchRegionWithDetails(regionDetails); + async insertRegionsIntoSurveyFromFeatures(surveyId: number, features: Feature[]): Promise { + // Find intersecting NRM regions from list of features + const regions = await this.regionRepository.getIntersectingRegionsFromFeatures( + features, + REGION_FEATURE_CODE.NATURAL_RESOURCE_REGION // Optional filter + ); - await this.addRegionsToSurvey(surveyId, regions); + const regionIds = regions.map((region) => region.region_id); + + // Delete the previous regions and insert new + await this.refreshSurveyRegions(surveyId, regionIds); } /** @@ -35,22 +44,24 @@ export class RegionService extends DBService { * @returns {*} {Promise} */ async getUniqueRegionsForFeatures(features: Feature[]): Promise { - const bcgwService = new BcgwLayerService(); - return bcgwService.getUniqueRegionsForFeatures(features, this.connection); + return this.bcgwLayerService.getUniqueRegionsForFeatures(features, this.connection); } /** - * Links a given survey to a list of given regions. To avoid conflict - * all currently linked regions are removed before regions are linked + * Links a given survey to a list of regions. + * To avoid conflict all regions of the survey are removed before regions are re-inserted. + * Why? Regions are not currently added via a UI select control, instead they are inferred from the + * `Survey Study Area Map`. The regions should be an exhaustive list of what was included on the map. * * @param {number} surveyId - * @param {IRegion[]} regions + * @param {number[]} regionIds - region lookup ids + * @returns {Promise} */ - async addRegionsToSurvey(surveyId: number, regions: IRegion[]): Promise { - // remove existing regions from a survey + async refreshSurveyRegions(surveyId: number, regionIds: number[]): Promise { + // remove existing regions from the survey await this.regionRepository.deleteRegionsForSurvey(surveyId); - const regionIds = regions.map((item) => item.region_id); + // add regions back to survey await this.regionRepository.addRegionsToSurvey(surveyId, regionIds); } diff --git a/api/src/services/survey-service.test.ts b/api/src/services/survey-service.test.ts index 71a5c69869..1fca184b50 100644 --- a/api/src/services/survey-service.test.ts +++ b/api/src/services/survey-service.test.ts @@ -1,5 +1,6 @@ import chai, { expect } from 'chai'; import { Feature } from 'geojson'; +import { flatten } from 'lodash'; import { describe } from 'mocha'; import { QueryResult } from 'pg'; import sinon from 'sinon'; @@ -31,6 +32,7 @@ import { getMockDBConnection } from '../__mocks__/db'; import { HistoryPublishService } from './history-publish-service'; import { PermitService } from './permit-service'; import { PlatformService } from './platform-service'; +import { RegionService } from './region-service'; import { SiteSelectionStrategyService } from './site-selection-strategy-service'; import { SurveyBlockService } from './survey-block-service'; import { SurveyLocationService } from './survey-location-service'; @@ -152,7 +154,6 @@ describe('SurveyService', () => { const updateSurveyProprietorDataStub = sinon .stub(SurveyService.prototype, 'updateSurveyProprietorData') .resolves(); - const insertRegionStub = sinon.stub(SurveyService.prototype, 'insertRegion').resolves(); const upsertSurveyParticipantDataStub = sinon .stub(SurveyService.prototype, 'upsertSurveyParticipantData') .resolves(); @@ -177,7 +178,6 @@ describe('SurveyService', () => { expect(updateSurveyPermitDataStub).not.to.have.been.called; expect(upsertSurveyFundingSourceDataStub).to.have.been.calledOnce; expect(updateSurveyProprietorDataStub).not.to.have.been.called; - expect(insertRegionStub).not.to.have.been.called; expect(upsertSurveyParticipantDataStub).not.to.have.been.called; expect(updateSurveyStratumsStub).not.to.have.been.called; expect(insertUpdateDeleteSurveyLocationStub).not.to.have.been.called; @@ -1047,7 +1047,7 @@ describe('SurveyService', () => { const insertSurveyLocationsStub = sinon.stub(service, 'insertSurveyLocations').resolves(); const updateSurveyLocationStub = sinon.stub(service, 'updateSurveyLocation').resolves(); const deleteSurveyLocationStub = sinon.stub(service, 'deleteSurveyLocation').resolves(existingLocationsMock[1]); - const insertRegionStub = sinon.stub(service, 'insertRegion').resolves(); + const insertRegionsStub = sinon.stub(RegionService.prototype, 'insertRegionsIntoSurveyFromFeatures').resolves(); const surveyId = 20; const data: PostSurveyLocationData[] = [ @@ -1085,8 +1085,7 @@ describe('SurveyService', () => { revision_count: 0 }); expect(deleteSurveyLocationStub).to.be.calledOnceWith(40); - expect(insertRegionStub).to.be.calledWith(surveyId, geoJson2); // from inserts - expect(insertRegionStub).to.be.calledWith(surveyId, geoJson1); // from updates + expect(insertRegionsStub).to.be.calledWith(surveyId, flatten([geoJson1, geoJson2])); }); }); diff --git a/api/src/services/survey-service.ts b/api/src/services/survey-service.ts index 44bb039833..36902bf16a 100644 --- a/api/src/services/survey-service.ts +++ b/api/src/services/survey-service.ts @@ -1,4 +1,4 @@ -import { Feature } from 'geojson'; +import { flatten } from 'lodash'; import { IDBConnection } from '../database/db'; import { PostProprietorData, PostSurveyObject } from '../models/survey-create'; import { PostSurveyLocationData, PutPartnershipsData, PutSurveyObject } from '../models/survey-update'; @@ -40,6 +40,7 @@ export class SurveyService extends DBService { fundingSourceService: FundingSourceService; siteSelectionStrategyService: SiteSelectionStrategyService; surveyParticipationService: SurveyParticipationService; + regionService: RegionService; constructor(connection: IDBConnection) { super(connection); @@ -51,6 +52,7 @@ export class SurveyService extends DBService { this.fundingSourceService = new FundingSourceService(connection); this.siteSelectionStrategyService = new SiteSelectionStrategyService(connection); this.surveyParticipationService = new SurveyParticipationService(connection); + this.regionService = new RegionService(connection); } /** @@ -425,7 +427,8 @@ export class SurveyService extends DBService { // Insert survey locations promises.push(Promise.all(postSurveyData.locations.map((item) => this.insertSurveyLocations(surveyId, item)))); // Insert survey regions - promises.push(Promise.all(postSurveyData.locations.map((item) => this.insertRegion(surveyId, item.geojson)))); + const features = flatten(postSurveyData.locations.map((location) => location.geojson)); + promises.push(this.regionService.insertRegionsIntoSurveyFromFeatures(surveyId, features)); } // Handle site selection strategies @@ -482,19 +485,6 @@ export class SurveyService extends DBService { return service.upsertSurveyBlocks(surveyId, blocks); } - /** - * Insert region data. - * - * @param {number} surveyId - * @param {Feature[]} features - * @return {*} {Promise} - * @memberof SurveyService - */ - async insertRegion(surveyId: number, features: Feature[]): Promise { - const regionService = new RegionService(this.connection); - return regionService.addRegionsToSurveyFromFeatures(surveyId, features); - } - /** * Get survey attachments data for a given survey ID * @@ -724,13 +714,15 @@ export class SurveyService extends DBService { const updates = data.filter((item) => item.survey_location_id); const updatePromises = updates.map((item) => this.updateSurveyLocation(item)); - // Patch survey locations - await Promise.all([insertPromises, updatePromises, deletePromises]); + const features = flatten(data.map((item) => item.geojson)); - // Patch survey regions await Promise.all([ - ...inserts.map((item) => this.insertRegion(surveyId, item.geojson)), - ...updates.map((item) => this.insertRegion(surveyId, item.geojson)) + // Patch survey locations + insertPromises, + updatePromises, + deletePromises, + // Insert regions into survey - maps to NRM regions + this.regionService.insertRegionsIntoSurveyFromFeatures(surveyId, features) ]); } diff --git a/app/src/components/data-grid/StyledDataGrid.tsx b/app/src/components/data-grid/StyledDataGrid.tsx index 4321fbb2be..63987b3f6e 100644 --- a/app/src/components/data-grid/StyledDataGrid.tsx +++ b/app/src/components/data-grid/StyledDataGrid.tsx @@ -49,7 +49,10 @@ export const StyledDataGrid = (props: StyledD }, '& .MuiDataGrid-columnHeader:last-of-type, .MuiDataGrid-cell:last-of-type': { pr: 2 - } + }, + '&.MuiDataGrid-root--densityCompact .MuiDataGrid-cell': { py: '8px' }, + '&.MuiDataGrid-root--densityStandard .MuiDataGrid-cell': { py: '15px' }, + '&.MuiDataGrid-root--densityComfortable .MuiDataGrid-cell': { py: '22px' } }} /> ); diff --git a/app/src/constants/regions.ts b/app/src/constants/regions.ts new file mode 100644 index 0000000000..8451623cff --- /dev/null +++ b/app/src/constants/regions.ts @@ -0,0 +1,38 @@ +import blue from '@mui/material/colors/blue'; +import blueGrey from '@mui/material/colors/blueGrey'; +import brown from '@mui/material/colors/brown'; +import deepPurple from '@mui/material/colors/deepPurple'; +import orange from '@mui/material/colors/orange'; +import pink from '@mui/material/colors/pink'; +import red from '@mui/material/colors/red'; +import teal from '@mui/material/colors/teal'; +/** + * `Natural Resource Regions` appended text + * ie: `Cariboo Natural Resource Region` + * + */ +export const NRM_REGION_APPENDED_TEXT = ' Natural Resource Region'; + +/** + * Used to colour region chips. + * + */ +export const NRM_REGION_COLOUR_MAP = { + 'Kootenay-Boundary Natural Resource Region': blueGrey, + 'Thompson-Okanagan Natural Resource Region': orange, + 'West Coast Natural Resource Region': teal, + 'Cariboo Natural Resource Region': deepPurple, + 'South Coast Natural Resource Region': blue, + 'Northeast Natural Resource Region': brown, + 'Omineca Natural Resource Region': pink, + 'Skeena Natural Resource Region': red +}; + +/** + * Helper function to retrive nrm region colour + * @param {string} region - name of region + * @returns {*} mui colour object + */ +export const getNrmRegionColour = (region: string) => { + return NRM_REGION_COLOUR_MAP[region as keyof typeof NRM_REGION_COLOUR_MAP] ?? blueGrey; +}; diff --git a/app/src/features/projects/create/CreateProjectPage.tsx b/app/src/features/projects/create/CreateProjectPage.tsx index 769e83a883..cf3154928d 100644 --- a/app/src/features/projects/create/CreateProjectPage.tsx +++ b/app/src/features/projects/create/CreateProjectPage.tsx @@ -137,6 +137,7 @@ const CreateProjectPage = () => { } setEnableCancelCheck(false); + history.push(`/admin/projects/${response.id}`); } finally { setIsSaving(false); diff --git a/app/src/features/projects/list/ProjectsListPage.tsx b/app/src/features/projects/list/ProjectsListPage.tsx index e02b8515e9..a35054bac4 100644 --- a/app/src/features/projects/list/ProjectsListPage.tsx +++ b/app/src/features/projects/list/ProjectsListPage.tsx @@ -7,15 +7,18 @@ import Container from '@mui/material/Container'; import Divider from '@mui/material/Divider'; import Link from '@mui/material/Link'; import Paper from '@mui/material/Paper'; +import Stack from '@mui/material/Stack'; import Toolbar from '@mui/material/Toolbar'; import Typography from '@mui/material/Typography'; import { GridColDef, GridPaginationModel, GridSortModel } from '@mui/x-data-grid'; +import ColouredRectangleChip from 'components/chips/ColouredRectangleChip'; import { StyledDataGrid } from 'components/data-grid/StyledDataGrid'; import PageHeader from 'components/layout/PageHeader'; import { IProjectAdvancedFilters } from 'components/search-filter/ProjectAdvancedFilters'; import { SystemRoleGuard } from 'components/security/Guards'; import { DATE_FORMAT } from 'constants/dateTimeFormats'; import { ListProjectsI18N } from 'constants/i18n'; +import { getNrmRegionColour, NRM_REGION_APPENDED_TEXT } from 'constants/regions'; import { SYSTEM_ROLE } from 'constants/roles'; import { APIError } from 'hooks/api/useAxios'; import { useBiohubApi } from 'hooks/useBioHubApi'; @@ -116,7 +119,18 @@ const ProjectsListPage = () => { { field: 'regions', headerName: 'Regions', - flex: 1 + type: 'string', + flex: 1, + renderCell: (params) => ( + + {params.row.regions.map((region) => { + const label = region.replace(NRM_REGION_APPENDED_TEXT, ''); + return ( + + ); + })} + + ) }, { field: 'start_date', @@ -207,6 +221,9 @@ const ProjectsListPage = () => { 'auto'} + getEstimatedRowHeight={() => 500} rows={projectRows} rowCount={projectsDataLoader.data?.pagination.total ?? 0} getRowId={(row) => row.project_id} diff --git a/app/src/features/surveys/observations/sampling-sites/create/form/SamplingMethodForm.tsx b/app/src/features/surveys/observations/sampling-sites/create/form/SamplingMethodForm.tsx index 9eca9aec1b..f6aed740f7 100644 --- a/app/src/features/surveys/observations/sampling-sites/create/form/SamplingMethodForm.tsx +++ b/app/src/features/surveys/observations/sampling-sites/create/form/SamplingMethodForm.tsx @@ -142,75 +142,81 @@ const SamplingMethodForm = () => { {errors.sample_methods} )} - - {values.sample_methods.map((item, index) => ( - - - - {getCodesName(codesContext.codesDataLoader.data, 'sample_methods', item.method_lookup_id || 0)} - + + + {values.sample_methods.map((item, index) => ( + + + {getCodesName( codesContext.codesDataLoader.data, - 'method_response_metrics', - item.method_response_metric_id || 0 + 'sample_methods', + item.method_lookup_id || 0 )} - - - } - action={ - ) => - handleMenuClick(event, index) - } - aria-label="settings"> - - - } - /> - - - {item.description && ( - - {item.description} - - )} - - - Periods - - - - + + {getCodesName( + codesContext.codesDataLoader.data, + 'method_response_metrics', + item.method_response_metric_id || 0 + )} + + + } + action={ + ) => + handleMenuClick(event, index) + } + aria-label="settings"> + + + } + /> + + + {item.description && ( + + {item.description} + + )} + + + Periods + + + + + - - - - - - ))} + + + + + ))} + + + )} + /> + ); +}; diff --git a/app/src/features/surveys/animals/animal-form/components/ecological-units/components/EcologicalUnitsOptionSelect.tsx b/app/src/features/surveys/animals/animal-form/components/ecological-units/components/EcologicalUnitsOptionSelect.tsx new file mode 100644 index 0000000000..162b3e07fa --- /dev/null +++ b/app/src/features/surveys/animals/animal-form/components/ecological-units/components/EcologicalUnitsOptionSelect.tsx @@ -0,0 +1,58 @@ +import AutocompleteField, { IAutocompleteFieldOption } from 'components/fields/AutocompleteField'; +import { useFormikContext } from 'formik'; +import { ICreateEditAnimalRequest } from 'interfaces/useCritterApi.interface'; + +interface IEcologicalUnitsOptionSelectProps { + /** + * The label to display for the select field. + * + * @type {string} + * @memberof IEcologicalUnitsOptionSelectProps + */ + label: string; + /** + * List of options to display in the select field. + * + * @type {IAutocompleteFieldOption[]} + * @memberof IEcologicalUnitsOptionSelectProps + */ + options: IAutocompleteFieldOption[]; + /** + * The index of the component in the list. + * + * @type {number} + * @memberof IEcologicalUnitsOptionSelectProps + */ + index: number; +} + +/** + * Returns a component for selecting ecological (ie. collection) unit options for a given ecological unit. + * + * @param {IEcologicalUnitsOptionSelectProps} props + * @return {*} + */ +export const EcologicalUnitsOptionSelect = (props: IEcologicalUnitsOptionSelectProps) => { + const { label, options, index } = props; + + const { values, setFieldValue } = useFormikContext(); + + return ( + { + if (option?.value) { + setFieldValue(`ecological_units.[${index}].collection_unit_id`, option.value); + } + }} + disabled={Boolean(!values.ecological_units[index]?.collection_category_id)} + required + sx={{ + flex: '1 1 auto' + }} + /> + ); +}; diff --git a/app/src/features/surveys/animals/animal-form/components/ecological-units/components/EcologicalUnitsSelect.tsx b/app/src/features/surveys/animals/animal-form/components/ecological-units/components/EcologicalUnitsSelect.tsx new file mode 100644 index 0000000000..da703dbe1f --- /dev/null +++ b/app/src/features/surveys/animals/animal-form/components/ecological-units/components/EcologicalUnitsSelect.tsx @@ -0,0 +1,130 @@ +import { mdiClose } from '@mdi/js'; +import { Icon } from '@mdi/react'; +import Card from '@mui/material/Card'; +import grey from '@mui/material/colors/grey'; +import IconButton from '@mui/material/IconButton'; +import Stack from '@mui/material/Stack'; +import AutocompleteField from 'components/fields/AutocompleteField'; +import { FieldArrayRenderProps, useFormikContext } from 'formik'; +import { useCritterbaseApi } from 'hooks/useCritterbaseApi'; +import useDataLoader from 'hooks/useDataLoader'; +import { ICollectionCategory, ICreateEditAnimalRequest } from 'interfaces/useCritterApi.interface'; +import { useEffect, useMemo, useState } from 'react'; +import { EcologicalUnitsOptionSelect } from './EcologicalUnitsOptionSelect'; + +interface IEcologicalUnitsSelect { + // The collection units (categories) available to select from + ecologicalUnits: ICollectionCategory[]; + // Formik field array helpers + arrayHelpers: FieldArrayRenderProps; + // The index of the field array for these controls + index: number; +} + +/** + * Returns a component for selecting ecological (ie. collection) units for a given species. + * + * @param {IEcologicalUnitsSelect} props + * @return {*} + */ +export const EcologicalUnitsSelect = (props: IEcologicalUnitsSelect) => { + const { index, ecologicalUnits } = props; + + const { values, setFieldValue } = useFormikContext(); + + const critterbaseApi = useCritterbaseApi(); + + // Get the collection category ID for the selected ecological unit + const selectedEcologicalUnitId: string | undefined = values.ecological_units[index]?.collection_category_id; + + const ecologicalUnitOptionDataLoader = useDataLoader((collection_category_id: string) => + critterbaseApi.xref.getCollectionUnits(collection_category_id) + ); + + useEffect(() => { + // If a collection category is already selected, load the collection units for that category + if (!selectedEcologicalUnitId) { + return; + } + + ecologicalUnitOptionDataLoader.load(selectedEcologicalUnitId); + }, [ecologicalUnitOptionDataLoader, selectedEcologicalUnitId]); + + // Set the label for the ecological unit options autocomplete field + const [ecologicalUnitOptionLabel, setEcologicalUnitOptionLabel] = useState( + ecologicalUnits.find((ecologicalUnit) => ecologicalUnit.collection_category_id === selectedEcologicalUnitId) + ?.category_name ?? '' + ); + + // Filter out the categories that are already selected so they can't be selected again + const filteredCategories = useMemo( + () => + ecologicalUnits + .filter( + (ecologicalUnit) => + !values.ecological_units.some( + (existing) => + existing.collection_category_id === ecologicalUnit.collection_category_id && + existing.collection_category_id !== selectedEcologicalUnitId + ) + ) + .map((option) => { + return { + value: option.collection_category_id, + label: option.category_name + }; + }) ?? [], + [ecologicalUnits, selectedEcologicalUnitId, values.ecological_units] + ); + + // Map the collection unit options to the format required by the AutocompleteField + const ecologicalUnitOptions = useMemo( + () => + ecologicalUnitOptionDataLoader.data?.map((option) => ({ + value: option.collection_unit_id, + label: option.unit_name + })) ?? [], + [ecologicalUnitOptionDataLoader.data] + ); + + return ( + + { + if (option?.value) { + setFieldValue(`ecological_units.[${index}].collection_category_id`, option.value); + setEcologicalUnitOptionLabel(option.label); + } + }} + required + sx={{ + flex: '1 1 auto' + }} + /> + + props.arrayHelpers.remove(index)} + sx={{ mt: 1.125 }}> + + + + ); +}; diff --git a/app/src/features/surveys/animals/animal-form/components/general-information/AnimalGeneralInformationForm.tsx b/app/src/features/surveys/animals/animal-form/components/general-information/AnimalGeneralInformationForm.tsx new file mode 100644 index 0000000000..d89ec8cb5d --- /dev/null +++ b/app/src/features/surveys/animals/animal-form/components/general-information/AnimalGeneralInformationForm.tsx @@ -0,0 +1,73 @@ +import Collapse from '@mui/material/Collapse'; +import Grid from '@mui/material/Grid'; +import Box from '@mui/system/Box'; +import CustomTextField from 'components/fields/CustomTextField'; +import SelectedSpecies from 'components/species/components/SelectedSpecies'; +import SpeciesAutocompleteField from 'components/species/components/SpeciesAutocompleteField'; +import { useFormikContext } from 'formik'; +import { ICreateEditAnimalRequest } from 'interfaces/useCritterApi.interface'; + +export interface IAnimalGeneralInformationFormProps { + isEdit?: boolean; +} + +/** + * Returns components for setting general information fields when creating or editing an animal + * + * @param {IAnimalGeneralInformationFormProps} props + * @return {*} + */ +export const AnimalGeneralInformationForm = (props: IAnimalGeneralInformationFormProps) => { + const { isEdit } = props; + + const { values, errors, setFieldValue } = useFormikContext(); + + return ( + + + + { + setFieldValue('species', species); + setFieldValue('ecological_units', []); + }} + clearOnSelect={true} + error={errors.species} + /> + {values.species && ( + + setFieldValue('species', null)} + /> + + )} + + + + + + + + + + ); +}; diff --git a/app/src/features/surveys/animals/animal-form/create/CreateAnimalPage.tsx b/app/src/features/surveys/animals/animal-form/create/CreateAnimalPage.tsx new file mode 100644 index 0000000000..3f8ed2659d --- /dev/null +++ b/app/src/features/surveys/animals/animal-form/create/CreateAnimalPage.tsx @@ -0,0 +1,206 @@ +import LoadingButton from '@mui/lab/LoadingButton'; +import Breadcrumbs from '@mui/material/Breadcrumbs'; +import Button from '@mui/material/Button'; +import Container from '@mui/material/Container'; +import Link from '@mui/material/Link'; +import Paper from '@mui/material/Paper'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import { IErrorDialogProps } from 'components/dialog/ErrorDialog'; +import PageHeader from 'components/layout/PageHeader'; +import { CreateAnimalI18N } from 'constants/i18n'; +import { AnimalFormContainer } from 'features/surveys/animals/animal-form/components/AnimalFormContainer'; +import { AnimalSex } from 'features/surveys/view/survey-animals/animal'; +import { FormikProps } from 'formik'; +import { APIError } from 'hooks/api/useAxios'; +import { useBiohubApi } from 'hooks/useBioHubApi'; +import { useAnimalPageContext, useDialogContext, useProjectContext, useSurveyContext } from 'hooks/useContext'; +import { useCritterbaseApi } from 'hooks/useCritterbaseApi'; +import { SKIP_CONFIRMATION_DIALOG, useUnsavedChangesDialog } from 'hooks/useUnsavedChangesDialog'; +import { ICreateEditAnimalRequest } from 'interfaces/useCritterApi.interface'; +import { useRef, useState } from 'react'; +import { Prompt, useHistory } from 'react-router'; +import { Link as RouterLink } from 'react-router-dom'; + +export const defaultAnimalDataFormValues: ICreateEditAnimalRequest = { + nickname: '', + species: null, + ecological_units: [], + wildlife_health_id: '', + critter_comment: '' +}; + +/** + * Returns the page for creating new animals (critters) and inserting them into the current survey. + * + * @return {*} + */ +export const CreateAnimalPage = () => { + const history = useHistory(); + + const biohubApi = useBiohubApi(); + const critterbaseApi = useCritterbaseApi(); + + const surveyContext = useSurveyContext(); + const projectContext = useProjectContext(); + const dialogContext = useDialogContext(); + const animalPageContext = useAnimalPageContext(); + + const { locationChangeInterceptor } = useUnsavedChangesDialog(); + + const [isSaving, setIsSaving] = useState(false); + + const formikRef = useRef>(null); + + const { projectId, surveyId } = surveyContext; + + const handleCancel = () => { + history.push(`/admin/projects/${projectId}/surveys/${surveyId}/animals`); + }; + + const showCreateErrorDialog = (textDialogProps?: Partial) => { + dialogContext.setErrorDialog({ + dialogTitle: CreateAnimalI18N.createErrorTitle, + dialogText: CreateAnimalI18N.createErrorText, + onClose: () => { + dialogContext.setErrorDialog({ open: false }); + }, + onOk: () => { + dialogContext.setErrorDialog({ open: false }); + }, + ...textDialogProps, + open: true + }); + }; + + /** + * Creates an animal + * + * @return {*} + */ + const handleSubmit = async (values: ICreateEditAnimalRequest) => { + setIsSaving(true); + + try { + if (!values.species) { + return; + } + + const response = await biohubApi.survey.createCritterAndAddToSurvey(projectId, surveyId, { + critter_id: undefined, + itis_tsn: values.species.tsn, + wlh_id: undefined, + animal_id: values.nickname, + sex: AnimalSex.UNKNOWN, + critter_comment: values.critter_comment + }); + + // Insert collection units through bulk create + if (values.ecological_units.length > 0) { + await critterbaseApi.critters.bulkCreate({ + collections: values.ecological_units + .filter((unit) => unit.collection_category_id !== null && unit.collection_unit_id !== null) + .map((unit) => ({ + critter_collection_unit_id: undefined, + critter_id: response.critterbase_critter_id, + collection_category_id: unit.collection_category_id as string, + collection_unit_id: unit.collection_unit_id as string + })) + }); + } + + if (!response) { + showCreateErrorDialog({ + dialogError: 'The response from the server was null, or did not contain a survey ID.' + }); + return; + } + + animalPageContext.setSelectedAnimal({ + critterbase_critter_id: response.critterbase_critter_id, + survey_critter_id: response.survey_critter_id + }); + + // Refresh the context, so the next page loads with the latest data + surveyContext.critterDataLoader.refresh(surveyContext.projectId, surveyContext.surveyId); + + history.push(`/admin/projects/${projectId}/surveys/${surveyId}/animals`, SKIP_CONFIRMATION_DIALOG); + } catch (error) { + const apiError = error as APIError; + showCreateErrorDialog({ + dialogTitle: 'Error Creating Survey', + dialogError: apiError?.message, + dialogErrorDetails: apiError?.errors + }); + } finally { + setIsSaving(false); + } + }; + + return ( + <> + + '}> + + {projectContext.projectDataLoader.data?.projectData.project.project_name} + + + {surveyContext.surveyDataLoader.data?.surveyData.survey_details.survey_name} + + + Manage Animals + + + Create New Animal + + + } + buttonJSX={ + + formikRef.current?.submitForm()}> + Save and Exit + + + + } + /> + + + + + + { + formikRef.current?.submitForm(); + }}> + Save and Exit + + + + + + + ); +}; diff --git a/app/src/features/surveys/animals/animal-form/edit/EditAnimalPage.tsx b/app/src/features/surveys/animals/animal-form/edit/EditAnimalPage.tsx new file mode 100644 index 0000000000..9fa8407824 --- /dev/null +++ b/app/src/features/surveys/animals/animal-form/edit/EditAnimalPage.tsx @@ -0,0 +1,255 @@ +import LoadingButton from '@mui/lab/LoadingButton'; +import Breadcrumbs from '@mui/material/Breadcrumbs'; +import Button from '@mui/material/Button'; +import CircularProgress from '@mui/material/CircularProgress'; +import Container from '@mui/material/Container'; +import Link from '@mui/material/Link'; +import Paper from '@mui/material/Paper'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import { IErrorDialogProps } from 'components/dialog/ErrorDialog'; +import PageHeader from 'components/layout/PageHeader'; +import { EditAnimalI18N } from 'constants/i18n'; +import { AnimalFormContainer } from 'features/surveys/animals/animal-form/components/AnimalFormContainer'; +import { AnimalSex } from 'features/surveys/view/survey-animals/animal'; +import { FormikProps } from 'formik'; +import { APIError } from 'hooks/api/useAxios'; +import { + useAnimalPageContext, + useDialogContext, + useProjectContext, + useSurveyContext, + useTaxonomyContext +} from 'hooks/useContext'; +import { useCritterbaseApi } from 'hooks/useCritterbaseApi'; +import { SKIP_CONFIRMATION_DIALOG, useUnsavedChangesDialog } from 'hooks/useUnsavedChangesDialog'; +import { ICreateEditAnimalRequest } from 'interfaces/useCritterApi.interface'; +import { useEffect, useRef, useState } from 'react'; +import { Prompt, useHistory, useParams } from 'react-router'; +import { Link as RouterLink } from 'react-router-dom'; + +/** + * Returns the page for editing an existing animal (critter) within a Survey + * + * @return {*} + */ +export const EditAnimalPage = () => { + const history = useHistory(); + + const critterbaseApi = useCritterbaseApi(); + const surveyContext = useSurveyContext(); + const projectContext = useProjectContext(); + const dialogContext = useDialogContext(); + const animalPageContext = useAnimalPageContext(); + const taxonomyContext = useTaxonomyContext(); + + const urlParams: Record = useParams(); + const surveyCritterId: number | undefined = Number(urlParams['survey_critter_id']); + + const { locationChangeInterceptor } = useUnsavedChangesDialog(); + + const [isSaving, setIsSaving] = useState(false); + + const formikRef = useRef>(null); + + const { projectId, surveyId } = surveyContext; + + // Update the selected animal based on url Params + if (surveyCritterId) { + animalPageContext.setSelectedAnimalFromSurveyCritterId(surveyCritterId); + } + + const critter = animalPageContext.critterDataLoader.data; + + useEffect(() => { + if (!critter?.itis_tsn) { + return; + } + + taxonomyContext.getCachedSpeciesTaxonomyById(critter.itis_tsn); + }, [critter?.itis_tsn, taxonomyContext]); + + // Loading spinner if the data later hasn't updated to the selected animal yet + if (!critter || animalPageContext.selectedAnimal?.critterbase_critter_id !== critter.critter_id) { + return ; + } + + const handleCancel = () => { + history.push(`/admin/projects/${projectId}/surveys/${surveyId}/animals`); + }; + + const showCreateErrorDialog = (textDialogProps?: Partial) => { + dialogContext.setErrorDialog({ + dialogTitle: EditAnimalI18N.createErrorTitle, + dialogText: EditAnimalI18N.createErrorText, + onClose: () => { + dialogContext.setErrorDialog({ open: false }); + }, + onOk: () => { + dialogContext.setErrorDialog({ open: false }); + }, + ...textDialogProps, + open: true + }); + }; + + /** + * Creates an animal + * + * @return {*} + */ + const handleSubmit = async (values: ICreateEditAnimalRequest) => { + setIsSaving(true); + + try { + if (!values.species) { + return; + } + + const response = await critterbaseApi.critters.updateCritter({ + critter_id: critter.critter_id, + itis_tsn: values.species.tsn, + wlh_id: values.wildlife_health_id, + animal_id: values.nickname, + sex: AnimalSex.UNKNOWN, + critter_comment: values.critter_comment + }); + + // Find collection units to delete + const collectionsForDelete = critter.collection_units.filter( + (existing) => + !values.ecological_units.some( + (incoming) => incoming.collection_category_id === existing.collection_category_id + ) + ); + + // Patch collection units in bulk + const bulkResponse = await critterbaseApi.critters.bulkUpdate({ + collections: [ + ...values.ecological_units + .filter((unit) => unit.collection_category_id !== null && unit.collection_unit_id !== null) + .map((unit) => ({ + critter_collection_unit_id: unit.critter_collection_unit_id, + critter_id: critter.critter_id, + collection_category_id: unit.collection_category_id as string, + collection_unit_id: unit.collection_unit_id as string + })), + ...collectionsForDelete.map((collection) => ({ + ...collection, + critter_id: critter.critter_id, + _delete: true + })) + ] + }); + + if (!response || !bulkResponse) { + showCreateErrorDialog({ + dialogError: 'The response from the server was null, or did not contain a survey ID.' + }); + return; + } + + // Refresh the context, so the next page loads with the latest data + surveyContext.critterDataLoader.refresh(surveyContext.projectId, surveyContext.surveyId); + animalPageContext.critterDataLoader.refresh(critter.critter_id); + + history.push(`/admin/projects/${projectId}/surveys/${surveyId}/animals`, SKIP_CONFIRMATION_DIALOG); + } catch (error) { + const apiError = error as APIError; + showCreateErrorDialog({ + dialogTitle: 'Error Creating Survey', + dialogError: apiError?.message, + dialogErrorDetails: apiError?.errors + }); + } finally { + setIsSaving(false); + } + }; + + return ( + <> + + '}> + + {projectContext.projectDataLoader.data?.projectData.project.project_name} + + + {surveyContext.surveyDataLoader.data?.surveyData.survey_details.survey_name} + + + Manage Animals + + + {critter.animal_id} + + + Edit Animal + + + } + buttonJSX={ + + formikRef.current?.submitForm()}> + Save and Exit + + + + } + /> + + + + ({ ...unit, critter_id: critter.critter_id })), + wildlife_health_id: critter.wlh_id, + critter_comment: critter.critter_comment + } as ICreateEditAnimalRequest + } + handleSubmit={handleSubmit} + formikRef={formikRef} + isEdit={true} + /> + + { + formikRef.current?.submitForm(); + }}> + Save and Exit + + + + + + + ); +}; diff --git a/app/src/features/surveys/animals/components/ScientificNameTypography.tsx b/app/src/features/surveys/animals/components/ScientificNameTypography.tsx new file mode 100644 index 0000000000..405e137848 --- /dev/null +++ b/app/src/features/surveys/animals/components/ScientificNameTypography.tsx @@ -0,0 +1,26 @@ +import Typography, { TypographyProps } from '@mui/material/Typography'; + +interface IScientificNameTypographyProps extends TypographyProps { + name: string; +} + +/** + * Typography wrapper for formatting a species' scientific name. Returns an italicized Typography + * component if the input name has 2 or more words. + * + * @param props + * @returns + */ +export const ScientificNameTypography = (props: IScientificNameTypographyProps) => { + const terms = props.name.split(' '); + + if (terms.length > 1) { + return ( + + {props.name} + + ); + } else { + return {props.name}; + } +}; diff --git a/app/src/features/surveys/animals/list/AnimalListContainer.tsx b/app/src/features/surveys/animals/list/AnimalListContainer.tsx new file mode 100644 index 0000000000..1d1accf6a1 --- /dev/null +++ b/app/src/features/surveys/animals/list/AnimalListContainer.tsx @@ -0,0 +1,395 @@ +import { mdiDotsVertical, mdiPencilOutline, mdiTrashCanOutline } from '@mdi/js'; +import Icon from '@mdi/react'; +import Box from '@mui/material/Box'; +import Checkbox from '@mui/material/Checkbox'; +import grey from '@mui/material/colors/grey'; +import Divider from '@mui/material/Divider'; +import FormControlLabel from '@mui/material/FormControlLabel'; +import FormGroup from '@mui/material/FormGroup'; +import IconButton from '@mui/material/IconButton'; +import ListItemIcon from '@mui/material/ListItemIcon'; +import ListItemText from '@mui/material/ListItemText'; +import Menu, { MenuProps } from '@mui/material/Menu'; +import MenuItem from '@mui/material/MenuItem'; +import Paper from '@mui/material/Paper'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import { SkeletonList } from 'components/loading/SkeletonLoaders'; +import { ISurveyCritter } from 'contexts/animalPageContext'; +import { useBiohubApi } from 'hooks/useBioHubApi'; +import { useAnimalPageContext, useCodesContext, useDialogContext, useSurveyContext } from 'hooks/useContext'; +import { useState } from 'react'; +import { Link as RouterLink } from 'react-router-dom'; +import { AnimalListToolbar } from './components/AnimalListToolbar'; +import { CritterListItem } from './components/CritterListItem'; + +/** + * Returns a list of all animals (critters) in the survey + * + * @return {*} + */ +export const AnimalListContainer = () => { + const [checkboxSelectedIds, setCheckboxSelectedIds] = useState([]); + const [critterAnchorEl, setCritterAnchorEl] = useState(null); + const [headerAnchorEl, setHeaderAnchorEl] = useState(null); + const [selectedCritterMenu, setSelectedCritterMenu] = useState(); + + const codesContext = useCodesContext(); + const surveyContext = useSurveyContext(); + const dialogContext = useDialogContext(); + + const biohubApi = useBiohubApi(); + + const { projectId, surveyId } = useSurveyContext(); + + const { setSelectedAnimal, selectedAnimal } = useAnimalPageContext(); + + const critters = surveyContext.critterDataLoader.data; + + if (!critters) { + return ( + + + + ); + } + + const crittersCount = critters.length; + + const handleCheckboxChange = (critterId: number) => { + setCheckboxSelectedIds((prev) => { + if (prev.includes(critterId)) { + return prev.filter((item) => item !== critterId); + } else { + return [...prev, critterId]; + } + }); + }; + + const handleCritterMenuClick = (event: React.MouseEvent, critter: ISurveyCritter) => { + setCritterAnchorEl(event.currentTarget); + setSelectedCritterMenu(critter); + }; + + /** + * Handle the deletion of a critter + * + */ + const handleDeleteCritter = async (surveyCritterId: number) => { + await biohubApi.survey + .removeCrittersFromSurvey(surveyContext.projectId, surveyContext.surveyId, [surveyCritterId]) + .then(() => { + dialogContext.setYesNoDialog({ open: false }); + setCritterAnchorEl(null); + surveyContext.critterDataLoader.refresh(surveyContext.projectId, surveyContext.surveyId); + }) + .catch((error: any) => { + dialogContext.setYesNoDialog({ open: false }); + setCritterAnchorEl(null); + dialogContext.setSnackbar({ + snackbarMessage: ( + <> + + Error Deleting Animal + + + {String(error)} + + + ), + open: true + }); + }); + }; + + /** + * Handle the deletion of multiple critters + * + */ + const handleBulkDeleteCritters = async () => { + await biohubApi.survey + .removeCrittersFromSurvey(surveyContext.projectId, surveyContext.surveyId, checkboxSelectedIds) + .then(() => { + dialogContext.setYesNoDialog({ open: false }); + + // If the selected animal is the deleted animal, unset the selected animal + if (checkboxSelectedIds.some((id) => id == selectedAnimal?.survey_critter_id)) { + setSelectedAnimal(); + } + + setCheckboxSelectedIds([]); + setHeaderAnchorEl(null); + surveyContext.critterDataLoader.refresh(surveyContext.projectId, surveyContext.surveyId); + }) + .catch((error: any) => { + dialogContext.setYesNoDialog({ open: false }); + setCheckboxSelectedIds([]); + setHeaderAnchorEl(null); + dialogContext.setSnackbar({ + snackbarMessage: ( + <> + + Error Deleting Animals + + + {String(error)} + + + ), + open: true + }); + }); + }; + + /** + * Display the delete Animal dialog. + * + */ + const deleteCritterDialog = () => { + dialogContext.setYesNoDialog({ + dialogTitle: 'Delete Animal?', + dialogContent: ( + + Are you sure you want to delete this Animal? + + ), + yesButtonLabel: 'Delete Animal', + noButtonLabel: 'Cancel', + yesButtonProps: { color: 'error' }, + onClose: () => { + dialogContext.setYesNoDialog({ open: false }); + }, + onNo: () => { + dialogContext.setYesNoDialog({ open: false }); + }, + open: true, + onYes: () => { + if (selectedCritterMenu?.survey_critter_id) { + handleDeleteCritter(selectedCritterMenu?.survey_critter_id); + } + // If the selected animal is the deleted animal, unset the selected animal + if (selectedCritterMenu?.survey_critter_id == selectedAnimal?.survey_critter_id) { + setSelectedAnimal(); + } + } + }); + }; + + const handlePromptConfirmBulkDelete = () => { + dialogContext.setYesNoDialog({ + dialogTitle: 'Delete Animals?', + dialogContent: ( + + Are you sure you want to delete the selected Animals? + + ), + yesButtonLabel: 'Delete Animals', + noButtonLabel: 'Cancel', + yesButtonProps: { color: 'error' }, + onClose: () => { + dialogContext.setYesNoDialog({ open: false }); + }, + onNo: () => { + dialogContext.setYesNoDialog({ open: false }); + }, + open: true, + onYes: () => { + handleBulkDeleteCritters(); + } + }); + }; + + const handleHeaderMenuClick = (event: React.MouseEvent) => { + setHeaderAnchorEl(event.currentTarget); + }; + + return ( + <> + {selectedCritterMenu && ( + setCritterAnchorEl(null)} + anchorEl={critterAnchorEl} + anchorOrigin={{ + vertical: 'top', + horizontal: 'right' + }} + transformOrigin={{ + vertical: 'top', + horizontal: 'right' + }}> + + { + setSelectedAnimal(selectedCritterMenu); + }}> + + + + Edit Details + + + { + deleteCritterDialog(); + setCritterAnchorEl(null); + }}> + + + + Delete + + + )} + + setHeaderAnchorEl(null)} + anchorEl={headerAnchorEl} + anchorOrigin={{ + vertical: 'top', + horizontal: 'right' + }} + transformOrigin={{ + vertical: 'top', + horizontal: 'right' + }}> + + + + + Delete + + + + + + + + + {surveyContext.critterDataLoader.isLoading || codesContext.codesDataLoader.isLoading ? ( + + ) : ( + + + + + Select All + + } + control={ + 0 && checkboxSelectedIds.length === crittersCount} + indeterminate={checkboxSelectedIds.length >= 1 && checkboxSelectedIds.length < crittersCount} + onClick={() => { + if (checkboxSelectedIds.length === crittersCount) { + setCheckboxSelectedIds([]); + return; + } + + const critterIds = critters.map((critter) => critter.survey_critter_id); + setCheckboxSelectedIds(critterIds); + }} + inputProps={{ 'aria-label': 'controlled' }} + /> + } + /> + + + + + {!critters.length && ( + + No Animals + + )} + {critters.map((critter) => ( + + + ) => + handleCritterMenuClick(event, { + critterbase_critter_id: critter.critter_id, + survey_critter_id: critter.survey_critter_id + }) + } + aria-label="animal-settings"> + + + + ))} + + + )} + + + + + ); +}; diff --git a/app/src/features/surveys/animals/list/components/AnimalListToolbar.tsx b/app/src/features/surveys/animals/list/components/AnimalListToolbar.tsx new file mode 100644 index 0000000000..68f09c493e --- /dev/null +++ b/app/src/features/surveys/animals/list/components/AnimalListToolbar.tsx @@ -0,0 +1,60 @@ +import { mdiDotsVertical, mdiPlus } from '@mdi/js'; +import { Icon } from '@mdi/react'; +import Button from '@mui/material/Button'; +import IconButton from '@mui/material/IconButton'; +import Toolbar from '@mui/material/Toolbar'; +import Typography from '@mui/material/Typography'; +import { useSurveyContext } from 'hooks/useContext'; +import { Link as RouterLink } from 'react-router-dom'; + +interface IAnimaListToolbarProps { + animalCount: number; + checkboxSelectedIdsLength: number; + handleHeaderMenuClick: (event: React.MouseEvent) => void; +} + +/** + * Toolbar for actions affecting animals with a survey, ie. delete an animal from a Survey + * + * @param {IAnimaListToolbarProps} props + * @return {*} + */ +export const AnimalListToolbar = (props: IAnimaListToolbarProps) => { + const { surveyId, projectId } = useSurveyContext(); + + return ( + + + Animals ‌ + + ({props.animalCount}) + + + + + + + + ); +}; diff --git a/app/src/features/surveys/animals/list/components/CritterListItem.tsx b/app/src/features/surveys/animals/list/components/CritterListItem.tsx new file mode 100644 index 0000000000..459e0f2681 --- /dev/null +++ b/app/src/features/surveys/animals/list/components/CritterListItem.tsx @@ -0,0 +1,131 @@ +import Checkbox from '@mui/material/Checkbox'; +import CircularProgress from '@mui/material/CircularProgress'; +import grey from '@mui/material/colors/grey'; +import IconButton from '@mui/material/IconButton'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import { useAnimalPageContext, useSurveyContext } from 'hooks/useContext'; +import { ISimpleCritterWithInternalId } from 'interfaces/useSurveyApi.interface'; +import { useEffect } from 'react'; +import { ScientificNameTypography } from '../../components/ScientificNameTypography'; + +interface ICritterListItemProps { + critter: ISimpleCritterWithInternalId; + isChecked: boolean; + handleCheckboxChange: (surveyCritterId: number) => void; +} + +/** + * Component for displaying and selecting an animal within the AnimalListContainer + * + * @param {ICritterListItemProps} props + * @return {*} + */ +export const CritterListItem = (props: ICritterListItemProps) => { + const surveyContext = useSurveyContext(); + const critters = surveyContext.critterDataLoader.data; + const { critter, isChecked, handleCheckboxChange } = props; + + const { selectedAnimal, setSelectedAnimal } = useAnimalPageContext(); + + const { projectId, surveyId } = surveyContext; + + useEffect(() => { + surveyContext.critterDataLoader.load(projectId, surveyId); + }, [projectId, surveyContext.critterDataLoader, surveyId]); + + if (!critters?.length) { + return ; + } + + return ( + + { + if (critter.survey_critter_id !== selectedAnimal?.survey_critter_id) + setSelectedAnimal({ + survey_critter_id: critter.survey_critter_id, + critterbase_critter_id: critter.critter_id + }); + }} + sx={{ + pt: 0.5, + pb: 1.5, + borderRadius: 0, + flex: '1 1 auto', + justifyContent: 'flex-start', + '&:focus': { + outline: 'none' + }, + '& .MuiTypography-root': { + color: 'text.primary' + }, + bgcolor: selectedAnimal?.survey_critter_id === critter.survey_critter_id ? grey[100] : undefined + }}> + + { + event.stopPropagation(); + handleCheckboxChange(critter.survey_critter_id); + }} + inputProps={{ 'aria-label': 'controlled' }} + /> + + + {critter.animal_id} + + + + + + + ); +}; diff --git a/app/src/features/surveys/animals/profile/AnimalProfileContainer.tsx b/app/src/features/surveys/animals/profile/AnimalProfileContainer.tsx new file mode 100644 index 0000000000..6764138d89 --- /dev/null +++ b/app/src/features/surveys/animals/profile/AnimalProfileContainer.tsx @@ -0,0 +1,26 @@ +import Paper from '@mui/material/Paper'; +import Stack from '@mui/material/Stack'; +import { AnimalDetailsContainer } from 'features/surveys/animals/profile/details/AnimalDetailsContainer'; +import { AnimalCaptureContainer } from './captures/AnimalCaptureContainer'; +import AnimalMortalityContainer from './mortality/AnimalMortalityContainer'; + +/** + * Component for displaying an animal's details (profile) within the Manage Animals page + * + * @return {*} + */ +export const AnimalProfileContainer = () => { + return ( + + + + + + + + + + + + ); +}; diff --git a/app/src/features/surveys/animals/profile/captures/AnimalCaptureContainer.tsx b/app/src/features/surveys/animals/profile/captures/AnimalCaptureContainer.tsx new file mode 100644 index 0000000000..397f25b629 --- /dev/null +++ b/app/src/features/surveys/animals/profile/captures/AnimalCaptureContainer.tsx @@ -0,0 +1,117 @@ +import Box from '@mui/material/Box'; +import Skeleton from '@mui/material/Skeleton'; +import { SkeletonHorizontalStack } from 'components/loading/SkeletonLoaders'; +import { AnimalCaptureCardContainer } from 'features/surveys/animals/profile/captures/components/AnimalCaptureCardContainer'; +import { AnimalCapturesToolbar } from 'features/surveys/animals/profile/captures/components/AnimalCapturesToolbar'; +import { useAnimalPageContext, useSurveyContext } from 'hooks/useContext'; +import { useCritterbaseApi } from 'hooks/useCritterbaseApi'; +import { + ICaptureResponse, + IMarkingResponse, + IQualitativeMeasurementResponse, + IQuantitativeMeasurementResponse +} from 'interfaces/useCritterApi.interface'; +import { useHistory } from 'react-router'; +import { AnimalCapturesMap } from './components/AnimalCapturesMap'; + +export interface ICaptureWithSupplementaryData extends ICaptureResponse { + markings: IMarkingResponse[]; + measurements: { qualitative: IQualitativeMeasurementResponse[]; quantitative: IQuantitativeMeasurementResponse[] }; +} + +/** + * Container for the animal captures component within the animal profile page + * + * @return {*} + */ +export const AnimalCaptureContainer = () => { + const critterbaseApi = useCritterbaseApi(); + + const history = useHistory(); + + const { projectId, surveyId } = useSurveyContext(); + + const animalPageContext = useAnimalPageContext(); + + const data = animalPageContext.critterDataLoader.data; + + if (!animalPageContext.selectedAnimal || animalPageContext.critterDataLoader.isLoading) { + return ( + + + + + + + + ); + } + + const selectedAnimal = animalPageContext.selectedAnimal; + + if (!selectedAnimal) { + return null; + } + + const captures: ICaptureWithSupplementaryData[] = + data?.captures.map((capture) => ({ + ...capture, + markings: data?.markings.filter((marking) => marking.capture_id === capture.capture_id), + measurements: { + qualitative: data.measurements.qualitative.filter( + (measurement) => measurement.capture_id === capture.capture_id + ), + quantitative: data.measurements.quantitative.filter( + (measurement) => measurement.capture_id === capture.capture_id + ) + } + })) || []; + + const handleDelete = async (selectedCapture: string, critterbase_critter_id: string) => { + // Delete markings and measurements associated with the capture to avoid foreign key constraint error + await critterbaseApi.critters.bulkUpdate({ + markings: data?.markings + .filter((marking) => marking.capture_id === selectedCapture) + .map((marking) => ({ + ...marking, + critter_id: selectedAnimal.critterbase_critter_id, + _delete: true + })), + qualitative_measurements: + data?.measurements.qualitative + .filter((measurement) => measurement.capture_id === selectedCapture) + .map((measurement) => ({ + ...measurement, + _delete: true + })) ?? [], + quantitative_measurements: + data?.measurements.quantitative + .filter((measurement) => measurement.capture_id === selectedCapture) + .map((measurement) => ({ + ...measurement, + _delete: true + })) ?? [] + }); + + // Delete the actual capture + await critterbaseApi.capture.deleteCapture(selectedCapture); + + // Refresh capture container + animalPageContext.critterDataLoader.refresh(critterbase_critter_id); + }; + + return ( + <> + { + history.push( + `/admin/projects/${projectId}/surveys/${surveyId}/animals/${selectedAnimal.survey_critter_id}/capture/create` + ); + }} + /> + {captures.length > 0 && } + + + ); +}; diff --git a/app/src/features/surveys/animals/profile/captures/capture-form/components/AnimalCaptureForm.tsx b/app/src/features/surveys/animals/profile/captures/capture-form/components/AnimalCaptureForm.tsx new file mode 100644 index 0000000000..eff71ba31c --- /dev/null +++ b/app/src/features/surveys/animals/profile/captures/capture-form/components/AnimalCaptureForm.tsx @@ -0,0 +1,164 @@ +import { Divider } from '@mui/material'; +import Stack from '@mui/material/Stack'; +import FormikErrorSnackbar from 'components/alert/FormikErrorSnackbar'; +import HorizontalSplitFormComponent from 'components/fields/HorizontalSplitFormComponent'; +import { Formik, FormikProps } from 'formik'; +import { ICreateCaptureRequest, IEditCaptureRequest } from 'interfaces/useCritterApi.interface'; +import { isDefined } from 'utils/Utils'; +import yup from 'utils/YupSchema'; +import { MarkingsForm } from '../../../markings/MarkingsForm'; +import { MeasurementsForm } from '../../../measurements/MeasurementsForm'; +import { CaptureGeneralInformationForm } from './general-information/CaptureGeneralInformationForm'; +import { CaptureLocationForm } from './location/CaptureLocationForm'; +import { ReleaseLocationForm } from './location/ReleaseLocationForm'; + +export interface IAnimalCaptureFormProps { + initialCaptureData: FormikValuesType; + handleSubmit: (formikData: FormikValuesType) => void; + formikRef: React.RefObject>; +} + +/** + * Returns the formik component for creating and editing an animal capture + * + * @template FormikValuesType + * @param {IAnimalCaptureFormProps} props + * @return {*} + */ +export const AnimalCaptureForm = ( + props: IAnimalCaptureFormProps +) => { + const animalCaptureYupSchema = yup.object({ + capture: yup.object({ + capture_id: yup.string().nullable(), + capture_date: yup.string().required('Capture date is required'), + capture_time: yup.string().nullable(), + capture_comment: yup.string().required('Capture comment is required'), + release_date: yup.string().nullable(), + release_time: yup.string().nullable(), + release_comment: yup.string().nullable(), + capture_location: yup + .object() + .shape({ + type: yup.string(), + // Points may have 3 coords for [lon, lat, elevation] + geometry: yup.object({ + type: yup.string(), + coordinates: yup.array().of(yup.number()).min(2).max(3) + }), + properties: yup.object().optional() + }) + .nullable() + .default(undefined) + .required('Capture location is required'), + release_location: yup + .array( + yup.object({ + geojson: yup.array().min(1, 'Release location is required if it is different from the capture location') + }) + ) + .min(1, 'Release location is required if it is different from the capture location') + .nullable() + }), + measurements: yup.array( + yup + .object() + .shape({ + taxon_measurement_id: yup.string().required('Measurement type is required.'), + value: yup.mixed().test('is-valid-measurement', 'Measurement value is required.', function () { + const { value } = this.parent; + if (isDefined(value)) { + // Field value is defined, check if it is valid + return yup.number().isValidSync(value); + } + // Field value is not defined, return valid for now + return true; + }), + qualitative_option_id: yup + .mixed() + .test('is-valid-measurement', 'Measurement value is required.', function () { + const { qualitative_option_id } = this.parent; + if (isDefined(qualitative_option_id)) { + // Field value is defined, check if it is valid + return yup.string().isValidSync(qualitative_option_id); + } + // Field value is not defined, return valid for now + return true; + }) + }) + .test('is-valid-measurement', 'Measurement must have a type and a value', function (_value) { + const { taxon_measurement_id } = _value; + const path = this.path; + if (taxon_measurement_id && !isDefined(_value.value) && !isDefined(_value.qualitative_option_id)) { + // Measurement type is defined but neither value nor qualitative option is defined, add errors for both + const errors = [ + this.createError({ + path: `${path}.qualitative_option_id`, + message: 'Measurement value is required.' + }), + this.createError({ + path: `${path}.value`, + message: 'Measurement value is required.' + }) + ]; + return new yup.ValidationError(errors); + } + // Field value is not defined, return valid + return true; + }) + ), + markings: yup.array( + yup.object({ + marking_type_id: yup.string().required('Marking type is required.'), + taxon_marking_body_location_id: yup.string().required('Marking body location is required.'), + identifier: yup.string().nullable(), + primary_colour_id: yup.string().nullable(), + secondary_colour_id: yup.string().nullable(), + comment: yup.string().nullable() + }) + ) + }); + + return ( + + + + } + /> + + } + /> + + } + /> + + } + /> + + } + /> + + + ); +}; diff --git a/app/src/features/surveys/animals/profile/captures/capture-form/components/general-information/CaptureGeneralInformationForm.tsx b/app/src/features/surveys/animals/profile/captures/capture-form/components/general-information/CaptureGeneralInformationForm.tsx new file mode 100644 index 0000000000..d961ee22a4 --- /dev/null +++ b/app/src/features/surveys/animals/profile/captures/capture-form/components/general-information/CaptureGeneralInformationForm.tsx @@ -0,0 +1,91 @@ +import { mdiCalendar } from '@mdi/js'; +import Grid from '@mui/material/Grid'; +import Typography from '@mui/material/Typography'; +import Box from '@mui/system/Box'; +import CustomTextField from 'components/fields/CustomTextField'; +import { DateTimeFields } from 'components/fields/DateTimeFields'; +import { useFormikContext } from 'formik'; +import { ICreateCaptureRequest, IEditCaptureRequest } from 'interfaces/useCritterApi.interface'; + +/** + * Returns the controls for general information fields relating to the capture on the animal capture form + * + * @template T + * @return {*} + */ +export const CaptureGeneralInformationForm = < + FormikValuesType extends ICreateCaptureRequest | IEditCaptureRequest +>() => { + const formikProps = useFormikContext(); + + return ( + + + + + Capture information + + + + + + + + + + + Release information (optional) + + + + + + + + + + + ); +}; diff --git a/app/src/features/surveys/animals/profile/captures/capture-form/components/location/CaptureLocationForm.tsx b/app/src/features/surveys/animals/profile/captures/capture-form/components/location/CaptureLocationForm.tsx new file mode 100644 index 0000000000..69ac8976c0 --- /dev/null +++ b/app/src/features/surveys/animals/profile/captures/capture-form/components/location/CaptureLocationForm.tsx @@ -0,0 +1,24 @@ +import Box from '@mui/material/Box'; +import Grid from '@mui/material/Grid'; +import { CaptureLocationMapControl } from './CaptureLocationMapControl'; + +/** + * Returns the control for capture location on the animal capture form, wrapping around the actual map control. + * + * @return {*} + */ +export const CaptureLocationForm = () => { + return ( + + + + + + + + ); +}; diff --git a/app/src/features/surveys/animals/profile/captures/capture-form/components/location/CaptureLocationMapControl.tsx b/app/src/features/surveys/animals/profile/captures/capture-form/components/location/CaptureLocationMapControl.tsx new file mode 100644 index 0000000000..f63f78921c --- /dev/null +++ b/app/src/features/surveys/animals/profile/captures/capture-form/components/location/CaptureLocationMapControl.tsx @@ -0,0 +1,213 @@ +import { mdiTrayArrowUp } from '@mdi/js'; +import Icon from '@mdi/react'; +import Alert from '@mui/material/Alert'; +import AlertTitle from '@mui/material/AlertTitle'; +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; +import Grid from '@mui/material/Grid'; +import Paper from '@mui/material/Paper'; +import Toolbar from '@mui/material/Toolbar'; +import Typography from '@mui/material/Typography'; +import BaseLayerControls from 'components/map/components/BaseLayerControls'; +import { SetMapBounds } from 'components/map/components/Bounds'; +import DrawControls, { IDrawControlsRef } from 'components/map/components/DrawControls'; +import FullScreenScrollingEventHandler from 'components/map/components/FullScreenScrollingEventHandler'; +import ImportBoundaryDialog from 'components/map/components/ImportBoundaryDialog'; +import StaticLayers from 'components/map/components/StaticLayers'; +import { MapBaseCss } from 'components/map/styles/MapBaseCss'; +import { ALL_OF_BC_BOUNDARY, MAP_DEFAULT_CENTER, MAP_DEFAULT_ZOOM } from 'constants/spatial'; +import { useFormikContext } from 'formik'; +import { Feature } from 'geojson'; +import { ICreateCaptureRequest, IEditCaptureRequest } from 'interfaces/useCritterApi.interface'; +import { DrawEvents, LatLngBoundsExpression } from 'leaflet'; +import 'leaflet-fullscreen/dist/leaflet.fullscreen.css'; +import 'leaflet-fullscreen/dist/Leaflet.fullscreen.js'; +import 'leaflet/dist/leaflet.css'; +import { get } from 'lodash-es'; +import { useEffect, useMemo, useRef, useState } from 'react'; +import { FeatureGroup, LayersControl, MapContainer as LeafletMapContainer } from 'react-leaflet'; +import { calculateUpdatedMapBounds } from 'utils/mapBoundaryUploadHelpers'; + +export interface ICaptureLocationMapControlProps { + name: string; + title: string; + mapId: string; +} + +/** + * Capture location map control + * + * @param {ICaptureLocationMapControlProps} props + * @return {*} + */ +export const CaptureLocationMapControl = ( + props: ICaptureLocationMapControlProps +) => { + const { name, title } = props; + const [isOpen, setIsOpen] = useState(false); + const [lastDrawn, setLastDrawn] = useState(null); + + const drawControlsRef = useRef(); + + const { mapId } = props; + + const { values, setFieldValue, setFieldError, errors } = useFormikContext(); + + const [updatedBounds, setUpdatedBounds] = useState(undefined); + + // Array of capture location features + const captureLocationGeoJson: Feature | undefined = useMemo(() => { + const location: { latitude: number; longitude: number } | Feature = get(values, name); + + if (!location) { + return; + } + + if ('latitude' in location && location.latitude !== 0 && location.longitude !== 0) { + return { + type: 'Feature', + geometry: { type: 'Point', coordinates: [location.longitude, location.latitude] }, + properties: {} + }; + } + + if ('type' in location) { + return location; + } + }, [name, values]); + + useEffect(() => { + setUpdatedBounds(calculateUpdatedMapBounds([ALL_OF_BC_BOUNDARY])); + + if (captureLocationGeoJson) { + if ('type' in captureLocationGeoJson) { + if (captureLocationGeoJson.geometry.type === 'Point') + if (captureLocationGeoJson?.geometry.coordinates[0] !== 0) { + setUpdatedBounds(calculateUpdatedMapBounds([captureLocationGeoJson])); + } + } + } + }, [captureLocationGeoJson]); + + return ( + + {get(errors, name) && !Array.isArray(get(errors, name)) && ( + + Missing capture location + {get(errors, name) as string} + + )} + + + + setIsOpen(false)} + onSuccess={(features) => { + setUpdatedBounds(calculateUpdatedMapBounds(features)); + setFieldValue(name, features[0]); + setFieldError(name, undefined); + // Unset last drawn to show staticlayers, where the file geometry is loaded to + lastDrawn && drawControlsRef?.current?.deleteLayer(lastDrawn); + drawControlsRef?.current?.addLayer(features[0], () => 1); + setLastDrawn(1); + }} + onFailure={(message) => { + setFieldError(name, message); + }} + /> + + + + {title} + + + + + + + + + {/* Allow scroll wheel zoom when in full screen mode */} + + + {/* Programmatically set map bounds */} + + + + { + if (lastDrawn) { + drawControlsRef?.current?.deleteLayer(lastDrawn); + } + setFieldError(name, undefined); + + const feature = event.layer.toGeoJSON(); + setFieldValue(name, feature); + // Set last drawn to remove it if a subsequent shape is added. There can only be one shape. + setLastDrawn(id); + }} + onLayerEdit={(event: DrawEvents.Edited) => { + event.layers.getLayers().forEach((layer: any) => { + const feature = layer.toGeoJSON() as Feature; + setFieldValue(name, feature); + }); + }} + onLayerDelete={() => { + setFieldValue(name, null); + }} + /> + + + + {!lastDrawn && ( + + )} + + + + + + + + ); +}; diff --git a/app/src/features/surveys/animals/profile/captures/capture-form/components/location/ReleaseLocationForm.tsx b/app/src/features/surveys/animals/profile/captures/capture-form/components/location/ReleaseLocationForm.tsx new file mode 100644 index 0000000000..b430969d39 --- /dev/null +++ b/app/src/features/surveys/animals/profile/captures/capture-form/components/location/ReleaseLocationForm.tsx @@ -0,0 +1,53 @@ +import { Radio, Typography } from '@mui/material'; +import FormControlLabel from '@mui/material/FormControlLabel'; +import Grid from '@mui/material/Grid'; +import RadioGroup from '@mui/material/RadioGroup'; +import Box from '@mui/system/Box'; +import booleanEqual from '@turf/boolean-equal'; +import { useFormikContext } from 'formik'; +import { ICreateCaptureRequest, IEditCaptureRequest } from 'interfaces/useCritterApi.interface'; +import { useState } from 'react'; +import { CaptureLocationMapControl } from './CaptureLocationMapControl'; + +/** + * Returns the control for release location on the animal capture form, wrapping around the actual map control. + * + * @return {*} + */ +export const ReleaseLocationForm = () => { + const { values } = useFormikContext(); + + const [isReleaseSameAsCapture, setIsReleaseSameAsCapture] = useState( + !(values.capture.release_location && values.capture.capture_location) || + booleanEqual(values.capture.release_location, values.capture.capture_location) + ); + + return ( + <> + + Was the animal released where it was captured? + + setIsReleaseSameAsCapture(event.target.value === 'true')}> + } label="Yes" /> + } label="No" /> + + + {!isReleaseSameAsCapture && ( + + + + + + + + )} + + ); +}; diff --git a/app/src/features/surveys/animals/profile/captures/capture-form/create/CreateCapturePage.tsx b/app/src/features/surveys/animals/profile/captures/capture-form/create/CreateCapturePage.tsx new file mode 100644 index 0000000000..5ed735cca7 --- /dev/null +++ b/app/src/features/surveys/animals/profile/captures/capture-form/create/CreateCapturePage.tsx @@ -0,0 +1,295 @@ +import LoadingButton from '@mui/lab/LoadingButton'; +import Breadcrumbs from '@mui/material/Breadcrumbs'; +import Button from '@mui/material/Button'; +import Container from '@mui/material/Container'; +import Link from '@mui/material/Link'; +import Paper from '@mui/material/Paper'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import { IErrorDialogProps } from 'components/dialog/ErrorDialog'; +import PageHeader from 'components/layout/PageHeader'; +import { SkeletonHorizontalStack } from 'components/loading/SkeletonLoaders'; +import { CreateCaptureI18N } from 'constants/i18n'; +import dayjs from 'dayjs'; +import { AnimalCaptureForm } from 'features/surveys/animals/profile/captures/capture-form/components/AnimalCaptureForm'; +import { + isQualitativeMeasurementCreate, + isQuantitativeMeasurementCreate +} from 'features/surveys/animals/profile/measurements/utils'; +import { FormikProps } from 'formik'; +import { APIError } from 'hooks/api/useAxios'; +import { useAnimalPageContext, useDialogContext, useProjectContext, useSurveyContext } from 'hooks/useContext'; +import { useCritterbaseApi } from 'hooks/useCritterbaseApi'; +import { SKIP_CONFIRMATION_DIALOG, useUnsavedChangesDialog } from 'hooks/useUnsavedChangesDialog'; +import { ICreateCaptureRequest } from 'interfaces/useCritterApi.interface'; +import { useEffect, useRef, useState } from 'react'; +import { Prompt, useHistory, useParams } from 'react-router'; +import { Link as RouterLink } from 'react-router-dom'; + +export const defaultAnimalCaptureFormValues: ICreateCaptureRequest = { + capture: { + capture_id: '', + capture_timestamp: '', + capture_date: '', + capture_time: '', + release_timestamp: '', + release_date: '', + release_time: '', + capture_comment: '', + release_comment: '', + capture_location: null, + release_location: null + }, + markings: [], + measurements: [] +}; + +/** + * Page for creating an animal capture record. + * + * @returns + */ +export const CreateCapturePage = () => { + const history = useHistory(); + + const critterbaseApi = useCritterbaseApi(); + + const surveyContext = useSurveyContext(); + const projectContext = useProjectContext(); + const dialogContext = useDialogContext(); + const animalPageContext = useAnimalPageContext(); + + const urlParams: Record = useParams(); + const surveyCritterId: number | undefined = Number(urlParams['survey_critter_id']); + + const { locationChangeInterceptor } = useUnsavedChangesDialog(); + + const [isSaving, setIsSaving] = useState(false); + + const formikRef = useRef>(null); + + const { projectId, surveyId } = surveyContext; + + // If the user has refreshed the page and cleared the context, or come to this page externally from a link, + // use the url params to set the select animal in the context. The context then requests critter data from critterbase. + useEffect(() => { + if (animalPageContext.selectedAnimal || !surveyCritterId) { + return; + } + + animalPageContext.setSelectedAnimalFromSurveyCritterId(surveyCritterId); + }, [animalPageContext, surveyCritterId]); + + const handleCancel = () => { + history.push(`/admin/projects/${projectId}/surveys/${surveyId}/animals/details`); + }; + + const showCreateErrorDialog = (textDialogProps?: Partial) => { + dialogContext.setErrorDialog({ + dialogTitle: CreateCaptureI18N.createErrorTitle, + dialogText: CreateCaptureI18N.createErrorText, + onClose: () => { + dialogContext.setErrorDialog({ open: false }); + }, + onOk: () => { + dialogContext.setErrorDialog({ open: false }); + }, + ...textDialogProps, + open: true + }); + }; + + /** + * Creates an Capture + * + * @return {*} + */ + const handleSubmit = async (values: ICreateCaptureRequest) => { + setIsSaving(true); + + try { + const critterbaseCritterId = animalPageContext.selectedAnimal?.critterbase_critter_id; + + if (!values || !critterbaseCritterId || values.capture.capture_location?.geometry.type !== 'Point') { + return; + } + + const captureLocation = { + longitude: values.capture.capture_location.geometry.coordinates[0], + latitude: values.capture.capture_location.geometry.coordinates[1], + coordinate_uncertainty: 0, + coordinate_uncertainty_units: 'm' + }; + + // if release location is null, use the capture location, otherwise format it for critterbase + const releaseLocation = + values.capture.release_location?.geometry?.type === 'Point' + ? { + longitude: values.capture.release_location.geometry.coordinates[0], + latitude: values.capture.release_location.geometry.coordinates[1], + coordinate_uncertainty: 0, + coordinate_uncertainty_units: 'm' + } + : captureLocation; + + const captureTime = values.capture.capture_time ? ` ${values.capture.capture_time}-07:00` : 'T00:00:00-07:00'; + const captureTimestamp = dayjs(`${values.capture.capture_date}${captureTime}`).toDate(); + + // if release timestamp is null, use the capture timestamp, otherwise format it for critterbase + const releaseTime = values.capture.release_time ? ` ${values.capture.release_time}-07:00` : captureTime; + const releaseTimestamp = values.capture.release_date + ? dayjs(`${values.capture.release_date}${releaseTime}`).toDate() + : captureTimestamp; + + // Must create capture first to avoid foreign key constraints. Can't guarantee that the capture is + // inserted before the measurements/markings. + const captureResponse = await critterbaseApi.capture.createCapture({ + critter_id: critterbaseCritterId, + capture_id: undefined, + capture_timestamp: captureTimestamp, + release_timestamp: releaseTimestamp, + capture_comment: values.capture.capture_comment ?? '', + release_comment: values.capture.release_comment ?? '', + capture_location: captureLocation, + release_location: releaseLocation ?? captureLocation + }); + + if (!captureResponse) { + showCreateErrorDialog({ + dialogError: 'An error occurred while attempting to create the capture record.', + dialogErrorDetails: ['Capture create failed'] + }); + return; + } + + // Create new measurements added while editing the capture + const bulkResponse = await critterbaseApi.critters.bulkCreate({ + markings: values.markings.map((marking) => ({ + ...marking, + marking_id: marking.marking_id, + critter_id: critterbaseCritterId, + capture_id: captureResponse.capture_id + })), + qualitative_measurements: values.measurements + .filter(isQualitativeMeasurementCreate) + // Format qualitative measurements for create + .map((measurement) => ({ + critter_id: critterbaseCritterId, + capture_id: captureResponse.capture_id, + taxon_measurement_id: measurement.taxon_measurement_id, + qualitative_option_id: measurement.qualitative_option_id + })), + quantitative_measurements: values.measurements + .filter(isQuantitativeMeasurementCreate) + // Format quantitative measurements for create + .map((measurement) => ({ + critter_id: critterbaseCritterId, + capture_id: captureResponse.capture_id, + taxon_measurement_id: measurement.taxon_measurement_id, + value: measurement.value + })) + }); + + if (!bulkResponse) { + showCreateErrorDialog({ + dialogError: 'An error occurred while attempting to create the capture record.', + dialogErrorDetails: ['Bulk create failed when creating measurements and markings'] + }); + return; + } + + // Refresh page + animalPageContext.critterDataLoader.refresh(critterbaseCritterId); + + history.push(`/admin/projects/${projectId}/surveys/${surveyId}/animals/details`, SKIP_CONFIRMATION_DIALOG); + } catch (error) { + const apiError = error as APIError; + showCreateErrorDialog({ + dialogTitle: 'Error Creating Survey', + dialogError: apiError?.message, + dialogErrorDetails: apiError?.errors + }); + } finally { + setIsSaving(false); + } + }; + + const animalId = animalPageContext.critterDataLoader.data?.animal_id; + + return ( + <> + + '}> + + {projectContext.projectDataLoader.data?.projectData.project.project_name} + + + {surveyContext.surveyDataLoader.data?.surveyData.survey_details.survey_name} + + + Manage Animals + + + {animalId} + + + Create New Capture + + + ) : ( + + ) + } + buttonJSX={ + + formikRef.current?.submitForm()}> + Save and Exit + + + + } + /> + + + + handleSubmit(formikData)} + formikRef={formikRef} + /> + + { + formikRef.current?.submitForm(); + }}> + Save and Exit + + + + + + + ); +}; diff --git a/app/src/features/surveys/animals/profile/captures/capture-form/edit/EditCapturePage.tsx b/app/src/features/surveys/animals/profile/captures/capture-form/edit/EditCapturePage.tsx new file mode 100644 index 0000000000..fdd7f766e2 --- /dev/null +++ b/app/src/features/surveys/animals/profile/captures/capture-form/edit/EditCapturePage.tsx @@ -0,0 +1,347 @@ +import LoadingButton from '@mui/lab/LoadingButton'; +import { CircularProgress } from '@mui/material'; +import Breadcrumbs from '@mui/material/Breadcrumbs'; +import Button from '@mui/material/Button'; +import Container from '@mui/material/Container'; +import Link from '@mui/material/Link'; +import Paper from '@mui/material/Paper'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import PageHeader from 'components/layout/PageHeader'; +import { SkeletonHorizontalStack } from 'components/loading/SkeletonLoaders'; +import { EditCaptureI18N } from 'constants/i18n'; +import dayjs from 'dayjs'; +import { AnimalCaptureForm } from 'features/surveys/animals/profile/captures/capture-form/components/AnimalCaptureForm'; +import { FormikProps } from 'formik'; +import { APIError } from 'hooks/api/useAxios'; +import { useAnimalPageContext, useDialogContext, useProjectContext, useSurveyContext } from 'hooks/useContext'; +import { useCritterbaseApi } from 'hooks/useCritterbaseApi'; +import useDataLoader from 'hooks/useDataLoader'; +import { SKIP_CONFIRMATION_DIALOG, useUnsavedChangesDialog } from 'hooks/useUnsavedChangesDialog'; +import { IEditCaptureRequest } from 'interfaces/useCritterApi.interface'; +import { useEffect, useRef, useState } from 'react'; +import { Prompt, useHistory, useParams } from 'react-router'; +import { Link as RouterLink } from 'react-router-dom'; +import { formatCritterDetailsForBulkUpdate, formatLocation } from './utils'; + +/** + * Page for editing an existing animal capture record. + * + * @return {*} + */ +export const EditCapturePage = () => { + const history = useHistory(); + + const critterbaseApi = useCritterbaseApi(); + + const surveyContext = useSurveyContext(); + const projectContext = useProjectContext(); + const dialogContext = useDialogContext(); + const animalPageContext = useAnimalPageContext(); + + const urlParams: Record = useParams(); + + const surveyCritterId: number | undefined = Number(urlParams['survey_critter_id']); + const captureId: string | undefined = String(urlParams['capture_id']); + + const { locationChangeInterceptor } = useUnsavedChangesDialog(); + + const [isSaving, setIsSaving] = useState(false); + + const formikRef = useRef>(null); + + const { projectId, surveyId } = surveyContext; + + const critter = animalPageContext.critterDataLoader.data; + + const captureDataLoader = useDataLoader(() => critterbaseApi.capture.getCapture(captureId)); + + useEffect(() => { + captureDataLoader.load(); + }, [captureDataLoader]); + + const capture = captureDataLoader.data; + + // If the user has refreshed the page and cleared the context, or come to this page externally from a link, use the + // url params to set the selected animal in the context. The context then requests critter data from Critterbase. + useEffect(() => { + if (animalPageContext.selectedAnimal || !surveyCritterId) { + return; + } + + animalPageContext.setSelectedAnimalFromSurveyCritterId(surveyCritterId); + }, [animalPageContext, surveyCritterId]); + + if (!capture || !critter) { + return ; + } + + const handleCancel = () => { + history.push(`/admin/projects/${projectId}/surveys/${surveyId}/animals/details`); + }; + + /** + * Creates an Capture + * + * @return {*} + */ + const handleSubmit = async (values: IEditCaptureRequest) => { + setIsSaving(true); + + try { + const critterbaseCritterId = animalPageContext.selectedAnimal?.critterbase_critter_id; + + if (!values || !critterbaseCritterId || values.capture.capture_location?.geometry.type !== 'Point') { + return; + } + + // Format capture location + const captureLocation = formatLocation(values.capture.capture_location); + + // If release location is null, use the capture location, otherwise format release location + const releaseLocation = values.capture.release_location + ? formatLocation(values.capture.release_location) + : values.capture.capture_location; + + // Format capture timestamp + const captureTime = values.capture.capture_time ? ` ${values.capture.capture_time}-07:00` : 'T00:00:00-07:00'; + const captureTimestamp = dayjs(`${values.capture.capture_date}${captureTime}`).toDate(); + + // If release timestamp is null, use the capture timestamp, otherwise format release location + const releaseTime = (values.capture.release_time && ` ${values.capture.release_time}-07:00`) || 'T00:00:00-07:00'; + const releaseTimestamp = values.capture.release_date + ? dayjs(`${values.capture.release_date}${releaseTime}`).toDate() + : captureTimestamp; + + const { + qualitativeMeasurementsForDelete, + quantitativeMeasurementsForDelete, + markingsForDelete, + markingsForCreate, + markingsForUpdate, + qualitativeMeasurementsForCreate, + quantitativeMeasurementsForCreate, + qualitativeMeasurementsForUpdate, + quantitativeMeasurementsForUpdate + } = formatCritterDetailsForBulkUpdate(critter, values.markings, values.measurements, values.capture.capture_id); + + // Create new measurements added while editing the capture + if ( + qualitativeMeasurementsForCreate.length || + quantitativeMeasurementsForCreate.length || + markingsForCreate.length + ) { + await critterbaseApi.critters.bulkCreate({ + qualitative_measurements: qualitativeMeasurementsForCreate, + quantitative_measurements: quantitativeMeasurementsForCreate, + markings: markingsForCreate + }); + } + + // Update existing critter information + const response = await critterbaseApi.critters.bulkUpdate({ + captures: [ + { + capture_id: values.capture.capture_id, + capture_timestamp: captureTimestamp, + release_timestamp: releaseTimestamp, + capture_comment: values.capture.capture_comment ?? '', + release_comment: values.capture.release_comment ?? '', + capture_location: captureLocation, + release_location: releaseLocation ?? captureLocation, + critter_id: critterbaseCritterId + } + ], + markings: [...markingsForUpdate, ...markingsForDelete], + qualitative_measurements: [...qualitativeMeasurementsForUpdate, ...qualitativeMeasurementsForDelete], + quantitative_measurements: [...quantitativeMeasurementsForUpdate, ...quantitativeMeasurementsForDelete] + }); + + if (!response) { + dialogContext.setErrorDialog({ + dialogTitle: EditCaptureI18N.createErrorTitle, + dialogText: EditCaptureI18N.createErrorText, + open: true, + onClose: () => { + dialogContext.setErrorDialog({ open: false }); + }, + onOk: () => { + dialogContext.setErrorDialog({ open: false }); + } + }); + return; + } + + // Refresh page + animalPageContext.critterDataLoader.refresh(critterbaseCritterId); + + history.push(`/admin/projects/${projectId}/surveys/${surveyId}/animals/details`, SKIP_CONFIRMATION_DIALOG); + } catch (error) { + const apiError = error as APIError; + + dialogContext.setErrorDialog({ + dialogTitle: EditCaptureI18N.createErrorTitle, + dialogText: EditCaptureI18N.createErrorText, + dialogErrorDetails: apiError?.errors, + open: true, + onClose: () => { + dialogContext.setErrorDialog({ open: false }); + }, + onOk: () => { + dialogContext.setErrorDialog({ open: false }); + } + }); + } finally { + setIsSaving(false); + } + }; + + const [captureDate, captureTime] = dayjs(capture.capture_timestamp).format('YYYY-MM-DD HH:mm:ss').split(' '); + const [releaseDate, releaseTime] = dayjs(capture.release_timestamp).format('YYYY-MM-DD HH:mm:ss').split(' '); + + // Initial formik values + const initialFormikValues: IEditCaptureRequest = { + capture: { + capture_id: capture.capture_id, + capture_comment: capture.capture_comment ?? '', + capture_timestamp: capture.capture_timestamp, + capture_date: captureDate, + capture_time: captureTime, + capture_location: { + type: 'Feature', + geometry: { + type: 'Point', + coordinates: [capture.capture_location.longitude ?? 0, capture.capture_location.latitude ?? 0] + }, + properties: {} + }, + release_location: { + type: 'Feature', + geometry: { + type: 'Point', + coordinates: [capture.release_location?.longitude ?? 0, capture.release_location?.latitude ?? 0] + }, + properties: {} + }, + release_timestamp: capture.release_timestamp ?? '', + release_date: releaseDate, + release_time: releaseTime, + release_comment: capture.release_comment ?? '' + }, + markings: + critter.markings + .filter((marking) => marking.capture_id === capture.capture_id) + .map((marking) => ({ + marking_id: marking.marking_id, + identifier: marking.identifier, + comment: marking.comment, + capture_id: marking.capture_id, + mortality_id: marking.mortality_id, + taxon_marking_body_location_id: marking.taxon_marking_body_location_id, + marking_type_id: marking.marking_type_id, + primary_colour_id: marking.primary_colour_id, + secondary_colour_id: marking.secondary_colour_id + })) ?? [], + measurements: [ + ...(critter.measurements.qualitative + .filter((measurement) => measurement.capture_id === capture.capture_id) + .map((measurement) => ({ + measurement_qualitative_id: measurement.measurement_qualitative_id, + taxon_measurement_id: measurement.taxon_measurement_id, + capture_id: measurement.capture_id, + mortality_id: measurement.mortality_id, + qualitative_option_id: measurement.qualitative_option_id, + measurement_comment: measurement.measurement_comment, + measured_timestamp: measurement.measured_timestamp + })) ?? []), + ...(critter.measurements.quantitative + .filter((measurement) => measurement.capture_id === capture.capture_id) + .map((measurement) => ({ + measurement_quantitative_id: measurement.measurement_quantitative_id, + taxon_measurement_id: measurement.taxon_measurement_id, + capture_id: measurement.capture_id, + mortality_id: measurement.mortality_id, + measurement_comment: measurement.measurement_comment, + measured_timestamp: measurement.measured_timestamp, + value: measurement.value + })) ?? []) + ] + }; + + return ( + <> + + '}> + + {projectContext.projectDataLoader.data?.projectData.project.project_name} + + + {surveyContext.surveyDataLoader.data?.surveyData.survey_details.survey_name} + + + Manage Animals + + + {critter.animal_id} + + + Edit Capture + + + ) : ( + + ) + } + buttonJSX={ + + formikRef.current?.submitForm()}> + Save and Exit + + + + } + /> + + + + handleSubmit(formikData)} + formikRef={formikRef} + /> + + { + formikRef.current?.submitForm(); + }}> + Save and Exit + + + + + + + ); +}; diff --git a/app/src/features/surveys/animals/profile/captures/capture-form/edit/utils.ts b/app/src/features/surveys/animals/profile/captures/capture-form/edit/utils.ts new file mode 100644 index 0000000000..57ed035d9f --- /dev/null +++ b/app/src/features/surveys/animals/profile/captures/capture-form/edit/utils.ts @@ -0,0 +1,189 @@ +import { + isQualitativeMeasurementCreate, + isQualitativeMeasurementUpdate, + isQuantitativeMeasurementCreate, + isQuantitativeMeasurementUpdate +} from 'features/surveys/animals/profile/measurements/utils'; +import { Feature } from 'geojson'; +import { + ICritterDetailedResponse, + IMarkingPostData, + IQualitativeMeasurementCreate, + IQualitativeMeasurementUpdate, + IQuantitativeMeasurementCreate, + IQuantitativeMeasurementUpdate +} from 'interfaces/useCritterApi.interface'; + +/** + * Formats a location object into the required structure. + * + * @param {Feature} location The location object to be formatted. + * @return {*} The formatted location object. + */ +export const formatLocation = (location: Feature) => { + if (location.geometry.type === 'Point') + return { + longitude: location.geometry.coordinates[0], + latitude: location.geometry.coordinates[1], + coordinate_uncertainty: 0, + coordinate_uncertainty_units: 'm' + }; +}; + +/** + * Formats critter details for bulk update, including markings and measurements. + * + * @param {ICritterDetailedResponse} critter The critter object containing existing details. + * @param {IMarkingPostData[]} markings Array of markings for the critter. + * @param {((IQuantitativeMeasurementUpdate | IQualitativeMeasurementUpdate)[])} measurements Array of measurements for the critter. + * @param {string} captureId The capture object containing capture details. + * @return {*} Formatted critter details for bulk update. + */ +export const formatCritterDetailsForBulkUpdate = ( + critter: ICritterDetailedResponse, + markings: IMarkingPostData[], + measurements: ( + | IQuantitativeMeasurementCreate + | IQualitativeMeasurementCreate + | IQuantitativeMeasurementUpdate + | IQualitativeMeasurementUpdate + )[], + captureId: string +) => { + // Find qualitative measurements to delete + const qualitativeMeasurementsForDelete = + critter.measurements.qualitative + // Filter out measurements that are not on the current capture + .filter( + (existingQualitativeMeasurementsOnCritter) => existingQualitativeMeasurementsOnCritter.capture_id === captureId + ) + // Filter out measurements that are not in the incoming measurements + .filter( + (existingQualitativeMeasurementsOnCapture) => + !measurements.some( + (incomingMeasurements) => + isQualitativeMeasurementUpdate(incomingMeasurements) && + incomingMeasurements.measurement_qualitative_id === + existingQualitativeMeasurementsOnCapture.measurement_qualitative_id + ) + ) + // The remaining measurements are the ones to delete from the critter for the current capture + .map((item) => ({ ...item, _delete: true })) ?? []; + + // Find quantitative measurements to delete + const quantitativeMeasurementsForDelete = + critter.measurements.quantitative + // Filter out measurements that are not on the current capture + .filter( + (existingQUantitativeMeasurementsOnCritter) => + existingQUantitativeMeasurementsOnCritter.capture_id === captureId + ) + // Filter out measurements that are not in the incoming measurements + .filter( + (existingQuantitativeMeasurementsOnCapture) => + !measurements.some( + (incomingMeasurements) => + isQuantitativeMeasurementUpdate(incomingMeasurements) && + incomingMeasurements.measurement_quantitative_id === + existingQuantitativeMeasurementsOnCapture.measurement_quantitative_id + ) + ) + // The remaining measurements are the ones to delete from the critter for the current capture + .map((item) => ({ ...item, _delete: true })) ?? []; + + // Find markings to delete + const markingsForDelete = critter.markings + // Filter out markings that are not on the current capture + .filter((existingMarkingsOnCritter) => existingMarkingsOnCritter.capture_id === captureId) + // Filter out markings that are not in the incoming markings + .filter( + (existingmarkingsOnCapture) => + !markings.some((incomingMarking) => incomingMarking.marking_id === existingmarkingsOnCapture.marking_id) + ) + // The remaining markings are the ones to delete from the critter for the current capture + .map((item) => ({ ...item, critter_id: critter.critter_id, _delete: true })); + + // Find markings for create + const markingsForCreate = markings + // Filter out markings that have a marking_id (i.e. they are existing markings that need to be updated, not created) + .filter((marking) => !marking.marking_id) + .map((marking) => ({ + ...marking, + marking_id: marking.marking_id, + critter_id: critter.critter_id, + capture_id: captureId + })); + + // Find markings for update + const markingsForUpdate = markings + // Filter out markings that do not have a marking_id (i.e. they are new markings that need to be created, not updated) + .filter((marking) => marking.marking_id) + .map((marking) => ({ + ...marking, + marking_id: marking.marking_id, + critter_id: critter.critter_id, + capture_id: captureId + })); + + // Find qualitative measurements for create + const qualitativeMeasurementsForCreate = measurements + .filter(isQualitativeMeasurementCreate) + .map((measurement: IQualitativeMeasurementCreate) => ({ + critter_id: critter.critter_id, + capture_id: captureId, + taxon_measurement_id: measurement.taxon_measurement_id, + qualitative_option_id: measurement.qualitative_option_id, + measured_timestamp: measurement.measured_timestamp, + measurement_comment: measurement.measurement_comment + })); + + // Find quantitative measurements for create + const quantitativeMeasurementsForCreate = measurements + .filter(isQuantitativeMeasurementCreate) + .map((measurement: IQuantitativeMeasurementCreate) => ({ + critter_id: critter.critter_id, + capture_id: captureId, + taxon_measurement_id: measurement.taxon_measurement_id, + value: measurement.value, + measured_timestamp: measurement.measured_timestamp, + measurement_comment: measurement.measurement_comment + })); + + // Find qualitative measurements for update + const qualitativeMeasurementsForUpdate = measurements + .filter(isQualitativeMeasurementUpdate) + .map((measurement: IQualitativeMeasurementUpdate) => ({ + // critter_id: critter.critter_id, + capture_id: captureId, + measurement_qualitative_id: measurement.measurement_qualitative_id, + taxon_measurement_id: measurement.taxon_measurement_id, + qualitative_option_id: measurement.qualitative_option_id, + measured_timestamp: measurement.measured_timestamp, + measurement_comment: measurement.measurement_comment + })); + + // Find quantitative measurements for update + const quantitativeMeasurementsForUpdate = measurements + .filter(isQuantitativeMeasurementUpdate) + .map((measurement: IQuantitativeMeasurementUpdate) => ({ + critter_id: critter.critter_id, + measurement_quantitative_id: measurement.measurement_quantitative_id, + capture_id: captureId, + taxon_measurement_id: measurement.taxon_measurement_id, + value: measurement.value, + measured_timestamp: measurement.measured_timestamp, + measurement_comment: measurement.measurement_comment + })); + + return { + qualitativeMeasurementsForDelete, + quantitativeMeasurementsForDelete, + markingsForDelete, + markingsForCreate, + markingsForUpdate, + qualitativeMeasurementsForCreate, + quantitativeMeasurementsForCreate, + qualitativeMeasurementsForUpdate, + quantitativeMeasurementsForUpdate + }; +}; diff --git a/app/src/features/surveys/animals/profile/captures/components/AnimalCaptureCardContainer.tsx b/app/src/features/surveys/animals/profile/captures/components/AnimalCaptureCardContainer.tsx new file mode 100644 index 0000000000..0bb004e6d3 --- /dev/null +++ b/app/src/features/surveys/animals/profile/captures/components/AnimalCaptureCardContainer.tsx @@ -0,0 +1,208 @@ +import { mdiChevronDown, mdiDotsVertical, mdiPencilOutline, mdiTrashCanOutline } from '@mdi/js'; +import Icon from '@mdi/react'; +import Accordion from '@mui/material/Accordion'; +import AccordionDetails from '@mui/material/AccordionDetails'; +import AccordionSummary from '@mui/material/AccordionSummary'; +import Box from '@mui/material/Box'; +import grey from '@mui/material/colors/grey'; +import IconButton from '@mui/material/IconButton'; +import ListItemIcon from '@mui/material/ListItemIcon'; +import ListItemText from '@mui/material/ListItemText'; +import Menu, { MenuProps } from '@mui/material/Menu'; +import MenuItem from '@mui/material/MenuItem'; +import Paper from '@mui/material/Paper'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import YesNoDialog from 'components/dialog/YesNoDialog'; +import { DATE_FORMAT } from 'constants/dateTimeFormats'; +import { ISurveyCritter } from 'contexts/animalPageContext'; +import { useSurveyContext } from 'hooks/useContext'; +import { useState } from 'react'; +import { Link as RouterLink } from 'react-router-dom'; +import { getFormattedDate } from 'utils/Utils'; +import { ICaptureWithSupplementaryData } from '../AnimalCaptureContainer'; +import { AnimalCaptureCardDetailsContainer } from './capture-card-details/AnimalCaptureCardDetailsContainer'; + +interface IAnimalCaptureCardContainer { + captures: ICaptureWithSupplementaryData[]; + selectedAnimal: ISurveyCritter; + handleDelete: (selectedCapture: string, critterbase_critter_id: string) => Promise; +} +/** + * Returns accordion cards for displaying animal capture details on the animal profile page + * + * @param {IAnimalCaptureCardContainer} props + * @return {*} + */ +export const AnimalCaptureCardContainer = (props: IAnimalCaptureCardContainer) => { + const { captures, selectedAnimal, handleDelete } = props; + + const [selectedCapture, setSelectedCapture] = useState(null); + const [captureAnchorEl, setCaptureAnchorEl] = useState(null); + const [captureForDelete, setCaptureForDelete] = useState(); + + const { projectId, surveyId } = useSurveyContext(); + + return ( + <> + {/* 3 DOT ACTION MENU */} + {selectedCapture && ( + setCaptureAnchorEl(null)} + anchorEl={captureAnchorEl} + anchorOrigin={{ + vertical: 'top', + horizontal: 'right' + }} + transformOrigin={{ + vertical: 'top', + horizontal: 'right' + }}> + + + + + + Edit Details + + + { + setCaptureAnchorEl(null); + setCaptureForDelete(true); + }}> + + + + Delete + + + )} + + {/* DELETE CONFIRMATION DIALOG */} + {captureForDelete && selectedCapture && ( + { + setCaptureForDelete(false); + handleDelete(selectedCapture, selectedAnimal.critterbase_critter_id); + }} + onClose={() => setCaptureForDelete(false)} + onNo={() => setCaptureForDelete(false)} + /> + )} + + {captures.length ? ( + captures.map((capture) => { + /* CAPTURE DETAILS */ + return ( + + + } + aria-controls="panel1bh-content" + sx={{ + flex: '1 1 auto', + mr: 1, + pr: 8.5, + minHeight: 55, + overflow: 'hidden', + border: 0, + '& .MuiAccordionSummary-content': { + flex: '1 1 auto', + py: 0, + pl: 0, + overflow: 'hidden', + whiteSpace: 'nowrap' + } + }}> + + + {getFormattedDate(DATE_FORMAT.MediumDateTimeFormat, capture.capture_timestamp)}  + + {capture.capture_location.latitude && capture.capture_location.longitude && ( + + + {capture.capture_location.longitude},  + {capture.capture_location.latitude} + + + )} + + + ) => { + setCaptureAnchorEl(event.currentTarget); + setSelectedCapture(capture.capture_id); + }} + aria-label="sample-site-settings"> + + + + + + + + ); + }) + ) : ( + /* NO CAPTURE RECORDS */ + + + This animal has no captures + + + )} + + ); +}; diff --git a/app/src/features/surveys/animals/profile/captures/components/AnimalCapturesMap.tsx b/app/src/features/surveys/animals/profile/captures/components/AnimalCapturesMap.tsx new file mode 100644 index 0000000000..d0a483ba3f --- /dev/null +++ b/app/src/features/surveys/animals/profile/captures/components/AnimalCapturesMap.tsx @@ -0,0 +1,49 @@ +import Box from '@mui/system/Box'; +import { IStaticLayer } from 'components/map/components/StaticLayers'; +import SurveyMap from 'features/surveys/view/SurveyMap'; +import { Feature } from 'geojson'; +import { ICaptureResponse } from 'interfaces/useCritterApi.interface'; +import { isDefined } from 'utils/Utils'; + +interface IAnimalCapturesMapProps { + captures: ICaptureResponse[]; + isLoading: boolean; +} + +/** + * Wrapper around the Survey Map component for displaying the selected animal's captures on the map + * + * @param {IAnimalCapturesMapProps} props + * @return {*} + */ +export const AnimalCapturesMap = (props: IAnimalCapturesMapProps) => { + const { captures, isLoading } = props; + + const captureMapFeatures = captures + .filter((capture) => isDefined(capture.capture_location?.latitude) && isDefined(capture.capture_location.longitude)) + .map((capture) => ({ + type: 'Feature', + geometry: { + type: 'Point', + coordinates: [capture.capture_location.longitude, capture.capture_location.latitude] + }, + properties: { captureId: capture.capture_id, date: capture.capture_timestamp } + })) as Feature[]; + + const staticLayers: IStaticLayer[] = captureMapFeatures.map((feature, index) => ({ + layerName: 'Captures', + popupRecordTitle: 'Capture Location', + features: [ + { + key: `${feature.geometry}-${index}`, + geoJSON: feature + } + ] + })); + + return ( + + + + ); +}; diff --git a/app/src/features/surveys/animals/profile/captures/components/AnimalCapturesToolbar.tsx b/app/src/features/surveys/animals/profile/captures/components/AnimalCapturesToolbar.tsx new file mode 100644 index 0000000000..14e7be7575 --- /dev/null +++ b/app/src/features/surveys/animals/profile/captures/components/AnimalCapturesToolbar.tsx @@ -0,0 +1,51 @@ +import { mdiPlus } from '@mdi/js'; +import { Icon } from '@mdi/react'; +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; +import Toolbar from '@mui/material/Toolbar'; +import Typography from '@mui/material/Typography'; + +interface ICapturesToolbarProps { + capturesCount: number; + onAddAnimalCapture: () => void; +} + +/** + * Toolbar for actions affecting an animal's captures, ie. add a new capture + * + * @param {ICapturesToolbarProps} props + * @returns {*} + */ +export const AnimalCapturesToolbar = (props: ICapturesToolbarProps) => { + const { capturesCount, onAddAnimalCapture } = props; + + return ( + + + Captures + + ({capturesCount}) + + + + + + + ); +}; diff --git a/app/src/features/surveys/animals/profile/captures/components/capture-card-details/AnimalCaptureCardDetailsContainer.tsx b/app/src/features/surveys/animals/profile/captures/components/capture-card-details/AnimalCaptureCardDetailsContainer.tsx new file mode 100644 index 0000000000..514f630bd5 --- /dev/null +++ b/app/src/features/surveys/animals/profile/captures/components/capture-card-details/AnimalCaptureCardDetailsContainer.tsx @@ -0,0 +1,41 @@ +import Grid from '@mui/material/Grid'; +import Stack from '@mui/material/Stack'; +import { CaptureDetails } from 'features/surveys/animals/profile/captures/components/capture-card-details/components/CaptureDetails'; +import { ReleaseDetails } from 'features/surveys/animals/profile/captures/components/capture-card-details/components/ReleaseDetails'; +import { MarkingDetails } from 'features/surveys/animals/profile/components/MarkingDetails'; +import { MeasurementDetails } from 'features/surveys/animals/profile/components/MeasurementDetails'; +import { ICaptureWithSupplementaryData } from '../../AnimalCaptureContainer'; + +interface IAnimalCaptureCardDetailsContainerProps { + capture: ICaptureWithSupplementaryData; +} + +/** + * Details displayed with the accordion component displaying an animal capture + * + * @param {IAnimalCaptureCardDetailsContainerProps} props + * @return {*} + */ +export const AnimalCaptureCardDetailsContainer = (props: IAnimalCaptureCardDetailsContainerProps) => { + const { capture } = props; + + return ( + + + + + + + + + + + + + ); +}; diff --git a/app/src/features/surveys/animals/profile/captures/components/capture-card-details/components/CaptureDetails.tsx b/app/src/features/surveys/animals/profile/captures/components/capture-card-details/components/CaptureDetails.tsx new file mode 100644 index 0000000000..9f75a76c6e --- /dev/null +++ b/app/src/features/surveys/animals/profile/captures/components/capture-card-details/components/CaptureDetails.tsx @@ -0,0 +1,75 @@ +import Box from '@mui/material/Box'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import { DATE_FORMAT } from 'constants/dateTimeFormats'; +import { ICaptureWithSupplementaryData } from 'features/surveys/animals/profile/captures/AnimalCaptureContainer'; +import { getFormattedDate } from 'utils/Utils'; + +interface ICaptureDetailsProps { + capture: ICaptureWithSupplementaryData; +} + +/** + * Component for displaying animal capture 'capture' details. + * + * @param {ICaptureDetailsProps} props + * @return {*} + */ +export const CaptureDetails = (props: ICaptureDetailsProps) => { + const { capture } = props; + + const captureTimestamp = capture.capture_timestamp; + const captureLocation = capture.capture_location; + const captureComment = capture.capture_comment; + + if (!captureTimestamp && (!captureLocation.latitude || !captureLocation.longitude) && !captureComment) { + return null; + } + + return ( + + + + + Capture time + + + {getFormattedDate(DATE_FORMAT.MediumDateTimeFormat, captureTimestamp)} + + + + + + Capture location + + + {captureLocation.longitude}, {captureLocation.latitude} + + + + + {captureComment && ( + + + Capture comment + + + {captureComment || 'None'} + + + )} + + ); +}; diff --git a/app/src/features/surveys/animals/profile/captures/components/capture-card-details/components/ReleaseDetails.tsx b/app/src/features/surveys/animals/profile/captures/components/capture-card-details/components/ReleaseDetails.tsx new file mode 100644 index 0000000000..51eb3e93b9 --- /dev/null +++ b/app/src/features/surveys/animals/profile/captures/components/capture-card-details/components/ReleaseDetails.tsx @@ -0,0 +1,81 @@ +import Box from '@mui/material/Box'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import { DATE_FORMAT } from 'constants/dateTimeFormats'; +import { ICaptureWithSupplementaryData } from 'features/surveys/animals/profile/captures/AnimalCaptureContainer'; +import { getFormattedDate } from 'utils/Utils'; + +interface IReleaseDetailsProps { + capture: ICaptureWithSupplementaryData; +} + +/** + * Component for displaying animal capture 'release' details. + * + * @param {IReleaseDetailsProps} props + * @return {*} + */ +export const ReleaseDetails = (props: IReleaseDetailsProps) => { + const { capture } = props; + + const releaseTimestamp = capture.release_timestamp; + const releaseLocation = capture.release_location; + const releaseComment = capture.release_comment; + + if (!releaseTimestamp && !releaseLocation && !releaseComment) { + return null; + } + + return ( + + + {releaseTimestamp && ( + + + Release time + + + {getFormattedDate(DATE_FORMAT.MediumDateTimeFormat, releaseTimestamp)} + + + )} + + {releaseLocation && ( + + + Release location + + {releaseLocation && ( + + {releaseLocation.longitude}, {releaseLocation.latitude} + + )} + + )} + + + {releaseComment && ( + + + Release comment + + + {releaseComment || 'None'} + + + )} + + ); +}; diff --git a/app/src/features/surveys/animals/profile/components/MarkingDetails.tsx b/app/src/features/surveys/animals/profile/components/MarkingDetails.tsx new file mode 100644 index 0000000000..1f2d9fd73f --- /dev/null +++ b/app/src/features/surveys/animals/profile/components/MarkingDetails.tsx @@ -0,0 +1,45 @@ +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import { MarkingCard } from 'features/surveys/animals/profile/markings/MarkingCard'; +import { IMarkingResponse } from 'interfaces/useCritterApi.interface'; + +interface IMarkingDetailsProps { + markings: IMarkingResponse[]; +} + +/** + * Generic component to display animal marking details. + * + * @param {IMarkingDetailsProps} props + * @return {*} + */ +export const MarkingDetails = (props: IMarkingDetailsProps) => { + const { markings } = props; + + if (!markings?.length) { + return null; + } + + return ( + + + Markings + + + {markings.map((marking, index) => ( + + + + ))} + + + ); +}; diff --git a/app/src/features/surveys/animals/profile/components/MeasurementDetails.tsx b/app/src/features/surveys/animals/profile/components/MeasurementDetails.tsx new file mode 100644 index 0000000000..b67f05daaf --- /dev/null +++ b/app/src/features/surveys/animals/profile/components/MeasurementDetails.tsx @@ -0,0 +1,44 @@ +import Box from '@mui/material/Box'; +import grey from '@mui/material/colors/grey'; +import Paper from '@mui/material/Paper'; +import Typography from '@mui/material/Typography'; +import { IQualitativeMeasurementResponse, IQuantitativeMeasurementResponse } from 'interfaces/useCritterApi.interface'; +import startCase from 'lodash-es/startCase'; +import { v4 } from 'uuid'; + +interface IMeasurementDetailsProps { + measurements: { qualitative: IQualitativeMeasurementResponse[]; quantitative: IQuantitativeMeasurementResponse[] }; +} + +/** + * Generic component to display animal measurement details. + * + * @param {IMeasurementDetailsProps} props + * @return {*} + */ +export const MeasurementDetails = (props: IMeasurementDetailsProps) => { + const { measurements } = props; + + if (!measurements.qualitative.length && !measurements.quantitative.length) { + return null; + } + + const allMeasurements = [...measurements.quantitative, ...measurements.qualitative]; + + return ( + + + Measurements + + + {allMeasurements.map((measurement) => ( + + + {startCase(measurement.measurement_name)}: {measurement.value} + + + ))} + + + ); +}; diff --git a/app/src/features/surveys/animals/profile/details/AnimalDetailsContainer.tsx b/app/src/features/surveys/animals/profile/details/AnimalDetailsContainer.tsx new file mode 100644 index 0000000000..5e9d45bbc1 --- /dev/null +++ b/app/src/features/surveys/animals/profile/details/AnimalDetailsContainer.tsx @@ -0,0 +1,47 @@ +import Box from '@mui/material/Box'; +import Skeleton from '@mui/material/Skeleton'; +import Stack from '@mui/material/Stack'; +import { AnimalProfileHeader } from 'features/surveys/animals/profile/details/components/AnimalProfileHeader'; +import { useAnimalPageContext } from 'hooks/useContext'; + +/** + * Returns header component for an animal's profile, displayed after selecting an animal + * + * @return {*} + */ +export const AnimalDetailsContainer = () => { + const animalPageContext = useAnimalPageContext(); + + if ( + !animalPageContext.selectedAnimal || + animalPageContext.critterDataLoader.isLoading || + !animalPageContext.critterDataLoader.data + ) { + return ( + + {/* Title */} + + {/* Species/Status/ID */} + + + + + + + + + + + {/* Divider */} + + {/* Attributes */} + + + + + + ); + } + + return ; +}; diff --git a/app/src/features/surveys/animals/profile/details/components/AnimalAttributeItem.tsx b/app/src/features/surveys/animals/profile/details/components/AnimalAttributeItem.tsx new file mode 100644 index 0000000000..4cd987f5cc --- /dev/null +++ b/app/src/features/surveys/animals/profile/details/components/AnimalAttributeItem.tsx @@ -0,0 +1,26 @@ +import Icon from '@mdi/react'; +import Box from '@mui/material/Box'; +import grey from '@mui/material/colors/grey'; +import Typography from '@mui/material/Typography'; + +interface IAnimalAttributeItemProps { + startIcon?: string; + text: string | JSX.Element; +} + +/** + * Component to display text with an icon within an animal's profile + * + * @param props + * @returns + */ +export const AnimalAttributeItem = (props: IAnimalAttributeItemProps) => { + return ( + + {props.startIcon && } + + {props.text} + + + ); +}; diff --git a/app/src/features/surveys/animals/profile/details/components/AnimalProfileHeader.tsx b/app/src/features/surveys/animals/profile/details/components/AnimalProfileHeader.tsx new file mode 100644 index 0000000000..48e99e2e6e --- /dev/null +++ b/app/src/features/surveys/animals/profile/details/components/AnimalProfileHeader.tsx @@ -0,0 +1,116 @@ +import { mdiCheckboxMultipleBlankOutline, mdiInformationOutline, mdiPlusBoxOutline } from '@mdi/js'; +import { Icon } from '@mdi/react'; +import Box from '@mui/material/Box'; +import green from '@mui/material/colors/green'; +import grey from '@mui/material/colors/grey'; +import red from '@mui/material/colors/red'; +import Divider from '@mui/material/Divider'; +import IconButton from '@mui/material/IconButton'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import ColouredRectangleChip from 'components/chips/ColouredRectangleChip'; +import { useDialogContext } from 'hooks/useContext'; +import { useCopyToClipboard } from 'hooks/useCopyToClipboard'; +import { ICritterDetailedResponse } from 'interfaces/useCritterApi.interface'; +import { setMessageSnackbar } from 'utils/Utils'; +import { ScientificNameTypography } from '../../../components/ScientificNameTypography'; +import { AnimalAttributeItem } from './AnimalAttributeItem'; + +interface IAnimalProfileHeaderProps { + critter: ICritterDetailedResponse; +} + +/** + * Returns header component for an animal's profile, displayed after selecting an animal + * + * @param {IAnimalProfileHeaderProps} props + * @return {*} + */ +export const AnimalProfileHeader = (props: IAnimalProfileHeaderProps) => { + const { critter } = props; + + const dialogContext = useDialogContext(); + + const { copyToClipboard } = useCopyToClipboard(); + + return ( + <> + + {critter.animal_id} + + + + + } + startIcon={mdiInformationOutline} + /> + {critter.wlh_id && } + + + + + + + Unique ID:  + + {critter.critter_id} + { + if (!critter.critter_id) { + return; + } + + copyToClipboard(critter.critter_id, () => + setMessageSnackbar('Unique ID copied to clipboard', dialogContext) + ).catch((error) => { + console.error('Could not copy text: ', error); + }); + }}> + + + + + + + + + Sex + + + {critter.sex} + + + {critter.collection_units.map((unit, index) => ( + + + {unit.category_name} + + + {unit.unit_name} + + + ))} + + + ); +}; diff --git a/app/src/features/surveys/animals/profile/markings/MarkingCard.tsx b/app/src/features/surveys/animals/profile/markings/MarkingCard.tsx new file mode 100644 index 0000000000..3c8a7809e5 --- /dev/null +++ b/app/src/features/surveys/animals/profile/markings/MarkingCard.tsx @@ -0,0 +1,101 @@ +import { mdiDotsVertical } from '@mdi/js'; +import { Icon } from '@mdi/react'; +import Box from '@mui/material/Box'; +import grey from '@mui/material/colors/grey'; +import IconButton from '@mui/material/IconButton'; +import Paper from '@mui/material/Paper'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import startCase from 'lodash-es/startCase'; + +interface IMarkingCardProps { + editable: boolean; + primary_colour_label?: string; + secondary_colour_label?: string; + identifier: string | number | null; + comment: string | null; + marking_type_label: string; + marking_body_location_label: string; + handleMarkingMenuClick?: (event: React.MouseEvent) => void; +} + +/** + * Card for displaying information about markings on the animal form + * + * @param {IMarkingCardProps} props + * @return {*} + */ +export const MarkingCard = (props: IMarkingCardProps) => { + const { + editable, + primary_colour_label, + secondary_colour_label, + comment, + identifier, + marking_body_location_label, + marking_type_label, + handleMarkingMenuClick + } = props; + + return ( + + + + {startCase(marking_type_label)} + + {editable && ( + + + + )} + + + {comment} + + + + + Position + + + {marking_body_location_label} + + + {identifier && ( + + + Identifier + + + {identifier} + + + )} + {primary_colour_label && ( + + + Primary colour + + + {primary_colour_label} + + + )} + {secondary_colour_label && ( + + + Secondary colour + + + {secondary_colour_label} + + + )} + + + ); +}; diff --git a/app/src/features/surveys/animals/profile/markings/MarkingsDialog.tsx b/app/src/features/surveys/animals/profile/markings/MarkingsDialog.tsx new file mode 100644 index 0000000000..d43d006998 --- /dev/null +++ b/app/src/features/surveys/animals/profile/markings/MarkingsDialog.tsx @@ -0,0 +1,120 @@ +import Stack from '@mui/material/Stack'; +import EditDialog from 'components/dialog/EditDialog'; +import AutocompleteField from 'components/fields/AutocompleteField'; +import CustomTextField from 'components/fields/CustomTextField'; +import { IMarkingPostData } from 'interfaces/useCritterApi.interface'; +import { + IMarkingBodyLocationResponse, + IMarkingColourOption, + IMarkingTypeResponse +} from 'interfaces/useMarkingApi.interface'; +import yup from 'utils/YupSchema'; +import { v4 } from 'uuid'; + +interface IMarkingsDialogProps { + initialValues?: IMarkingPostData; + markingColours: IMarkingColourOption[]; + markingBodyLocations: IMarkingBodyLocationResponse[]; + markingTypes: IMarkingTypeResponse[]; + isDialogOpen: boolean; + handleClose: () => void; + handleSave: (data: IMarkingPostData) => void; +} + +/** + * Animal markings dialog. + * + * @param {IMarkingsDialogProps} props + * @return {*} + */ +export const MarkingsDialog = (props: IMarkingsDialogProps) => { + const { initialValues, isDialogOpen, handleSave, handleClose, markingBodyLocations, markingColours, markingTypes } = + props; + + const animalMarkingYupSchema = yup.object({ + marking_type_id: yup.string().required('Marking type is required.'), + taxon_marking_body_location_id: yup.string().required('Marking body location is required.'), + identifier: yup.string().nullable(), + primary_colour_id: yup.string().nullable(), + secondary_colour_id: yup.string().nullable(), + comment: yup.string().nullable() + }); + + return ( + + + ({ + value: item.marking_type_id, + label: item.name + })) ?? [] + } + /> + + ({ + value: item.taxon_marking_body_location_id, + label: item.body_location + })) ?? [] + } + /> + + + + ({ value: item.colour_id, label: item.colour })) ?? []} + /> + ({ value: item.colour_id, label: item.colour })) ?? []} + /> + + + + ) + }} + /> + ); +}; diff --git a/app/src/features/surveys/animals/profile/markings/MarkingsForm.tsx b/app/src/features/surveys/animals/profile/markings/MarkingsForm.tsx new file mode 100644 index 0000000000..dd56a0e7fe --- /dev/null +++ b/app/src/features/surveys/animals/profile/markings/MarkingsForm.tsx @@ -0,0 +1,178 @@ +import { mdiPencilOutline, mdiPlus, mdiTrashCanOutline } from '@mdi/js'; +import { Icon } from '@mdi/react'; +import Button from '@mui/material/Button'; +import Collapse from '@mui/material/Collapse'; +import ListItemIcon from '@mui/material/ListItemIcon'; +import ListItemText from '@mui/material/ListItemText'; +import Menu, { MenuProps } from '@mui/material/Menu'; +import MenuItem from '@mui/material/MenuItem'; +import { Stack } from '@mui/system'; +import { MarkingsDialog } from 'features/surveys/animals/profile/markings/MarkingsDialog'; +import { FieldArray, FieldArrayRenderProps, useFormikContext } from 'formik'; +import { useAnimalPageContext } from 'hooks/useContext'; +import { useCritterbaseApi } from 'hooks/useCritterbaseApi'; +import useDataLoader from 'hooks/useDataLoader'; +import { IMarkings } from 'interfaces/useCritterApi.interface'; +import { useEffect, useState } from 'react'; +import { TransitionGroup } from 'react-transition-group'; +import { MarkingCard } from './MarkingCard'; + +/** + * Returns the control for applying markings to an animal on the animal form + * + * @template FormikValuesType + * @return {*} + */ +export const MarkingsForm = () => { + const { values } = useFormikContext(); + + const animalPageContext = useAnimalPageContext(); + + const critterbaseApi = useCritterbaseApi(); + + const [isDialogOpen, setIsDialogOpen] = useState(false); + const [selectedMarking, setSelectedMarking] = useState(null); + const [markingAnchorEl, setMarkingAnchorEl] = useState(null); + + // Get available marking types + const markingTypesDataLoader = useDataLoader(() => critterbaseApi.marking.getMarkingTypeOptions()); + + // Get available marking body positions + const markingBodyLocationDataLoader = useDataLoader((tsn: number) => + critterbaseApi.marking.getTsnMarkingBodyLocationOptions(tsn) + ); + + // Get available marking colours + const markingColoursDataLoader = useDataLoader(() => critterbaseApi.marking.getMarkingColourOptions()); + + useEffect(() => { + markingTypesDataLoader.load(); + }, [markingTypesDataLoader]); + + useEffect(() => { + markingColoursDataLoader.load(); + }, [markingColoursDataLoader]); + + useEffect(() => { + if (!animalPageContext.critterDataLoader.data) { + return; + } + + markingBodyLocationDataLoader.load(animalPageContext.critterDataLoader.data.itis_tsn); + }, [animalPageContext.critterDataLoader.data, markingBodyLocationDataLoader]); + + return ( + ( + <> + {/* CONTEXT MENU ACTIONS ON MARKING */} + {selectedMarking !== null && ( + { + setMarkingAnchorEl(null); + setSelectedMarking(null); + }} + anchorEl={markingAnchorEl} + anchorOrigin={{ + vertical: 'top', + horizontal: 'right' + }} + transformOrigin={{ + vertical: 'top', + horizontal: 'right' + }}> + { + setMarkingAnchorEl(null); + setIsDialogOpen(true); + }}> + + + + Edit + + { + arrayHelpers.remove(selectedMarking); + setMarkingAnchorEl(null); + setSelectedMarking(null); + }}> + + + + Delete + + + )} + + {/* ADD/EDIT MARKING DIALOG */} + { + selectedMarking !== null ? arrayHelpers.replace(selectedMarking, data) : arrayHelpers.push(data); + setIsDialogOpen(false); + setSelectedMarking(null); + }} + handleClose={() => { + setIsDialogOpen(false); + setSelectedMarking(null); + }} + /> + + {/* MARKING CARDS */} + + + {values.markings.map((marking, index) => ( + + colour.colour_id == marking.primary_colour_id) + ?.colour + } + secondary_colour_label={ + markingColoursDataLoader.data?.find((colour) => colour.colour_id == marking.secondary_colour_id) + ?.colour + } + marking_type_label={ + markingTypesDataLoader.data?.find((type) => type.marking_type_id == marking.marking_type_id) + ?.name ?? '' + } + marking_body_location_label={ + markingBodyLocationDataLoader.data?.find( + (body_location) => + body_location.taxon_marking_body_location_id == marking.taxon_marking_body_location_id + )?.body_location ?? '' + } + handleMarkingMenuClick={(event) => { + setMarkingAnchorEl(event.currentTarget); + setSelectedMarking(index); + }} + /> + + ))} + + + + + )} + /> + ); +}; diff --git a/app/src/features/surveys/animals/profile/measurements/MeasurementsForm.tsx b/app/src/features/surveys/animals/profile/measurements/MeasurementsForm.tsx new file mode 100644 index 0000000000..981fc6ef3b --- /dev/null +++ b/app/src/features/surveys/animals/profile/measurements/MeasurementsForm.tsx @@ -0,0 +1,86 @@ +import { mdiPlus } from '@mdi/js'; +import Icon from '@mdi/react'; +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; +import Collapse from '@mui/material/Collapse'; +import { MeasurementSelect } from 'features/surveys/animals/profile/measurements/components/MeasurementSelect'; +import { FieldArray, FieldArrayRenderProps, useFormikContext } from 'formik'; +import { useAnimalPageContext } from 'hooks/useContext'; +import { useCritterbaseApi } from 'hooks/useCritterbaseApi'; +import useDataLoader from 'hooks/useDataLoader'; +import { IMeasurementsCreate, IMeasurementsUpdate } from 'interfaces/useCritterApi.interface'; +import { useEffect } from 'react'; +import { TransitionGroup } from 'react-transition-group'; + +export const initialMeasurementValues = { + taxon_measurement_id: undefined, + qualitative_option_id: undefined, + value: undefined +}; + +/** + * Returns form controls for applying measurements. + * + * @template FormikValuesType + * @return {*} + */ +export const MeasurementsForm = () => { + const { values } = useFormikContext(); + + const critterbaseApi = useCritterbaseApi(); + const animalPageContext = useAnimalPageContext(); + + const measurementsDataLoader = useDataLoader((tsn: number) => critterbaseApi.xref.getTaxonMeasurements(tsn)); + + useEffect(() => { + const tsn = animalPageContext.critterDataLoader.data?.itis_tsn; + + if (!tsn) { + return; + } + + measurementsDataLoader.load(tsn); + }, [animalPageContext.critterDataLoader.data, measurementsDataLoader]); + + return ( + ( + <> + + {values.measurements.map((measurement, index) => ( + + + + + + ))} + + + + + )} + /> + ); +}; diff --git a/app/src/features/surveys/animals/profile/measurements/components/MeasurementSelect.tsx b/app/src/features/surveys/animals/profile/measurements/components/MeasurementSelect.tsx new file mode 100644 index 0000000000..0289fad424 --- /dev/null +++ b/app/src/features/surveys/animals/profile/measurements/components/MeasurementSelect.tsx @@ -0,0 +1,180 @@ +import { mdiClose } from '@mdi/js'; +import { Icon } from '@mdi/react'; +import Box from '@mui/material/Box'; +import Card from '@mui/material/Card'; +import grey from '@mui/material/colors/grey'; +import IconButton from '@mui/material/IconButton'; +import Stack from '@mui/material/Stack'; +import AutocompleteField from 'components/fields/AutocompleteField'; +import CustomTextField from 'components/fields/CustomTextField'; +import { initialMeasurementValues } from 'features/surveys/animals/profile/measurements/MeasurementsForm'; +import { + isQualitativeMeasurementUpdate, + isQuantitativeMeasurementUpdate +} from 'features/surveys/animals/profile/measurements/utils'; +import { FieldArrayRenderProps, useFormikContext } from 'formik'; +import { + CBQualitativeMeasurementTypeDefinition, + CBQuantitativeMeasurementTypeDefinition, + IMeasurementsCreate, + IMeasurementsUpdate +} from 'interfaces/useCritterApi.interface'; +import { useMemo } from 'react'; + +interface IMeasurementSelectProps { + // The collection units (categories) available to select from + measurements: (CBQuantitativeMeasurementTypeDefinition | CBQualitativeMeasurementTypeDefinition)[]; + // Formik field array helpers + arrayHelpers: FieldArrayRenderProps; + // The index of the field array for these controls + index: number; +} + +/** + * Returns a component for selecting ecological (ie. collection) units for a given species. + * + * @template FormikValuesType + * @param {IMeasurementSelectProps} props + * @return {*} + */ +export const MeasurementSelect = ( + props: IMeasurementSelectProps +) => { + const { index, measurements, arrayHelpers } = props; + + const { values, setFieldValue, setFieldTouched, setFieldError } = useFormikContext(); + + const selectedTaxonMeasurement = + measurements.find( + (measurement) => measurement.taxon_measurement_id === values.measurements[index]?.taxon_measurement_id + ) ?? null; + + // Filter out the categories that are already selected so they can't be selected again + const filteredCategories = useMemo( + () => + measurements + .filter( + (measurement) => + !values.measurements.some( + (existing) => + existing.taxon_measurement_id === measurement.taxon_measurement_id && + measurement.taxon_measurement_id !== selectedTaxonMeasurement?.taxon_measurement_id + ) + ) + .map((option) => { + return { + value: option.taxon_measurement_id, + label: + option.measurement_name ?? + measurements.find((measurement) => measurement.taxon_measurement_id === option.taxon_measurement_id) + ?.measurement_name + }; + }) ?? [], + [measurements, selectedTaxonMeasurement?.taxon_measurement_id, values.measurements] + ); + + return ( + + { + if (option?.value) { + setFieldError(`measurements.[${index}].taxon_measurement_id`, undefined); + setFieldValue(`measurements.[${index}]`, { + ...initialMeasurementValues, + taxon_measurement_id: option.value + }); + setFieldTouched(`measurements.[${index}]`, true, false); + } + }} + required + sx={{ + flex: '0.5' + }} + /> + + + {selectedTaxonMeasurement && 'options' in selectedTaxonMeasurement ? ( + ({ + label: option.option_label, + value: option.qualitative_option_id + }))} + onChange={(_, option) => { + if (option?.value) { + setFieldError(`measurements.[${index}].qualitative_option_id`, undefined); + + const currentMeasurementValue = values.measurements[index]; + setFieldValue(`measurements.[${index}]`, { + ...(isQualitativeMeasurementUpdate(currentMeasurementValue) && { + measurement_qualitative_id: currentMeasurementValue.measurement_qualitative_id + }), + taxon_measurement_id: currentMeasurementValue.taxon_measurement_id, + qualitative_option_id: option.value, + value: undefined + }); + + setFieldTouched(`measurements.[${index}].qualitative_option_id`, true, false); + } + }} + disabled={Boolean(!values.measurements[index].taxon_measurement_id)} + required + sx={{ + flex: '1 1 auto' + }} + /> + ) : ( + ) => { + setFieldError(`measurements.[${index}].value`, undefined); + + const currentMeasurementValue = values.measurements[index]; + setFieldValue(`measurements.[${index}]`, { + ...(isQuantitativeMeasurementUpdate(currentMeasurementValue) && { + measurement_quantitative_id: currentMeasurementValue.measurement_quantitative_id + }), + taxon_measurement_id: currentMeasurementValue.taxon_measurement_id, + qualitative_option_id: undefined, + value: Number(event.target.value) + }); + + setFieldTouched(`measurements.[${index}].value`, true, false); + } + }} + /> + )} + + + arrayHelpers.remove(index)} + sx={{ mt: 1.125 }}> + + + + ); +}; diff --git a/app/src/features/surveys/animals/profile/measurements/utils.ts b/app/src/features/surveys/animals/profile/measurements/utils.ts new file mode 100644 index 0000000000..34ebc9cee6 --- /dev/null +++ b/app/src/features/surveys/animals/profile/measurements/utils.ts @@ -0,0 +1,26 @@ +import { + IQualitativeMeasurementCreate, + IQualitativeMeasurementUpdate, + IQuantitativeMeasurementCreate, + IQuantitativeMeasurementUpdate +} from 'interfaces/useCritterApi.interface'; + +// Type guard for qualitative measurements +export function isQualitativeMeasurementCreate(measurement: any): measurement is IQualitativeMeasurementCreate { + return 'qualitative_option_id' in measurement && !measurement.value && !measurement.measurement_qualitative_id; +} + +// Type guard for quantitative measurements +export function isQuantitativeMeasurementCreate(measurement: any): measurement is IQuantitativeMeasurementCreate { + return 'value' in measurement && !measurement.qualitative_option_id && !measurement.measurement_quantitative_id; +} + +// Type guard for qualitative measurements +export function isQualitativeMeasurementUpdate(measurement: any): measurement is IQualitativeMeasurementUpdate { + return 'measurement_qualitative_id' in measurement && measurement.measurement_qualitative_id; +} + +// Type guard for quantitative measurements +export function isQuantitativeMeasurementUpdate(measurement: any): measurement is IQuantitativeMeasurementUpdate { + return 'measurement_quantitative_id' in measurement && measurement.measurement_quantitative_id; +} diff --git a/app/src/features/surveys/animals/profile/mortality/AnimalMortalityContainer.tsx b/app/src/features/surveys/animals/profile/mortality/AnimalMortalityContainer.tsx new file mode 100644 index 0000000000..8a84c19faf --- /dev/null +++ b/app/src/features/surveys/animals/profile/mortality/AnimalMortalityContainer.tsx @@ -0,0 +1,118 @@ +import Box from '@mui/material/Box'; +import Skeleton from '@mui/material/Skeleton'; +import { useAnimalPageContext, useSurveyContext } from 'hooks/useContext'; +import { useCritterbaseApi } from 'hooks/useCritterbaseApi'; +import { + IMarkingResponse, + IMortalityResponse, + IQualitativeMeasurementResponse, + IQuantitativeMeasurementResponse +} from 'interfaces/useCritterApi.interface'; +import { useHistory } from 'react-router'; +import { AnimalMortalityCardContainer } from './components/AnimalMortalityCardContainer'; +import { AnimalMortalityMap } from './components/AnimalMortalityMap'; +import { AnimalMortalityToolbar } from './components/AnimalMortalityToolbar'; + +export interface IMortalityWithSupplementaryData extends IMortalityResponse { + markings: IMarkingResponse[]; + measurements: { qualitative: IQualitativeMeasurementResponse[]; quantitative: IQuantitativeMeasurementResponse[] }; +} + +/** + * Container for the animal mortality component within the animal profile page + * + * @return {*} + */ +const AnimalMortalityContainer = () => { + const critterbaseApi = useCritterbaseApi(); + + const history = useHistory(); + + const { surveyId, projectId } = useSurveyContext(); + + const animalPageContext = useAnimalPageContext(); + + const data = animalPageContext.critterDataLoader.data; + + if (!animalPageContext.selectedAnimal || animalPageContext.critterDataLoader.isLoading) { + return ( + + + + + + + + ); + } + + const selectedAnimal = animalPageContext.selectedAnimal; + + if (!selectedAnimal) { + return null; + } + + const mortality: IMortalityWithSupplementaryData[] = + data?.mortality.map((mortality) => ({ + ...mortality, + markings: data?.markings.filter((marking) => marking.mortality_id === mortality.mortality_id), + measurements: { + qualitative: data.measurements.qualitative.filter( + (measurement) => measurement.mortality_id === mortality.mortality_id + ), + quantitative: data.measurements.quantitative.filter( + (measurement) => measurement.mortality_id === mortality.mortality_id + ) + } + })) || []; + + const handleDelete = async (selectedMortality: string, critterbase_critter_id: string) => { + // Delete markings and measurements associated with the mortality to avoid foreign key constraint error + await critterbaseApi.critters.bulkUpdate({ + markings: data?.markings + .filter((marking) => marking.mortality_id === selectedMortality) + .map((marking) => ({ + ...marking, + critter_id: selectedAnimal.critterbase_critter_id, + _delete: true + })), + qualitative_measurements: + data?.measurements.qualitative + .filter((measurement) => measurement.mortality_id === selectedMortality) + .map((measurement) => ({ + ...measurement, + _delete: true + })) ?? [], + quantitative_measurements: + data?.measurements.quantitative + .filter((measurement) => measurement.mortality_id === selectedMortality) + .map((measurement) => ({ + ...measurement, + _delete: true + })) ?? [] + }); + + // Delete the actual mortality + await critterbaseApi.mortality.deleteMortality(selectedMortality); + + // Refresh mortality container + animalPageContext.critterDataLoader.refresh(critterbase_critter_id); + }; + + return ( + <> + { + history.push( + `/admin/projects/${projectId}/surveys/${surveyId}/animals/${selectedAnimal.survey_critter_id}/mortality/create` + ); + }} + /> + {mortality.length > 0 && } + + + ); +}; + +export default AnimalMortalityContainer; diff --git a/app/src/features/surveys/animals/profile/mortality/components/AnimalMortalityCardContainer.tsx b/app/src/features/surveys/animals/profile/mortality/components/AnimalMortalityCardContainer.tsx new file mode 100644 index 0000000000..4e49b73f59 --- /dev/null +++ b/app/src/features/surveys/animals/profile/mortality/components/AnimalMortalityCardContainer.tsx @@ -0,0 +1,206 @@ +import { mdiChevronDown, mdiDotsVertical, mdiPencilOutline, mdiTrashCanOutline } from '@mdi/js'; +import Icon from '@mdi/react'; +import Accordion from '@mui/material/Accordion'; +import AccordionDetails from '@mui/material/AccordionDetails'; +import AccordionSummary from '@mui/material/AccordionSummary'; +import Box from '@mui/material/Box'; +import grey from '@mui/material/colors/grey'; +import IconButton from '@mui/material/IconButton'; +import ListItemIcon from '@mui/material/ListItemIcon'; +import ListItemText from '@mui/material/ListItemText'; +import Menu, { MenuProps } from '@mui/material/Menu'; +import MenuItem from '@mui/material/MenuItem'; +import Paper from '@mui/material/Paper'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import YesNoDialog from 'components/dialog/YesNoDialog'; +import { DATE_FORMAT } from 'constants/dateTimeFormats'; +import { ISurveyCritter } from 'contexts/animalPageContext'; +import { IMortalityWithSupplementaryData } from 'features/surveys/animals/profile/mortality/AnimalMortalityContainer'; +import { AnimalMortalityCardDetailsContainer } from 'features/surveys/animals/profile/mortality/components/mortality-card-details/AnimalMortalityCardDetailsContainer'; +import { useSurveyContext } from 'hooks/useContext'; +import { useState } from 'react'; +import { Link as RouterLink } from 'react-router-dom'; +import { getFormattedDate } from 'utils/Utils'; + +interface IAnimalMortalityCardContainer { + mortality: IMortalityWithSupplementaryData[]; + selectedAnimal: ISurveyCritter; + handleDelete: (selectedMortality: string, critterbase_critter_id: string) => Promise; +} +/** + * Returns accordion cards for displaying animal mortality details on the animal profile page + * + * @param {IAnimalMortalityCardContainer} props + * @return {*} + */ +export const AnimalMortalityCardContainer = (props: IAnimalMortalityCardContainer) => { + const { mortality, selectedAnimal, handleDelete } = props; + + const [selectedMortality, setSelectedMortality] = useState(null); + const [mortalityAnchorEl, setMortalityAnchorEl] = useState(null); + const [mortalityForDelete, setMortalityForDelete] = useState(); + + const { projectId, surveyId } = useSurveyContext(); + + return ( + <> + {/* 3 DOT ACTION MENU */} + {selectedMortality && ( + setMortalityAnchorEl(null)} + anchorEl={mortalityAnchorEl} + anchorOrigin={{ + vertical: 'top', + horizontal: 'right' + }} + transformOrigin={{ + vertical: 'top', + horizontal: 'right' + }}> + + + + + + Edit Details + + + { + setMortalityAnchorEl(null); + setMortalityForDelete(true); + }}> + + + + Delete + + + )} + {/* DELETE CONFIRMATION DIALOG */} + {mortalityForDelete && selectedMortality && ( + { + setMortalityForDelete(false); + handleDelete(selectedMortality, selectedAnimal.critterbase_critter_id); + }} + onClose={() => setMortalityForDelete(false)} + onNo={() => setMortalityForDelete(false)} + /> + )} + + {mortality.length ? ( + mortality.map((mortality) => { + /* MORTALITY DETAILS */ + return ( + + + } + aria-controls="panel1bh-content" + sx={{ + flex: '1 1 auto', + mr: 1, + pr: 8.5, + minHeight: 55, + overflow: 'hidden', + border: 0, + '& .MuiAccordionSummary-content': { + flex: '1 1 auto', + py: 0, + pl: 0, + overflow: 'hidden', + whiteSpace: 'nowrap' + } + }}> + + + {getFormattedDate(DATE_FORMAT.MediumDateTimeFormat, mortality.mortality_timestamp)}  + + {mortality.location?.latitude && mortality.location?.longitude && ( + + + {mortality.location.longitude},  + {mortality.location.latitude} + + + )} + + + ) => { + setMortalityAnchorEl(event.currentTarget); + setSelectedMortality(mortality.mortality_id); + }} + aria-label="sample-site-settings"> + + + + + + + + ); + }) + ) : ( + /* NO MORTALITY RECORDS */ + + + This animal has not been reported as deceased + + + )} + + ); +}; diff --git a/app/src/features/surveys/animals/profile/mortality/components/AnimalMortalityMap.tsx b/app/src/features/surveys/animals/profile/mortality/components/AnimalMortalityMap.tsx new file mode 100644 index 0000000000..04deba620f --- /dev/null +++ b/app/src/features/surveys/animals/profile/mortality/components/AnimalMortalityMap.tsx @@ -0,0 +1,49 @@ +import Box from '@mui/system/Box'; +import { IStaticLayer } from 'components/map/components/StaticLayers'; +import SurveyMap from 'features/surveys/view/SurveyMap'; +import { Feature } from 'geojson'; +import { IMortalityResponse } from 'interfaces/useCritterApi.interface'; +import { isDefined } from 'utils/Utils'; + +interface IAnimalMortalityMapProps { + mortality: IMortalityResponse[]; + isLoading: boolean; +} + +/** + * Wrapper around the Survey Map component for displaying the selected animal's mortality on the map + * + * @param {IAnimalMortalityMapProps} props + * @return {*} + */ +export const AnimalMortalityMap = (props: IAnimalMortalityMapProps) => { + const { mortality, isLoading } = props; + + const mortalityMapFeatures = mortality + .filter((mortality) => isDefined(mortality.location?.latitude) && isDefined(mortality.location?.longitude)) + .map((mortality) => ({ + type: 'Feature', + geometry: { + type: 'Point', + coordinates: [mortality.location?.longitude, mortality.location?.latitude] + }, + properties: { mortalityId: mortality.mortality_id, date: mortality.mortality_timestamp } + })) as Feature[]; + + const staticLayers: IStaticLayer[] = mortalityMapFeatures.map((feature, index) => ({ + layerName: 'Mortality', + popupRecordTitle: 'Capture Location', + features: [ + { + key: `${feature.geometry}-${index}`, + geoJSON: feature + } + ] + })); + + return ( + + + + ); +}; diff --git a/app/src/features/surveys/animals/profile/mortality/components/AnimalMortalityToolbar.tsx b/app/src/features/surveys/animals/profile/mortality/components/AnimalMortalityToolbar.tsx new file mode 100644 index 0000000000..023e6cc462 --- /dev/null +++ b/app/src/features/surveys/animals/profile/mortality/components/AnimalMortalityToolbar.tsx @@ -0,0 +1,53 @@ +import { mdiPlus } from '@mdi/js'; +import { Icon } from '@mdi/react'; +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; +import Toolbar from '@mui/material/Toolbar'; +import Typography from '@mui/material/Typography'; + +interface IAnimalMortalityToolbarProps { + mortalityCount: number; + onAddAnimalMortality: () => void; +} + +/** + * Toolbar for actions affecting an animal's Mortality, ie. add a new Mortality + * + * @param {IAnimalMortalityToolbarProps} props + * @return {*} + */ +export const AnimalMortalityToolbar = (props: IAnimalMortalityToolbarProps) => { + const { mortalityCount, onAddAnimalMortality } = props; + + return ( + + + Mortality + + ({mortalityCount}) + + + {mortalityCount === 0 && ( + + + + )} + + ); +}; diff --git a/app/src/features/surveys/animals/profile/mortality/components/mortality-card-details/AnimalMortalityCardDetailsContainer.tsx b/app/src/features/surveys/animals/profile/mortality/components/mortality-card-details/AnimalMortalityCardDetailsContainer.tsx new file mode 100644 index 0000000000..a6d9060b89 --- /dev/null +++ b/app/src/features/surveys/animals/profile/mortality/components/mortality-card-details/AnimalMortalityCardDetailsContainer.tsx @@ -0,0 +1,27 @@ +import Stack from '@mui/material/Stack'; +import { MarkingDetails } from 'features/surveys/animals/profile/components/MarkingDetails'; +import { MeasurementDetails } from 'features/surveys/animals/profile/components/MeasurementDetails'; +import { IMortalityWithSupplementaryData } from 'features/surveys/animals/profile/mortality/AnimalMortalityContainer'; +import { MortalityDetails } from 'features/surveys/animals/profile/mortality/components/mortality-card-details/MortalityDetails'; + +interface IAnimalMortalityCardDetailsContainerProps { + mortality: IMortalityWithSupplementaryData; +} + +/** + * Details displayed with the accordion component displaying an animal mortality + * + * @param {IAnimalMortalityCardDetailsContainerProps} props + * @return {*} + */ +export const AnimalMortalityCardDetailsContainer = (props: IAnimalMortalityCardDetailsContainerProps) => { + const { mortality } = props; + + return ( + + + + + + ); +}; diff --git a/app/src/features/surveys/animals/profile/mortality/components/mortality-card-details/MortalityDetails.tsx b/app/src/features/surveys/animals/profile/mortality/components/mortality-card-details/MortalityDetails.tsx new file mode 100644 index 0000000000..447d7bddb4 --- /dev/null +++ b/app/src/features/surveys/animals/profile/mortality/components/mortality-card-details/MortalityDetails.tsx @@ -0,0 +1,92 @@ +import Box from '@mui/material/Box'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import { DATE_FORMAT } from 'constants/dateTimeFormats'; +import { IMortalityWithSupplementaryData } from 'features/surveys/animals/profile/mortality/AnimalMortalityContainer'; +import { useCritterbaseApi } from 'hooks/useCritterbaseApi'; +import useDataLoader from 'hooks/useDataLoader'; +import { useEffect } from 'react'; +import { getFormattedDate } from 'utils/Utils'; + +interface IMortalityDetailsProps { + mortality: IMortalityWithSupplementaryData; +} + +/** + * Component for displaying animal mortality 'mortality' details. + * + * @param {IMortalityDetailsProps} props + * @return {*} + */ +export const MortalityDetails = (props: IMortalityDetailsProps) => { + const { mortality } = props; + + const critterbaseApi = useCritterbaseApi(); + + const mortalityCodesDataLoader = useDataLoader(() => critterbaseApi.mortality.getCauseOfDeathOptions()); + + useEffect(() => { + mortalityCodesDataLoader.load(); + }, [mortalityCodesDataLoader]); + + const mortalityTimestamp = mortality.mortality_timestamp; + const mortalityComment = mortality.mortality_comment; + + if (!mortalityTimestamp && !mortalityComment) { + return null; + } + + return ( + + + {mortalityTimestamp && ( + + + Mortality time + + + {getFormattedDate(DATE_FORMAT.MediumDateTimeFormat, mortalityTimestamp)} + + + )} + + {mortalityTimestamp && ( + + + Suspected cause of death + + + { + mortalityCodesDataLoader.data?.find((option) => option.id === mortality.proximate_cause_of_death_id) + ?.value + } + + + )} + + + {mortalityComment && ( + + + Mortality comment + + + {mortalityComment} + + + )} + + ); +}; diff --git a/app/src/features/surveys/animals/profile/mortality/mortality-form/components/AnimalMortalityForm.tsx b/app/src/features/surveys/animals/profile/mortality/mortality-form/components/AnimalMortalityForm.tsx new file mode 100644 index 0000000000..b3a4a4d812 --- /dev/null +++ b/app/src/features/surveys/animals/profile/mortality/mortality-form/components/AnimalMortalityForm.tsx @@ -0,0 +1,154 @@ +import { Divider } from '@mui/material'; +import Stack from '@mui/material/Stack'; +import FormikErrorSnackbar from 'components/alert/FormikErrorSnackbar'; +import HorizontalSplitFormComponent from 'components/fields/HorizontalSplitFormComponent'; +import { MarkingsForm } from 'features/surveys/animals/profile/markings/MarkingsForm'; +import { MeasurementsForm } from 'features/surveys/animals/profile/measurements/MeasurementsForm'; +import { Formik, FormikProps } from 'formik'; +import { ICreateMortalityRequest, IEditMortalityRequest } from 'interfaces/useCritterApi.interface'; +import { isDefined } from 'utils/Utils'; +import yup from 'utils/YupSchema'; +import { CauseOfDeathForm } from './cause-of-death/CauseOfDeathForm'; +import { MortalityGeneralInformationForm } from './general-information/MortalityGeneralInformationForm'; +import { MortalityLocationForm } from './location/MortalityLocationForm'; + +export interface IAnimalMortalityFormProps { + initialMortalityData: FormikValuesType; + handleSubmit: (formikData: FormikValuesType) => void; + formikRef: React.RefObject>; +} + +/** + * Returns the formik component for creating and editing an animal mortality + * + * @template FormikValuesType + * @param {IAnimalMortalityFormProps} props + * @return {*} + */ +export const AnimalMortalityForm = ( + props: IAnimalMortalityFormProps +) => { + const animalMortalityYupSchema = yup.object({ + mortality: yup.object({ + mortality_id: yup.string().nullable(), + mortality_date: yup.string().required('Mortality date is required'), + mortality_time: yup.string().nullable(), + proximate_cause_of_death_id: yup.string().nullable().required('Cause of death is required'), + mortality_comment: yup.string().required('Mortality comment is required'), + location: yup + .object() + .shape({ + type: yup.string(), + // Points may have 3 coords for [lon, lat, elevation] + geometry: yup.object({ + type: yup.string(), + coordinates: yup.array().of(yup.number()).min(2).max(3) + }), + properties: yup.object().optional() + }) + .nullable() + .default(undefined) + .required('mortality location is required') + }), + measurements: yup.array( + yup + .object() + .shape({ + taxon_measurement_id: yup.string().required('Measurement type is required.'), + value: yup.mixed().test('is-valid-measurement', 'Measurement value is required.', function () { + const { value } = this.parent; + if (isDefined(value)) { + // Field value is defined, check if it is valid + return yup.number().isValidSync(value); + } + // Field value is not defined, return valid for now + return true; + }), + qualitative_option_id: yup + .mixed() + .test('is-valid-measurement', 'Measurement value is required.', function () { + const { qualitative_option_id } = this.parent; + if (isDefined(qualitative_option_id)) { + // Field value is defined, check if it is valid + return yup.string().isValidSync(qualitative_option_id); + } + // Field value is not defined, return valid for now + return true; + }) + }) + .test('is-valid-measurement', 'Measurement must have a type and a value', function (_value) { + const { taxon_measurement_id } = _value; + const path = this.path; + if (taxon_measurement_id && !isDefined(_value.value) && !isDefined(_value.qualitative_option_id)) { + // Measurement type is defined but neither value nor qualitative option is defined, add errors for both + const errors = [ + this.createError({ + path: `${path}.qualitative_option_id`, + message: 'Measurement value is required.' + }), + this.createError({ + path: `${path}.value`, + message: 'Measurement value is required.' + }) + ]; + return new yup.ValidationError(errors); + } + // Field value is not defined, return valid + return true; + }) + ), + markings: yup.array( + yup.object({ + marking_type_id: yup.string().required('Marking type is required.'), + taxon_marking_body_location_id: yup.string().required('Marking body location is required.'), + identifier: yup.string().nullable(), + primary_colour_id: yup.string().nullable(), + secondary_colour_id: yup.string().nullable(), + comment: yup.string().nullable() + }) + ) + }); + + return ( + + + + } + /> + + } + /> + + } + /> + + } + /> + + } + /> + + + ); +}; diff --git a/app/src/features/surveys/animals/profile/mortality/mortality-form/components/cause-of-death/CauseOfDeathForm.tsx b/app/src/features/surveys/animals/profile/mortality/mortality-form/components/cause-of-death/CauseOfDeathForm.tsx new file mode 100644 index 0000000000..73df4917f5 --- /dev/null +++ b/app/src/features/surveys/animals/profile/mortality/mortality-form/components/cause-of-death/CauseOfDeathForm.tsx @@ -0,0 +1,50 @@ +import Box from '@mui/material/Box'; +import Grid from '@mui/material/Grid'; +import AutocompleteField from 'components/fields/AutocompleteField'; +import { useFormikContext } from 'formik'; +import { useCritterbaseApi } from 'hooks/useCritterbaseApi'; +import useDataLoader from 'hooks/useDataLoader'; +import { ICreateMortalityRequest, IEditMortalityRequest } from 'interfaces/useCritterApi.interface'; +import { useEffect } from 'react'; + +/** + * Returns the control for selecting the cause of death for an animal mortality. + * + * @template FormikValuesType + * @return {*} + */ +export const CauseOfDeathForm = () => { + const { setFieldValue } = useFormikContext(); + + const critterbaseApi = useCritterbaseApi(); + + const causeOfDeathDataLoader = useDataLoader(() => critterbaseApi.mortality.getCauseOfDeathOptions()); + + useEffect(() => { + causeOfDeathDataLoader.load(); + }, [causeOfDeathDataLoader]); + + return ( + + + + ({ value: cause.id, label: cause.value })) ?? []} + onChange={(_, option) => { + if (option?.value) { + setFieldValue('mortality.proximate_cause_of_death_id', option.value); + } + }} + required + sx={{ + flex: '0.5' + }} + /> + + + + ); +}; diff --git a/app/src/features/surveys/animals/profile/mortality/mortality-form/components/general-information/MortalityGeneralInformationForm.tsx b/app/src/features/surveys/animals/profile/mortality/mortality-form/components/general-information/MortalityGeneralInformationForm.tsx new file mode 100644 index 0000000000..fe05446878 --- /dev/null +++ b/app/src/features/surveys/animals/profile/mortality/mortality-form/components/general-information/MortalityGeneralInformationForm.tsx @@ -0,0 +1,59 @@ +import { mdiCalendar } from '@mdi/js'; +import Grid from '@mui/material/Grid'; +import Typography from '@mui/material/Typography'; +import Box from '@mui/system/Box'; +import CustomTextField from 'components/fields/CustomTextField'; +import { DateTimeFields } from 'components/fields/DateTimeFields'; +import { useFormikContext } from 'formik'; +import { ICreateMortalityRequest, IEditMortalityRequest } from 'interfaces/useCritterApi.interface'; + +/** + * Returns the form for entering general information about an animal mortality. + * + * @template FormikValuesType + * @return {*} + */ +export const MortalityGeneralInformationForm = < + FormikValuesType extends ICreateMortalityRequest | IEditMortalityRequest +>() => { + const formikProps = useFormikContext(); + + return ( + + + + + Mortality information + + + + + + + + + + + ); +}; diff --git a/app/src/features/surveys/animals/profile/mortality/mortality-form/components/location/MortalityLocationForm.tsx b/app/src/features/surveys/animals/profile/mortality/mortality-form/components/location/MortalityLocationForm.tsx new file mode 100644 index 0000000000..3a6a71a97b --- /dev/null +++ b/app/src/features/surveys/animals/profile/mortality/mortality-form/components/location/MortalityLocationForm.tsx @@ -0,0 +1,20 @@ +import Grid from '@mui/material/Grid'; +import Box from '@mui/system/Box'; +import { MortalityLocationMapControl } from './MortalityLocationMapControl'; + +/** + * Returns the form for entering the location of an animal mortality. + * + * @return {*} + */ +export const MortalityLocationForm = () => { + return ( + + + + + + + + ); +}; diff --git a/app/src/features/surveys/animals/profile/mortality/mortality-form/components/location/MortalityLocationMapControl.tsx b/app/src/features/surveys/animals/profile/mortality/mortality-form/components/location/MortalityLocationMapControl.tsx new file mode 100644 index 0000000000..3ed96cf82f --- /dev/null +++ b/app/src/features/surveys/animals/profile/mortality/mortality-form/components/location/MortalityLocationMapControl.tsx @@ -0,0 +1,211 @@ +import { mdiTrayArrowUp } from '@mdi/js'; +import Icon from '@mdi/react'; +import Alert from '@mui/material/Alert'; +import AlertTitle from '@mui/material/AlertTitle'; +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; +import Grid from '@mui/material/Grid'; +import Paper from '@mui/material/Paper'; +import Toolbar from '@mui/material/Toolbar'; +import Typography from '@mui/material/Typography'; +import BaseLayerControls from 'components/map/components/BaseLayerControls'; +import { SetMapBounds } from 'components/map/components/Bounds'; +import DrawControls, { IDrawControlsRef } from 'components/map/components/DrawControls'; +import FullScreenScrollingEventHandler from 'components/map/components/FullScreenScrollingEventHandler'; +import ImportBoundaryDialog from 'components/map/components/ImportBoundaryDialog'; +import StaticLayers from 'components/map/components/StaticLayers'; +import { MapBaseCss } from 'components/map/styles/MapBaseCss'; +import { ALL_OF_BC_BOUNDARY, MAP_DEFAULT_CENTER, MAP_DEFAULT_ZOOM } from 'constants/spatial'; +import { useFormikContext } from 'formik'; +import { Feature } from 'geojson'; +import { ICreateMortalityRequest, IEditMortalityRequest } from 'interfaces/useCritterApi.interface'; +import { DrawEvents, LatLngBoundsExpression } from 'leaflet'; +import 'leaflet-fullscreen/dist/leaflet.fullscreen.css'; +import 'leaflet-fullscreen/dist/Leaflet.fullscreen.js'; +import 'leaflet/dist/leaflet.css'; +import { get } from 'lodash-es'; +import { useEffect, useMemo, useRef, useState } from 'react'; +import { FeatureGroup, LayersControl, MapContainer as LeafletMapContainer } from 'react-leaflet'; +import { calculateUpdatedMapBounds } from 'utils/mapBoundaryUploadHelpers'; + +export interface IMortalityLocationMapControlProps { + name: string; + title: string; + mapId: string; +} + +/** + * Control for selecting a mortality location on a map. + * + * @template FormikValuesType + * @param {IMortalityLocationMapControlProps} props + * @return {*} + */ +export const MortalityLocationMapControl = ( + props: IMortalityLocationMapControlProps +) => { + const { name, title } = props; + const [isOpen, setIsOpen] = useState(false); + const [lastDrawn, setLastDrawn] = useState(null); + + const drawControlsRef = useRef(); + + const { mapId } = props; + + const { values, setFieldValue, setFieldError, errors } = useFormikContext(); + + const [updatedBounds, setUpdatedBounds] = useState(undefined); + + // Array of mortality location features. Should only be one. + const mortalityLocationGeoJson: Feature | undefined = useMemo(() => { + const location: { latitude: number; longitude: number } | Feature = get(values, name); + + if (!location) { + return; + } + + if ('latitude' in location && location.latitude !== 0 && location.longitude !== 0) { + return { + type: 'Feature', + geometry: { type: 'Point', coordinates: [location.longitude, location.latitude] }, + properties: {} + }; + } + + if ('type' in location) { + return location; + } + }, [name, values]); + + useEffect(() => { + if (mortalityLocationGeoJson) { + if ('type' in mortalityLocationGeoJson) { + if (mortalityLocationGeoJson.geometry.type === 'Point') + if (mortalityLocationGeoJson?.geometry.coordinates[0] !== 0) { + setUpdatedBounds(calculateUpdatedMapBounds([mortalityLocationGeoJson])); + return; + } + } + } + + setUpdatedBounds(calculateUpdatedMapBounds([ALL_OF_BC_BOUNDARY])); + }, [mortalityLocationGeoJson]); + + return ( + + {get(errors, name) && !Array.isArray(get(errors, name)) && ( + + Missing mortality location + {get(errors, name) as string} + + )} + + + + setIsOpen(false)} + onSuccess={(features) => { + // Map features into form data + const formData = features.map((item: Feature) => ({ + geojson: [item], + revision_count: 0 + })); + setUpdatedBounds(calculateUpdatedMapBounds(features)); + setFieldValue(name, formData[0].geojson); + }} + onFailure={(message) => { + setFieldError(name, message); + }} + /> + + + {title} + + + + + + + + + {/* Allow scroll wheel zoom when in full screen mode */} + + + {/* Programmatically set map bounds */} + + + + { + if (lastDrawn) { + drawControlsRef?.current?.deleteLayer(lastDrawn); + } + + const feature = event.layer.toGeoJSON(); + setFieldValue(name, feature); + // Set last drawn to remove it if a subsequent shape is added. There can only be one shape. + setLastDrawn(id); + }} + onLayerEdit={(event: DrawEvents.Edited) => { + event.layers.getLayers().forEach((layer: any) => { + const feature = layer.toGeoJSON() as Feature; + setFieldValue(name, feature); + }); + }} + onLayerDelete={() => { + setFieldValue(name, null); + }} + /> + + + + + + + + + + + + ); +}; diff --git a/app/src/features/surveys/animals/profile/mortality/mortality-form/create/CreateMortalityPage.tsx b/app/src/features/surveys/animals/profile/mortality/mortality-form/create/CreateMortalityPage.tsx new file mode 100644 index 0000000000..1891e96882 --- /dev/null +++ b/app/src/features/surveys/animals/profile/mortality/mortality-form/create/CreateMortalityPage.tsx @@ -0,0 +1,278 @@ +import LoadingButton from '@mui/lab/LoadingButton'; +import Breadcrumbs from '@mui/material/Breadcrumbs'; +import Button from '@mui/material/Button'; +import Container from '@mui/material/Container'; +import Link from '@mui/material/Link'; +import Paper from '@mui/material/Paper'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import { IErrorDialogProps } from 'components/dialog/ErrorDialog'; +import PageHeader from 'components/layout/PageHeader'; +import { SkeletonHorizontalStack } from 'components/loading/SkeletonLoaders'; +import { CreateMortalityI18N } from 'constants/i18n'; +import dayjs from 'dayjs'; +import { + isQualitativeMeasurementCreate, + isQuantitativeMeasurementCreate +} from 'features/surveys/animals/profile/measurements/utils'; +import { AnimalMortalityForm } from 'features/surveys/animals/profile/mortality/mortality-form/components/AnimalMortalityForm'; +import { formatLocation } from 'features/surveys/animals/profile/mortality/mortality-form/edit/utils'; +import { FormikProps } from 'formik'; +import { APIError } from 'hooks/api/useAxios'; +import { useAnimalPageContext, useDialogContext, useProjectContext, useSurveyContext } from 'hooks/useContext'; +import { useCritterbaseApi } from 'hooks/useCritterbaseApi'; +import { SKIP_CONFIRMATION_DIALOG, useUnsavedChangesDialog } from 'hooks/useUnsavedChangesDialog'; +import { ICreateMortalityRequest } from 'interfaces/useCritterApi.interface'; +import { useEffect, useRef, useState } from 'react'; +import { Prompt, useHistory, useParams } from 'react-router'; +import { Link as RouterLink } from 'react-router-dom'; + +export const initialAnimalMortalityFormValues: ICreateMortalityRequest = { + mortality: { + mortality_id: '', + mortality_timestamp: '', + mortality_date: '', + mortality_time: '', + proximate_cause_of_death_id: null, + proximate_cause_of_death_confidence: null, + proximate_predated_by_itis_tsn: null, + ultimate_cause_of_death_id: null, + ultimate_cause_of_death_confidence: null, + ultimate_predated_by_itis_tsn: null, + mortality_comment: '', + location: null + }, + markings: [], + measurements: [] +}; + +/** + * Page for creating a mortality record. + * + * @return {*} + */ +export const CreateMortalityPage = () => { + const history = useHistory(); + + const critterbaseApi = useCritterbaseApi(); + + const surveyContext = useSurveyContext(); + const projectContext = useProjectContext(); + const dialogContext = useDialogContext(); + const animalPageContext = useAnimalPageContext(); + + const urlParams: Record = useParams(); + const surveyCritterId: number | undefined = Number(urlParams['survey_critter_id']); + + const { locationChangeInterceptor } = useUnsavedChangesDialog(); + + const [isSaving, setIsSaving] = useState(false); + + const formikRef = useRef>(null); + + const { projectId, surveyId } = surveyContext; + + // If the user has refreshed the page and cleared the context, or come to this page externally from a link, + // use the url params to set the select animal in the context. The context then requests critter data from critterbase. + useEffect(() => { + if (animalPageContext.selectedAnimal || !surveyCritterId) { + return; + } + + animalPageContext.setSelectedAnimalFromSurveyCritterId(surveyCritterId); + }, [animalPageContext, surveyCritterId]); + + const handleCancel = () => { + history.push(`/admin/projects/${projectId}/surveys/${surveyId}/animals/details`); + }; + + const showCreateErrorDialog = (textDialogProps?: Partial) => { + dialogContext.setErrorDialog({ + dialogTitle: CreateMortalityI18N.createErrorTitle, + dialogText: CreateMortalityI18N.createErrorText, + onClose: () => { + dialogContext.setErrorDialog({ open: false }); + }, + onOk: () => { + dialogContext.setErrorDialog({ open: false }); + }, + ...textDialogProps, + open: true + }); + }; + + /** + * Creates an Mortality + * + * @return {*} + */ + const handleSubmit = async (values: ICreateMortalityRequest) => { + setIsSaving(true); + + try { + const critterbaseCritterId = animalPageContext.selectedAnimal?.critterbase_critter_id; + + if (!values || !critterbaseCritterId) { + return; + } + + const mortalityLocation = formatLocation(values.mortality.location); + + const mortalityTime = values.mortality.mortality_time + ? ` ${values.mortality.mortality_time}-07:00` + : 'T00:00:00-07:00'; + const mortalityTimestamp = dayjs(`${values.mortality.mortality_date}${mortalityTime}`).toDate(); + + const mortalityResponse = await critterbaseApi.mortality.createMortality({ + critter_id: critterbaseCritterId, + mortality_id: undefined, + mortality_timestamp: mortalityTimestamp, + proximate_cause_of_death_id: values.mortality.proximate_cause_of_death_id, + proximate_cause_of_death_confidence: values.mortality.proximate_cause_of_death_confidence, + proximate_predated_by_itis_tsn: values.mortality.proximate_predated_by_itis_tsn, + ultimate_cause_of_death_id: values.mortality.ultimate_cause_of_death_id, + ultimate_cause_of_death_confidence: values.mortality.ultimate_cause_of_death_confidence, + ultimate_predated_by_itis_tsn: values.mortality.ultimate_predated_by_itis_tsn, + location: mortalityLocation, + mortality_comment: values.mortality.mortality_comment + }); + + if (!mortalityResponse) { + showCreateErrorDialog({ + dialogError: 'An error occurred while attempting to create the mortality record.', + dialogErrorDetails: ['Mortality create failed'] + }); + return; + } + + // Create new measurements added while editing the mortality + const bulkResponse = await critterbaseApi.critters.bulkCreate({ + markings: values.markings.map((marking) => ({ + ...marking, + marking_id: marking.marking_id, + critter_id: critterbaseCritterId, + mortality_id: mortalityResponse.mortality_id + })), + qualitative_measurements: values.measurements + .filter(isQualitativeMeasurementCreate) + // Format qualitative measurements for create + .map((measurement) => ({ + critter_id: critterbaseCritterId, + mortality_id: mortalityResponse.mortality_id, + taxon_measurement_id: measurement.taxon_measurement_id, + qualitative_option_id: measurement.qualitative_option_id + })), + quantitative_measurements: values.measurements + .filter(isQuantitativeMeasurementCreate) + // Format quantitative measurements for create + .map((measurement) => ({ + critter_id: critterbaseCritterId, + mortality_id: mortalityResponse.mortality_id, + taxon_measurement_id: measurement.taxon_measurement_id, + value: measurement.value + })) + }); + + if (!bulkResponse) { + showCreateErrorDialog({ + dialogError: 'An error occurred while attempting to create the mortality record.', + dialogErrorDetails: ['Bulk create failed when creating measurements and markings'] + }); + return; + } + + // Refresh page + animalPageContext.critterDataLoader.refresh(critterbaseCritterId); + + history.push(`/admin/projects/${projectId}/surveys/${surveyId}/animals/details`, SKIP_CONFIRMATION_DIALOG); + } catch (error) { + const apiError = error as APIError; + showCreateErrorDialog({ + dialogTitle: 'Error Creating Survey', + dialogError: apiError?.message, + dialogErrorDetails: apiError?.errors + }); + } finally { + setIsSaving(false); + } + }; + + const animalId = animalPageContext.critterDataLoader.data?.animal_id; + + return ( + <> + + '}> + + {projectContext.projectDataLoader.data?.projectData.project.project_name} + + + {surveyContext.surveyDataLoader.data?.surveyData.survey_details.survey_name} + + + Manage Animals + + + {animalId} + + + Report Mortality + + + ) : ( + + ) + } + buttonJSX={ + + formikRef.current?.submitForm()}> + Save and Exit + + + + } + /> + + + + handleSubmit(values)} + formikRef={formikRef} + /> + + { + formikRef.current?.submitForm(); + }}> + Save and Exit + + + + + + + ); +}; diff --git a/app/src/features/surveys/animals/profile/mortality/mortality-form/edit/EditMortalityPage.tsx b/app/src/features/surveys/animals/profile/mortality/mortality-form/edit/EditMortalityPage.tsx new file mode 100644 index 0000000000..201f894129 --- /dev/null +++ b/app/src/features/surveys/animals/profile/mortality/mortality-form/edit/EditMortalityPage.tsx @@ -0,0 +1,336 @@ +import LoadingButton from '@mui/lab/LoadingButton'; +import { CircularProgress } from '@mui/material'; +import Breadcrumbs from '@mui/material/Breadcrumbs'; +import Button from '@mui/material/Button'; +import Container from '@mui/material/Container'; +import Link from '@mui/material/Link'; +import Paper from '@mui/material/Paper'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import PageHeader from 'components/layout/PageHeader'; +import { SkeletonHorizontalStack } from 'components/loading/SkeletonLoaders'; +import { EditMortalityI18N } from 'constants/i18n'; +import dayjs from 'dayjs'; +import { AnimalMortalityForm } from 'features/surveys/animals/profile/mortality/mortality-form/components/AnimalMortalityForm'; +import { FormikProps } from 'formik'; +import { APIError } from 'hooks/api/useAxios'; +import { useAnimalPageContext, useDialogContext, useProjectContext, useSurveyContext } from 'hooks/useContext'; +import { useCritterbaseApi } from 'hooks/useCritterbaseApi'; +import useDataLoader from 'hooks/useDataLoader'; +import { SKIP_CONFIRMATION_DIALOG, useUnsavedChangesDialog } from 'hooks/useUnsavedChangesDialog'; +import { IEditMortalityRequest } from 'interfaces/useCritterApi.interface'; +import { useEffect, useRef, useState } from 'react'; +import { Prompt, useHistory, useParams } from 'react-router'; +import { Link as RouterLink } from 'react-router-dom'; +import { formatCritterDetailsForBulkUpdate, formatLocation } from './utils'; + +/** + * Page for editing an existing animal mortality record. + * + * @return {*} + */ +export const EditMortalityPage = () => { + const history = useHistory(); + + const critterbaseApi = useCritterbaseApi(); + + const surveyContext = useSurveyContext(); + const projectContext = useProjectContext(); + const dialogContext = useDialogContext(); + const animalPageContext = useAnimalPageContext(); + + const urlParams: Record = useParams(); + + const surveyCritterId: number | undefined = Number(urlParams['survey_critter_id']); + const mortalityId: string | undefined = String(urlParams['mortality_id']); + + const { locationChangeInterceptor } = useUnsavedChangesDialog(); + + const [isSaving, setIsSaving] = useState(false); + + const formikRef = useRef>(null); + + const { projectId, surveyId } = surveyContext; + + const critter = animalPageContext.critterDataLoader.data; + + const mortalityDataLoader = useDataLoader(() => critterbaseApi.mortality.getMortality(mortalityId)); + + useEffect(() => { + mortalityDataLoader.load(); + }, [mortalityDataLoader]); + + const mortality = mortalityDataLoader.data; + + // If the user has refreshed the page and cleared the context, or come to this page externally from a link, use the + // url params to set the selected animal in the context. The context then requests critter data from Critterbase. + useEffect(() => { + if (animalPageContext.selectedAnimal || !surveyCritterId) { + return; + } + + animalPageContext.setSelectedAnimalFromSurveyCritterId(surveyCritterId); + }, [animalPageContext, surveyCritterId]); + + if (!mortality || !critter) { + return ; + } + + const handleCancel = () => { + history.push(`/admin/projects/${projectId}/surveys/${surveyId}/animals/details`); + }; + + /** + * Creates an Mortality + * + * @return {*} + */ + const handleSubmit = async (values: IEditMortalityRequest) => { + setIsSaving(true); + + try { + const critterbaseCritterId = animalPageContext.selectedAnimal?.critterbase_critter_id; + if (!values || !critterbaseCritterId) { + return; + } + + const mortalityLocation = formatLocation(values.mortality.location); + + // Format mortality timestamp + const mortalityTimestamp = dayjs( + `${values.mortality.mortality_date}${ + values.mortality.mortality_time ? ` ${values.mortality.mortality_time}-07:00` : 'T00:00:00-07:00' + }` + ).toDate(); + + const { + qualitativeMeasurementsForDelete, + quantitativeMeasurementsForDelete, + markingsForDelete, + markingsForCreate, + markingsForUpdate, + qualitativeMeasurementsForCreate, + quantitativeMeasurementsForCreate, + qualitativeMeasurementsForUpdate, + quantitativeMeasurementsForUpdate + } = formatCritterDetailsForBulkUpdate( + critter, + values.markings, + values.measurements, + values.mortality.mortality_id + ); + + // Create new measurements added while editing the mortality + if ( + qualitativeMeasurementsForCreate.length || + quantitativeMeasurementsForCreate.length || + markingsForCreate.length + ) { + await critterbaseApi.critters.bulkCreate({ + qualitative_measurements: qualitativeMeasurementsForCreate, + quantitative_measurements: quantitativeMeasurementsForCreate, + markings: markingsForCreate + }); + } + + // Update existing critter information + const response = await critterbaseApi.critters.bulkUpdate({ + mortality: { + critter_id: critterbaseCritterId, + mortality_id: values.mortality.mortality_id, + location: mortalityLocation, + mortality_timestamp: mortalityTimestamp, + mortality_comment: values.mortality.mortality_comment, + proximate_cause_of_death_id: values.mortality.proximate_cause_of_death_id, + proximate_cause_of_death_confidence: values.mortality.proximate_cause_of_death_confidence, + proximate_predated_by_itis_tsn: values.mortality.proximate_predated_by_itis_tsn, + ultimate_cause_of_death_id: values.mortality.ultimate_cause_of_death_id, + ultimate_cause_of_death_confidence: values.mortality.ultimate_cause_of_death_confidence, + ultimate_predated_by_itis_tsn: values.mortality.ultimate_predated_by_itis_tsn + }, + markings: [...markingsForUpdate, ...markingsForDelete], + qualitative_measurements: [...qualitativeMeasurementsForUpdate, ...qualitativeMeasurementsForDelete], + quantitative_measurements: [...quantitativeMeasurementsForUpdate, ...quantitativeMeasurementsForDelete] + }); + + if (!response) { + dialogContext.setErrorDialog({ + dialogTitle: EditMortalityI18N.editErrorTitle, + dialogText: EditMortalityI18N.editErrorText, + open: true, + onClose: () => { + dialogContext.setErrorDialog({ open: false }); + }, + onOk: () => { + dialogContext.setErrorDialog({ open: false }); + } + }); + return; + } + + // Refresh page + animalPageContext.critterDataLoader.refresh(critterbaseCritterId); + + history.push(`/admin/projects/${projectId}/surveys/${surveyId}/animals/details`, SKIP_CONFIRMATION_DIALOG); + } catch (error) { + const apiError = error as APIError; + + dialogContext.setErrorDialog({ + dialogTitle: EditMortalityI18N.editErrorTitle, + dialogText: EditMortalityI18N.editErrorText, + dialogErrorDetails: apiError?.errors, + open: true, + onClose: () => { + dialogContext.setErrorDialog({ open: false }); + }, + onOk: () => { + dialogContext.setErrorDialog({ open: false }); + } + }); + } finally { + setIsSaving(false); + } + }; + + const [mortalityDate, mortalityTime] = dayjs(mortality.mortality_timestamp).format('YYYY-MM-DD HH:mm:ss').split(' '); + + // Initial formik values + const initialFormikValues: IEditMortalityRequest = { + mortality: { + mortality_id: mortality.mortality_id, + mortality_comment: mortality.mortality_comment ?? '', + mortality_timestamp: mortality.mortality_timestamp, + mortality_date: mortalityDate, + mortality_time: mortalityTime, + proximate_cause_of_death_id: mortality.proximate_cause_of_death_id, + proximate_cause_of_death_confidence: mortality.proximate_cause_of_death_confidence, + proximate_predated_by_itis_tsn: mortality.proximate_predated_by_itis_tsn, + ultimate_cause_of_death_id: mortality.ultimate_cause_of_death_id, + ultimate_cause_of_death_confidence: mortality.ultimate_cause_of_death_confidence, + ultimate_predated_by_itis_tsn: mortality.ultimate_predated_by_itis_tsn, + location: { + type: 'Feature', + geometry: { + type: 'Point', + coordinates: [mortality.location?.longitude ?? 0, mortality.location?.latitude ?? 0] + }, + properties: {} + } + }, + markings: + critter.markings + .filter((marking) => marking.mortality_id === mortality.mortality_id) + .map((marking) => ({ + marking_id: marking.marking_id, + identifier: marking.identifier, + comment: marking.comment, + capture_id: marking.capture_id, + mortality_id: marking.mortality_id, + taxon_marking_body_location_id: marking.taxon_marking_body_location_id, + marking_type_id: marking.marking_type_id, + primary_colour_id: marking.primary_colour_id, + secondary_colour_id: marking.secondary_colour_id + })) ?? [], + measurements: [ + ...(critter.measurements.qualitative + .filter((measurement) => measurement.mortality_id === mortality.mortality_id) + .map((measurement) => ({ + measurement_qualitative_id: measurement.measurement_qualitative_id, + taxon_measurement_id: measurement.taxon_measurement_id, + capture_id: measurement.capture_id, + mortality_id: measurement.mortality_id, + qualitative_option_id: measurement.qualitative_option_id, + measurement_comment: measurement.measurement_comment, + measured_timestamp: measurement.measured_timestamp + })) ?? []), + ...(critter.measurements.quantitative + .filter((measurement) => measurement.mortality_id === mortality.mortality_id) + .map((measurement) => ({ + measurement_quantitative_id: measurement.measurement_quantitative_id, + taxon_measurement_id: measurement.taxon_measurement_id, + capture_id: measurement.capture_id, + mortality_id: measurement.mortality_id, + measurement_comment: measurement.measurement_comment, + measured_timestamp: measurement.measured_timestamp, + value: measurement.value + })) ?? []) + ] + }; + + return ( + <> + + '}> + + {projectContext.projectDataLoader.data?.projectData.project.project_name} + + + {surveyContext.surveyDataLoader.data?.surveyData.survey_details.survey_name} + + + Manage Animals + + + {critter.animal_id} + + + Edit Mortality + + + ) : ( + + ) + } + buttonJSX={ + + formikRef.current?.submitForm()}> + Save and Exit + + + + } + /> + + + + handleSubmit(values)} + formikRef={formikRef} + /> + + { + formikRef.current?.submitForm(); + }}> + Save and Exit + + + + + + + ); +}; diff --git a/app/src/features/surveys/animals/profile/mortality/mortality-form/edit/utils.ts b/app/src/features/surveys/animals/profile/mortality/mortality-form/edit/utils.ts new file mode 100644 index 0000000000..ab16e4174d --- /dev/null +++ b/app/src/features/surveys/animals/profile/mortality/mortality-form/edit/utils.ts @@ -0,0 +1,185 @@ +import { + isQualitativeMeasurementCreate, + isQualitativeMeasurementUpdate, + isQuantitativeMeasurementCreate, + isQuantitativeMeasurementUpdate +} from 'features/surveys/animals/profile/measurements/utils'; +import { Feature } from 'geojson'; +import { + ICritterDetailedResponse, + IMarkingPostData, + IQualitativeMeasurementCreate, + IQualitativeMeasurementUpdate, + IQuantitativeMeasurementCreate, + IQuantitativeMeasurementUpdate +} from 'interfaces/useCritterApi.interface'; + +/** + * Formats a location object into the required structure. + * + * @param {(Feature | null)} [location] The location object to be formatted. + * @return {*} The formatted location object. + */ +export const formatLocation = (location: Feature | null) => { + if (location?.geometry.type === 'Point') { + return { + longitude: location.geometry.coordinates[0], + latitude: location.geometry.coordinates[1], + coordinate_uncertainty: 0, + coordinate_uncertainty_units: 'm' + }; + } + + return undefined; +}; + +/** + * Formats critter details for bulk update, including markings and measurements. + * + * @param {ICritterDetailedResponse} critter The critter object containing existing details. + * @param {IMarkingPostData[]} markings Array of markings for the critter. + * @param {((IQuantitativeMeasurementUpdate | IQualitativeMeasurementUpdate)[])} measurements Array of measurements for the critter. + * @param {string} mortalityId The mortality id + * @return {*} Formatted critter details for bulk update. + */ +export const formatCritterDetailsForBulkUpdate = ( + critter: ICritterDetailedResponse, + markings: IMarkingPostData[], + measurements: ( + | IQuantitativeMeasurementCreate + | IQualitativeMeasurementCreate + | IQuantitativeMeasurementUpdate + | IQualitativeMeasurementUpdate + )[], + mortalityId: string +) => { + // Find qualitative measurements to delete + const qualitativeMeasurementsForDelete = + critter.measurements.qualitative + // Filter out measurements that are not on the current mortality + .filter( + (existingQualitativeMeasurementsOnCritter) => + existingQualitativeMeasurementsOnCritter.mortality_id === mortalityId + ) + // Filter out measurements that are not in the incoming measurements + .filter( + (existingQualitativeMeasurementsOnMortality) => + !measurements.some( + (incomingMeasurements) => + isQualitativeMeasurementUpdate(incomingMeasurements) && + incomingMeasurements.measurement_qualitative_id === + existingQualitativeMeasurementsOnMortality.measurement_qualitative_id + ) + ) + // The remaining measurements are the ones to delete from the critter for the current mortality + .map((item) => ({ ...item, _delete: true })) ?? []; + + // Find quantitative measurements to delete + const quantitativeMeasurementsForDelete = + critter.measurements.quantitative + // Filter out measurements that are not on the current mortality + .filter( + (existingQUantitativeMeasurementsOnCritter) => + existingQUantitativeMeasurementsOnCritter.mortality_id === mortalityId + ) + // Filter out measurements that are not in the incoming measurements + .filter( + (existingQuantitativeMeasurementsOnMortality) => + !measurements.some( + (incomingMeasurements) => + isQuantitativeMeasurementUpdate(incomingMeasurements) && + incomingMeasurements.measurement_quantitative_id === + existingQuantitativeMeasurementsOnMortality.measurement_quantitative_id + ) + ) + // The remaining measurements are the ones to delete from the critter for the current mortality + .map((item) => ({ ...item, _delete: true })) ?? []; + + // Find markings to delete + const markingsForDelete = critter.markings + // Filter out markings that are not on the current mortality + .filter((existingMarkingsOnCritter) => existingMarkingsOnCritter.mortality_id === mortalityId) + // Filter out markings that are not in the incoming markings + .filter( + (existingmarkingsOnMortality) => + !markings.some((incomingMarking) => incomingMarking.marking_id === existingmarkingsOnMortality.marking_id) + ) + // The remaining markings are the ones to delete from the critter for the current mortality + .map((item) => ({ ...item, critter_id: critter.critter_id, _delete: true })); + + // Find markings for create + const markingsForCreate = markings + // Filter out markings that have a marking_id (i.e. they are existing markings that need to be updated, not created) + .filter((marking) => !marking.marking_id) + .map((marking) => ({ + ...marking, + marking_id: marking.marking_id, + critter_id: critter.critter_id, + mortality_id: mortalityId + })); + + // Find markings for update + const markingsForUpdate = markings + // Filter out markings that do not have a marking_id (i.e. they are new markings that need to be created, not updated) + .filter((marking) => marking.marking_id) + .map((marking) => ({ + ...marking, + marking_id: marking.marking_id, + critter_id: critter.critter_id, + mortality_id: mortalityId + })); + + // Find qualitative measurements for create + const qualitativeMeasurementsForCreate = measurements.filter(isQualitativeMeasurementCreate).map((measurement) => ({ + critter_id: critter.critter_id, + mortality_id: mortalityId, + taxon_measurement_id: measurement.taxon_measurement_id, + qualitative_option_id: measurement.qualitative_option_id, + measured_timestamp: measurement.measured_timestamp, + measurement_comment: measurement.measurement_comment + })); + + // Find quantitative measurements for create + const quantitativeMeasurementsForCreate = measurements.filter(isQuantitativeMeasurementCreate).map((measurement) => ({ + critter_id: critter.critter_id, + mortality_id: mortalityId, + taxon_measurement_id: measurement.taxon_measurement_id, + value: measurement.value, + measured_timestamp: measurement.measured_timestamp, + measurement_comment: measurement.measurement_comment + })); + + // Find qualitative measurements for update + const qualitativeMeasurementsForUpdate = measurements.filter(isQualitativeMeasurementUpdate).map((measurement) => ({ + critter_id: critter.critter_id, + mortality_id: mortalityId, + measurement_qualitative_id: measurement.measurement_qualitative_id, + taxon_measurement_id: measurement.taxon_measurement_id, + qualitative_option_id: measurement.qualitative_option_id, + measured_timestamp: measurement.measured_timestamp, + measurement_comment: measurement.measurement_comment + })); + + // Find quantitative measurements for update + const quantitativeMeasurementsForUpdate = measurements.filter(isQuantitativeMeasurementUpdate).map((measurement) => ({ + critter_id: critter.critter_id, + mortality_id: mortalityId, + measurement_quantitative_id: measurement.measurement_quantitative_id, + taxon_measurement_id: measurement.taxon_measurement_id, + value: measurement.value, + measured_timestamp: measurement.measured_timestamp, + measurement_comment: measurement.measurement_comment + })); + + return { + qualitativeMeasurementsForDelete, + quantitativeMeasurementsForDelete, + markingsForDelete, + markingsForCreate, + markingsForUpdate, + qualitativeMeasurementsForCreate, + quantitativeMeasurementsForCreate, + qualitativeMeasurementsForUpdate, + quantitativeMeasurementsForUpdate + }; +}; diff --git a/app/src/features/surveys/components/locations/StudyAreaForm.tsx b/app/src/features/surveys/components/locations/StudyAreaForm.tsx index 334f00e810..14d8b7ff60 100644 --- a/app/src/features/surveys/components/locations/StudyAreaForm.tsx +++ b/app/src/features/surveys/components/locations/StudyAreaForm.tsx @@ -156,8 +156,8 @@ const StudyAreaForm = () => { {errors.locations && !Array.isArray(errors?.locations) && ( - - Study Area Missing + + Missing study area {errors.locations} )} diff --git a/app/src/features/surveys/components/locations/SurveyAreaMapControl.tsx b/app/src/features/surveys/components/locations/SurveyAreaMapControl.tsx index d5f91eaf27..a76ca58ae5 100644 --- a/app/src/features/surveys/components/locations/SurveyAreaMapControl.tsx +++ b/app/src/features/surveys/components/locations/SurveyAreaMapControl.tsx @@ -41,7 +41,9 @@ export const SurveyAreaMapControl = (props: ISurveyAreMapControlProps) => { const [selectedRegion, setSelectedRegion] = useState(null); useEffect(() => { - setUpdatedBounds(calculateUpdatedMapBounds(formik_props.values.locations.map((item) => item.geojson[0]))); + if (formik_props.values.locations.length) { + setUpdatedBounds(calculateUpdatedMapBounds(formik_props.values.locations.map((item) => item.geojson[0]))); + } }, [formik_props.values.locations]); return ( diff --git a/app/src/features/surveys/edit/EditSurveyPage.tsx b/app/src/features/surveys/edit/EditSurveyPage.tsx index fa85c9ba44..c0a7563eaa 100644 --- a/app/src/features/surveys/edit/EditSurveyPage.tsx +++ b/app/src/features/surveys/edit/EditSurveyPage.tsx @@ -40,7 +40,7 @@ const EditSurveyPage = () => { const formikRef = useRef>(null); // Ability to bypass showing the 'Are you sure you want to cancel' dialog - const [enableCancelCheck, setEnableCancelCheck] = useState(true); + const [enableCancelCheck, setEnableCancelCheck] = useState(true); const [isSaving, setIsSaving] = useState(false); const { locationChangeInterceptor } = useUnsavedChangesDialog(); diff --git a/app/src/features/surveys/observations/observations-table/configure-columns/components/measurements/search/MeasurementsSearchAutocomplete.tsx b/app/src/features/surveys/observations/observations-table/configure-columns/components/measurements/search/MeasurementsSearchAutocomplete.tsx index dee28f76b8..a531d1d57a 100644 --- a/app/src/features/surveys/observations/observations-table/configure-columns/components/measurements/search/MeasurementsSearchAutocomplete.tsx +++ b/app/src/features/surveys/observations/observations-table/configure-columns/components/measurements/search/MeasurementsSearchAutocomplete.tsx @@ -32,6 +32,13 @@ export interface IMeasurementsSearchAutocompleteProps { * @memberof IMeasurementsSearchAutocompleteProps */ onAddMeasurementColumn: (measurementColumn: CBMeasurementType) => void; + /** + * The species to filter measurement options for + * + * @type {number[]} + * @memberof IMeasurementsSearchAutocompleteProps + */ + speciesTsn?: number[]; } /** diff --git a/app/src/features/surveys/observations/sampling-sites/components/map/SamplingSiteMapControl.tsx b/app/src/features/surveys/observations/sampling-sites/components/map/SamplingSiteMapControl.tsx index 567cef1596..03652671c3 100644 --- a/app/src/features/surveys/observations/sampling-sites/components/map/SamplingSiteMapControl.tsx +++ b/app/src/features/surveys/observations/sampling-sites/components/map/SamplingSiteMapControl.tsx @@ -89,7 +89,7 @@ const SamplingSiteMapControl = (props: ISamplingSiteMapControlProps) => { ); useEffect(() => { - if (samplingSiteGeoJsonFeatures) { + if (samplingSiteGeoJsonFeatures.length) { setUpdatedBounds(calculateUpdatedMapBounds(samplingSiteGeoJsonFeatures)); } }, [samplingSiteGeoJsonFeatures]); diff --git a/app/src/features/surveys/observations/sampling-sites/create/form/MethodForm.tsx b/app/src/features/surveys/observations/sampling-sites/create/form/MethodForm.tsx index 13bc1a7b00..cba48b6148 100644 --- a/app/src/features/surveys/observations/sampling-sites/create/form/MethodForm.tsx +++ b/app/src/features/surveys/observations/sampling-sites/create/form/MethodForm.tsx @@ -221,7 +221,6 @@ const MethodForm = () => { timeRequired: false, timeIcon: mdiClockOutline }} - parentName={`sample_periods[${index}]`} formikProps={formikProps} /> {errors.sample_periods && @@ -260,7 +259,6 @@ const MethodForm = () => { timeRequired: false, timeIcon: mdiClockOutline }} - parentName={`sample_periods[${index}]`} formikProps={formikProps} /> {errors.sample_periods && diff --git a/app/src/features/surveys/observations/sampling-sites/create/form/SamplingMethodForm.tsx b/app/src/features/surveys/observations/sampling-sites/create/form/SamplingMethodForm.tsx index cc8466b582..b8e43e62e7 100644 --- a/app/src/features/surveys/observations/sampling-sites/create/form/SamplingMethodForm.tsx +++ b/app/src/features/surveys/observations/sampling-sites/create/form/SamplingMethodForm.tsx @@ -59,9 +59,6 @@ const SamplingMethodForm = () => { setAnchorEl(null); }; - console.log(values); - console.log(errors); - return ( <> {/* CREATE SAMPLE METHOD DIALOG */} @@ -138,7 +135,7 @@ const SamplingMethodForm = () => { {errors.sample_methods && !Array.isArray(errors.sample_methods) && ( Missing sampling method diff --git a/app/src/features/surveys/observations/sampling-sites/edit/form/SampleMethodEditForm.tsx b/app/src/features/surveys/observations/sampling-sites/edit/form/SampleMethodEditForm.tsx index f730c72e75..3d6d1a195c 100644 --- a/app/src/features/surveys/observations/sampling-sites/edit/form/SampleMethodEditForm.tsx +++ b/app/src/features/surveys/observations/sampling-sites/edit/form/SampleMethodEditForm.tsx @@ -148,7 +148,7 @@ const SampleMethodEditForm = (props: SampleMethodEditFormProps) => { {errors.sample_methods && !Array.isArray(errors.sample_methods) && ( Missing sampling method diff --git a/app/src/features/surveys/view/SurveyAnimals.test.tsx b/app/src/features/surveys/view/SurveyAnimals.test.tsx index 7caef6f7db..b72538159e 100644 --- a/app/src/features/surveys/view/SurveyAnimals.test.tsx +++ b/app/src/features/surveys/view/SurveyAnimals.test.tsx @@ -98,7 +98,7 @@ describe('SurveyAnimals', () => { ); await waitFor(() => { - expect(getByText('No Marked or Known Animals')).toBeInTheDocument(); + expect(getByText('No Animals')).toBeInTheDocument(); }); }); diff --git a/app/src/features/surveys/view/SurveyAnimals.tsx b/app/src/features/surveys/view/SurveyAnimals.tsx index 273f481ebd..fe7e168078 100644 --- a/app/src/features/surveys/view/SurveyAnimals.tsx +++ b/app/src/features/surveys/view/SurveyAnimals.tsx @@ -20,7 +20,7 @@ import { SurveyAnimalsTable } from './survey-animals/SurveyAnimalsTable'; import TelemetryMap from './survey-animals/telemetry-device/TelemetryMap'; const SurveyAnimals: React.FC = () => { - const bhApi = useBiohubApi(); + const biohubApi = useBiohubApi(); const dialogContext = useContext(DialogContext); const surveyContext = useContext(SurveyContext); const history = useHistory(); @@ -34,10 +34,10 @@ const SurveyAnimals: React.FC = () => { refresh: refreshCritters, load: loadCritters, data: critterData - } = useDataLoader(() => bhApi.survey.getSurveyCritters(projectId, surveyId)); + } = useDataLoader(() => biohubApi.survey.getSurveyCritters(projectId, surveyId)); const { load: loadDeployments, data: deploymentData } = useDataLoader(() => - bhApi.survey.getDeploymentsInSurvey(projectId, surveyId) + biohubApi.survey.getDeploymentsInSurvey(projectId, surveyId) ); if (!critterData) { @@ -58,7 +58,7 @@ const SurveyAnimals: React.FC = () => { data: telemetryData, isLoading: telemetryLoading } = useDataLoader(() => - bhApi.survey.getCritterTelemetry( + biohubApi.survey.getCritterTelemetry( projectId, surveyId, selectedCritterId ?? 0, @@ -91,7 +91,7 @@ const SurveyAnimals: React.FC = () => { setPopup('Failed to remove critter from survey.'); return; } - await bhApi.survey.removeCritterFromSurvey(projectId, surveyId, selectedCritterId); + await biohubApi.survey.removeCrittersFromSurvey(projectId, surveyId, [selectedCritterId]); } catch (e) { setPopup('Failed to remove critter from survey.'); } @@ -120,7 +120,7 @@ const SurveyAnimals: React.FC = () => { sx={{ flex: '1 1 auto' }}> - Marked and Known Animals + Animals { - + , string | undefined>; } - export class ArraySchema extends yup.ArraySchema { + interface ArraySchema { /** * Determine if the array of classification details has duplicates * @@ -160,5 +160,16 @@ declare module 'yup' { startKey: string, endKey: string ): yup.StringSchema, string | undefined>; + + /** + * Validates that the GeoJson point coordinates (latitude/longitude) are valid + * + * @param {string} message Error message to display + * @return {*} {(yup.NumberSchema, number | undefined>)} + * @memberof ArraySchema + */ + isValidPointCoordinates( + message: string + ): yup.NumberSchema, number | undefined>; } } diff --git a/app/src/utils/Utils.tsx b/app/src/utils/Utils.tsx index 0bf55f09a3..16e635759d 100644 --- a/app/src/utils/Utils.tsx +++ b/app/src/utils/Utils.tsx @@ -412,8 +412,6 @@ export const shapeFileFeatureDesc = (geometry: Feature { + if (!value || !value.length) { + return false; + } + return isValidCoordinates(value[1], value[0]); + }); +}); + export default yup; diff --git a/app/src/utils/spatial-utils.ts b/app/src/utils/spatial-utils.ts index 4d4104a325..9cce878245 100644 --- a/app/src/utils/spatial-utils.ts +++ b/app/src/utils/spatial-utils.ts @@ -1,4 +1,4 @@ -import { Feature } from 'geojson'; +import { Feature, Point } from 'geojson'; import { isDefined } from 'utils/Utils'; /** @@ -33,3 +33,37 @@ export const getPointFeature = (param properties: { ...params.properties } }; }; + +/** + * Checks whether a latitude-longitude pair of coordinates is valid + * + * @param {number} latitude + * @param {number} longitude + * @returns boolean + */ +export const isValidCoordinates = (latitude: number | undefined, longitude: number | undefined) => { + return latitude && longitude && latitude > -90 && latitude < 90 && longitude > -180 && longitude < 180 ? true : false; +}; + +/** + * Gets latitude and longitude values from a GeoJson Point Feature. + * + * @param {Feature} feature + * @return {*} {{ latitude: number; longitude: number }} + */ +export const getCoordinatesFromGeoJson = (feature: Feature): { latitude: number; longitude: number } => { + const lon = feature.geometry.coordinates[0]; + const lat = feature.geometry.coordinates[1]; + + return { latitude: lat as number, longitude: lon as number }; +}; + +/** + * Checks if the given feature is a GeoJson Feature containing a Point. + * + * @param {(Feature | any)} [feature] + * @return {*} {feature is Feature} + */ +export const isGeoJsonPointFeature = (feature?: Feature | any): feature is Feature => { + return (feature as Feature)?.geometry.type === 'Point'; +}; From a3cb8a7d72e3fc99a0ad347dd23e0f786ee1bab6 Mon Sep 17 00:00:00 2001 From: Macgregor Aubertin-Young <108430771+mauberti-bc@users.noreply.github.com> Date: Fri, 21 Jun 2024 15:32:49 -0700 Subject: [PATCH 22/31] SIMSBIOHUB-595: Remove project dates and programs (#1309) * remove project dates and programs * Remove remaining references to program. * Drop program code table. --------- Co-authored-by: Nick Phura --- api/src/models/project-create.test.ts | 25 - api/src/models/project-create.ts | 6 - api/src/models/project-update.test.ts | 27 - api/src/models/project-update.ts | 6 - api/src/models/project-view.test.ts | 30 - api/src/models/project-view.ts | 11 +- api/src/openapi/schemas/project.ts | 28 +- api/src/openapi/schemas/survey.ts | 11 +- api/src/openapi/schemas/user.ts | 6 +- api/src/paths/administrative-activities.ts | 4 +- api/src/paths/administrative-activity.ts | 2 +- api/src/paths/codes.ts | 16 - .../funding-sources/{fundingSourceId}.ts | 4 - api/src/paths/project/list.test.ts | 14 +- api/src/paths/project/list.ts | 43 +- .../paths/project/{projectId}/survey/index.ts | 2 - .../survey/{surveyId}/attachments/list.ts | 24 +- .../{surveyObservationId}/index.ts | 8 +- api/src/paths/project/{projectId}/update.ts | 19 +- api/src/paths/project/{projectId}/view.ts | 19 +- api/src/paths/resources/list.ts | 3 +- api/src/paths/user/{userId}/get.ts | 2 +- api/src/repositories/code-repository.ts | 21 - .../repositories/project-repository.test.ts | 79 +- api/src/repositories/project-repository.ts | 97 +- api/src/services/code-service.test.ts | 1 - api/src/services/code-service.ts | 3 - api/src/services/eml-service.test.ts | 1392 ----------------- api/src/services/eml-service.ts | 1015 ------------ api/src/services/project-service.test.ts | 12 +- api/src/services/project-service.ts | 43 +- .../search-filter/ProjectAdvancedFilters.tsx | 20 - .../components/ProjectDetailsForm.test.tsx | 28 +- .../components/ProjectDetailsForm.tsx | 53 +- .../projects/edit/EditProjectForm.tsx | 8 +- .../projects/list/ProjectsListPage.test.tsx | 4 +- .../projects/list/ProjectsListPage.tsx | 30 +- .../features/projects/view/ProjectDetails.tsx | 9 - .../features/projects/view/ProjectHeader.tsx | 30 - .../view/components/GeneralInformation.tsx | 66 - .../GeneralInformationForm.tsx | 4 +- .../features/surveys/edit/EditSurveyForm.tsx | 2 - app/src/interfaces/useCodesApi.interface.ts | 1 - app/src/interfaces/useProjectApi.interface.ts | 11 - app/src/test-helpers/code-helpers.ts | 1 - app/src/test-helpers/project-helpers.ts | 6 +- .../20240620000000_project_changes.ts | 53 + .../procedures/delete_project_procedure.ts | 2 - database/src/procedures/tr_project.ts | 34 - .../seeds/03_basic_project_survey_setup.ts | 23 +- scripts/bctw-deployments/main.js | 9 +- 51 files changed, 120 insertions(+), 3247 deletions(-) delete mode 100644 api/src/services/eml-service.test.ts delete mode 100644 api/src/services/eml-service.ts delete mode 100644 app/src/features/projects/view/components/GeneralInformation.tsx create mode 100644 database/src/migrations/20240620000000_project_changes.ts delete mode 100644 database/src/procedures/tr_project.ts diff --git a/api/src/models/project-create.test.ts b/api/src/models/project-create.test.ts index 584d233fcf..8738868337 100644 --- a/api/src/models/project-create.test.ts +++ b/api/src/models/project-create.test.ts @@ -36,18 +36,6 @@ describe('PostProjectData', () => { expect(projectPostData.name).to.equal(null); }); - it('sets programs', function () { - expect(projectPostData.project_programs).to.have.length(0); - }); - - it('sets start_date', function () { - expect(projectPostData.start_date).to.equal(null); - }); - - it('sets end_date', function () { - expect(projectPostData.end_date).to.equal(null); - }); - it('sets comments', function () { expect(projectPostData.comments).to.equal(null); }); @@ -58,7 +46,6 @@ describe('PostProjectData', () => { const obj = { project_name: 'name_test_data', - project_programs: [1], start_date: 'start_date_test_data', end_date: 'end_date_test_data', comments: 'comments_test_data' @@ -72,18 +59,6 @@ describe('PostProjectData', () => { expect(projectPostData.name).to.equal('name_test_data'); }); - it('sets type', function () { - expect(projectPostData.project_programs).to.eql([1]); - }); - - it('sets start_date', function () { - expect(projectPostData.start_date).to.equal('start_date_test_data'); - }); - - it('sets end_date', function () { - expect(projectPostData.end_date).to.equal('end_date_test_data'); - }); - it('sets comments', function () { expect(projectPostData.comments).to.equal('comments_test_data'); }); diff --git a/api/src/models/project-create.ts b/api/src/models/project-create.ts index bbad22ab4b..9266b6cb79 100644 --- a/api/src/models/project-create.ts +++ b/api/src/models/project-create.ts @@ -34,18 +34,12 @@ export class PostProjectObject { */ export class PostProjectData { name: string; - project_programs: number[]; - start_date: string; - end_date: string; comments: string; constructor(obj?: any) { defaultLog.debug({ label: 'PostProjectData', message: 'params', obj }); this.name = obj?.project_name || null; - this.project_programs = obj?.project_programs || []; - this.start_date = obj?.start_date || null; - this.end_date = obj?.end_date || null; this.comments = obj?.comments || null; } } diff --git a/api/src/models/project-update.test.ts b/api/src/models/project-update.test.ts index 91770246fb..405909387a 100644 --- a/api/src/models/project-update.test.ts +++ b/api/src/models/project-update.test.ts @@ -14,18 +14,6 @@ describe('PutProjectData', () => { expect(data.name).to.equal(null); }); - it('sets type', () => { - expect(data.project_programs).to.eql([]); - }); - - it('sets start_date', () => { - expect(data.start_date).to.equal(null); - }); - - it('sets end_date', () => { - expect(data.end_date).to.equal(null); - }); - it('sets revision_count', () => { expect(data.revision_count).to.equal(null); }); @@ -34,9 +22,6 @@ describe('PutProjectData', () => { describe('all values provided', () => { const obj = { project_name: 'project name', - project_programs: [1], - start_date: '2020-04-20T07:00:00.000Z', - end_date: '2020-05-20T07:00:00.000Z', revision_count: 1 }; @@ -50,18 +35,6 @@ describe('PutProjectData', () => { expect(data.name).to.equal('project name'); }); - it('sets programs', () => { - expect(data.project_programs).to.eql([1]); - }); - - it('sets start_date', () => { - expect(data.start_date).to.eql('2020-04-20T07:00:00.000Z'); - }); - - it('sets end_date', () => { - expect(data.end_date).to.equal('2020-05-20T07:00:00.000Z'); - }); - it('sets revision_count', () => { expect(data.revision_count).to.equal(1); }); diff --git a/api/src/models/project-update.ts b/api/src/models/project-update.ts index f66364a269..a5a46f2ab4 100644 --- a/api/src/models/project-update.ts +++ b/api/src/models/project-update.ts @@ -4,18 +4,12 @@ const defaultLog = getLogger('models/project-update'); export class PutProjectData { name: string; - project_programs: number[]; - start_date: string; - end_date: string; revision_count: number; constructor(obj?: any) { defaultLog.debug({ label: 'PutProjectData', message: 'params', obj }); this.name = obj?.project_name || null; - this.project_programs = obj?.project_programs || []; - this.start_date = obj?.start_date || null; - this.end_date = obj?.end_date || null; this.revision_count = obj?.revision_count ?? null; } } diff --git a/api/src/models/project-view.test.ts b/api/src/models/project-view.test.ts index e24454106e..7a9deed4dc 100644 --- a/api/src/models/project-view.test.ts +++ b/api/src/models/project-view.test.ts @@ -17,9 +17,6 @@ describe('ProjectData', () => { project_id: 1, uuid: 'uuid', project_name: '', - project_programs: [], - start_date: '2005-01-01', - end_date: '2006-01-01', comments: '', revision_count: 1 }; @@ -32,18 +29,6 @@ describe('ProjectData', () => { it('sets name', () => { expect(data.project_name).to.equal(''); }); - - it('sets programs', () => { - expect(data.project_programs).to.eql([]); - }); - - it('sets start_date', () => { - expect(data.start_date).to.eql('2005-01-01'); - }); - - it('sets end_date', () => { - expect(data.end_date).to.eql('2006-01-01'); - }); }); describe('all values provided', () => { @@ -63,9 +48,6 @@ describe('ProjectData', () => { project_id: 1, uuid: 'uuid', project_name: 'project name', - project_programs: [1], - start_date: '2020-04-20T07:00:00.000Z', - end_date: '2020-05-20T07:00:00.000Z', comments: '', revision_count: 1 }; @@ -78,18 +60,6 @@ describe('ProjectData', () => { it('sets name', () => { expect(data.project_name).to.equal(projectData.name); }); - - it('sets type', () => { - expect(data.project_programs).to.eql([1]); - }); - - it('sets start_date', () => { - expect(data.start_date).to.eql('2020-04-20T07:00:00.000Z'); - }); - - it('sets end_date', () => { - expect(data.end_date).to.eql('2020-05-20T07:00:00.000Z'); - }); }); }); diff --git a/api/src/models/project-view.ts b/api/src/models/project-view.ts index b562ceca09..e49f79afd6 100644 --- a/api/src/models/project-view.ts +++ b/api/src/models/project-view.ts @@ -3,9 +3,6 @@ import { ProjectUser } from '../repositories/project-participation-repository'; import { SystemUser } from '../repositories/user-repository'; export interface IProjectAdvancedFilters { - project_programs?: number[]; - start_date?: string; - end_date?: string; keyword?: string; project_name?: string; itis_tsns?: number[]; @@ -22,9 +19,6 @@ export const ProjectData = z.object({ project_id: z.number(), uuid: z.string().uuid(), project_name: z.string(), - project_programs: z.array(z.number()), - start_date: z.string(), - end_date: z.string().nullable(), comments: z.string().nullable(), revision_count: z.number() }); @@ -34,10 +28,7 @@ export type ProjectData = z.infer; export const ProjectListData = z.object({ project_id: z.number(), name: z.string(), - project_programs: z.array(z.number()), - regions: z.array(z.string()), - start_date: z.string(), - end_date: z.string().nullable().optional() + regions: z.array(z.string()) }); export type ProjectListData = z.infer; diff --git a/api/src/openapi/schemas/project.ts b/api/src/openapi/schemas/project.ts index 03b4341580..e9a9f7a94d 100644 --- a/api/src/openapi/schemas/project.ts +++ b/api/src/openapi/schemas/project.ts @@ -17,13 +17,6 @@ export const projectCreatePostRequestObject = { project_name: { type: 'string' }, - project_programs: { - type: 'array', - minItems: 1, - items: { - type: 'number' - } - }, start_date: { type: 'string', description: 'ISO 8601 date string' @@ -112,7 +105,7 @@ const projectUpdateProperties = { description: 'Basic project metadata', type: 'object', additionalProperties: false, - required: ['project_name', 'project_programs', 'start_date', 'end_date', 'revision_count'], + required: ['project_name', 'revision_count'], nullable: true, properties: { project_id: { @@ -129,23 +122,6 @@ const projectUpdateProperties = { project_name: { type: 'string' }, - project_programs: { - type: 'array', - items: { - type: 'number' - } - }, - start_date: { - type: 'string', - format: 'date', - description: 'ISO 8601 date string for the project start date' - }, - end_date: { - type: 'string', - format: 'date', - description: 'ISO 8601 date string for the project end date', - nullable: true - }, revision_count: { type: 'number' } @@ -242,7 +218,7 @@ const projectUpdateProperties = { type: 'string' }, record_end_date: { - oneOf: [{ type: 'object' }, { type: 'string', format: 'date' }], + type: 'string', description: 'Determines if the user record has expired', nullable: true }, diff --git a/api/src/openapi/schemas/survey.ts b/api/src/openapi/schemas/survey.ts index fb8e24eeb9..c7db5e593a 100644 --- a/api/src/openapi/schemas/survey.ts +++ b/api/src/openapi/schemas/survey.ts @@ -28,9 +28,8 @@ export const surveyDetailsSchema: OpenAPIV3.SchemaObject = { type: 'string' }, start_date: { - description: 'Survey start date', type: 'string', - format: 'date' + description: 'Survey start date. ISO 8601 date string.' }, end_date: { description: 'Survey end date', @@ -94,13 +93,11 @@ export const surveyFundingSourceSchema: OpenAPIV3.SchemaObject = { start_date: { description: 'Funding source start date', type: 'string', - format: 'date', nullable: true }, end_date: { description: 'Funding source end date', type: 'string', - format: 'date', nullable: true }, description: { @@ -266,13 +263,11 @@ export const surveyFundingSourceDataSchema: OpenAPIV3.SchemaObject = { start_date: { description: 'Funding source start date', type: 'string', - format: 'date', nullable: true }, end_date: { description: 'Funding source end date', type: 'string', - format: 'date', nullable: true }, description: { @@ -575,8 +570,8 @@ export const surveySupplementaryDataSchema: OpenAPIV3.SchemaObject = { minimum: 1 }, event_timestamp: { - oneOf: [{ type: 'object' }, { type: 'string', format: 'date' }], - description: 'ISO 8601 date string for the project start date' + type: 'string', + description: 'ISO 8601 date string' }, submission_uuid: { type: 'string', diff --git a/api/src/openapi/schemas/user.ts b/api/src/openapi/schemas/user.ts index 3d16c96f49..598a7defcc 100644 --- a/api/src/openapi/schemas/user.ts +++ b/api/src/openapi/schemas/user.ts @@ -68,7 +68,7 @@ export const systemUserSchema: OpenAPIV3.SchemaObject = { type: 'string' }, record_end_date: { - oneOf: [{ type: 'object' }, { type: 'string', format: 'date' }], + type: 'string', description: 'Determines if the user record has expired', nullable: true }, @@ -203,7 +203,7 @@ export const projectAndSystemUserSchema: OpenAPIV3.SchemaObject = { type: 'string' }, record_end_date: { - oneOf: [{ type: 'object' }, { type: 'string', format: 'date' }], + type: 'string', description: 'Determines if the user record has expired', nullable: true }, @@ -277,7 +277,7 @@ export const surveyParticipationAndSystemUserSchema: OpenAPIV3.SchemaObject = { type: 'string' }, record_end_date: { - oneOf: [{ type: 'object' }, { type: 'string', format: 'date' }], + type: 'string', description: 'Determines if the user record has expired', nullable: true }, diff --git a/api/src/paths/administrative-activities.ts b/api/src/paths/administrative-activities.ts index 3223ffb065..5f83783081 100644 --- a/api/src/paths/administrative-activities.ts +++ b/api/src/paths/administrative-activities.ts @@ -106,8 +106,8 @@ GET.apiDoc = { nullable: true }, create_date: { - oneOf: [{ type: 'object' }, { type: 'string', format: 'date' }], - description: 'ISO 8601 date string for the project start date' + type: 'string', + description: 'ISO 8601 date string' } } } diff --git a/api/src/paths/administrative-activity.ts b/api/src/paths/administrative-activity.ts index c370a4fe82..04dec02ae5 100644 --- a/api/src/paths/administrative-activity.ts +++ b/api/src/paths/administrative-activity.ts @@ -48,7 +48,7 @@ POST.apiDoc = { }, date: { description: 'The date this administrative activity was made', - oneOf: [{ type: 'object' }, { type: 'string', format: 'date' }] + type: 'string' } } } diff --git a/api/src/paths/codes.ts b/api/src/paths/codes.ts index 606ffdca45..882107504e 100644 --- a/api/src/paths/codes.ts +++ b/api/src/paths/codes.ts @@ -30,7 +30,6 @@ GET.apiDoc = { 'iucn_conservation_action_level_2_subclassification', 'iucn_conservation_action_level_3_subclassification', 'proprietor_type', - 'program', 'system_roles', 'project_roles', 'administrative_activity_status_type', @@ -189,21 +188,6 @@ GET.apiDoc = { } } }, - program: { - type: 'array', - items: { - type: 'object', - additionalProperties: false, - properties: { - id: { - type: 'number' - }, - name: { - type: 'string' - } - } - } - }, system_roles: { type: 'array', items: { diff --git a/api/src/paths/funding-sources/{fundingSourceId}.ts b/api/src/paths/funding-sources/{fundingSourceId}.ts index 349ccb34ab..b630fef7d7 100644 --- a/api/src/paths/funding-sources/{fundingSourceId}.ts +++ b/api/src/paths/funding-sources/{fundingSourceId}.ts @@ -89,12 +89,10 @@ GET.apiDoc = { }, start_date: { type: 'string', - format: 'date', nullable: true }, end_date: { type: 'string', - format: 'date', nullable: true } } @@ -252,12 +250,10 @@ PUT.apiDoc = { }, start_date: { type: 'string', - format: 'date', nullable: true }, end_date: { type: 'string', - format: 'date', nullable: true }, revision_count: { diff --git a/api/src/paths/project/list.test.ts b/api/src/paths/project/list.test.ts index a730991df9..20674b8a09 100644 --- a/api/src/paths/project/list.test.ts +++ b/api/src/paths/project/list.test.ts @@ -7,7 +7,7 @@ import { SYSTEM_ROLE } from '../../constants/roles'; import * as db from '../../database/db'; import { HTTPError } from '../../errors/http-error'; import * as authorization from '../../request-handlers/security/authorization'; -import { COMPLETION_STATUS, ProjectService } from '../../services/project-service'; +import { ProjectService } from '../../services/project-service'; import { getMockDBConnection } from '../../__mocks__/db'; import * as list from './list'; @@ -94,11 +94,7 @@ describe('list', () => { { project_id: 1, name: 'myproject', - project_programs: [1], - start_date: '2022-02-02', - end_date: null, - regions: [], - completion_status: COMPLETION_STATUS.COMPLETED + regions: [] } ]); sinon.stub(ProjectService.prototype, 'getProjectCount').resolves(1); @@ -120,11 +116,7 @@ describe('list', () => { { project_id: 1, name: 'myproject', - project_programs: [1], - start_date: '2022-02-02', - end_date: null, - regions: [], - completion_status: COMPLETION_STATUS.COMPLETED + regions: [] } ] }); diff --git a/api/src/paths/project/list.ts b/api/src/paths/project/list.ts index aeb1bbcf70..fac26a5220 100644 --- a/api/src/paths/project/list.ts +++ b/api/src/paths/project/list.ts @@ -51,13 +51,6 @@ GET.apiDoc = { type: 'string', nullable: true }, - project_programs: { - type: 'array', - items: { - type: 'integer' - }, - nullable: true - }, keyword: { type: 'string', nullable: true @@ -92,15 +85,7 @@ GET.apiDoc = { items: { type: 'object', additionalProperties: false, - required: [ - 'project_id', - 'name', - 'project_programs', - 'completion_status', - 'start_date', - 'end_date', - 'regions' - ], + required: ['project_id', 'name', 'regions'], properties: { project_id: { type: 'integer' @@ -108,30 +93,11 @@ GET.apiDoc = { name: { type: 'string' }, - project_programs: { - type: 'array', - items: { - type: 'integer' - } - }, - start_date: { - oneOf: [{ type: 'object' }, { type: 'string', format: 'date' }], - description: 'ISO 8601 date string for the funding end_date' - }, - end_date: { - oneOf: [{ type: 'object' }, { type: 'string', format: 'date' }], - nullable: true, - description: 'ISO 8601 date string for the funding end_date' - }, regions: { type: 'array', items: { type: 'string' } - }, - completion_status: { - type: 'string', - enum: ['Completed', 'Active'] } } } @@ -182,12 +148,7 @@ export function getProjectList(): RequestHandler { const filterFields: IProjectAdvancedFilters = { keyword: req.query.keyword && String(req.query.keyword), project_name: req.query.project_name && String(req.query.project_name), - project_programs: req.query.project_programs - ? String(req.query.project_programs).split(',').map(Number) - : undefined, - itis_tsns: req.query.itis_tsns ? String(req.query.itis_tsns).split(',').map(Number) : undefined, - start_date: req.query.start_date && String(req.query.start_date), - end_date: req.query.end_date && String(req.query.end_date) + itis_tsns: req.query.itis_tsns ? String(req.query.itis_tsns).split(',').map(Number) : undefined }; const paginationOptions = makePaginationOptionsFromRequest(req); diff --git a/api/src/paths/project/{projectId}/survey/index.ts b/api/src/paths/project/{projectId}/survey/index.ts index 15d1e31719..6487b48837 100644 --- a/api/src/paths/project/{projectId}/survey/index.ts +++ b/api/src/paths/project/{projectId}/survey/index.ts @@ -85,12 +85,10 @@ GET.apiDoc = { }, start_date: { type: 'string', - format: 'date', description: 'ISO 8601 date string' }, end_date: { type: 'string', - format: 'date', description: 'ISO 8601 date string', nullable: true }, diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/attachments/list.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/attachments/list.ts index 2f2f6e82ff..8bd09bedd0 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/attachments/list.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/attachments/list.ts @@ -117,23 +117,23 @@ GET.apiDoc = { type: 'number' }, event_timestamp: { - oneOf: [{ type: 'object' }, { type: 'string', format: 'date' }], - description: 'ISO 8601 date string for the project start date' + type: 'string', + description: 'ISO 8601 date string' }, artifact_revision_id: { type: 'string' }, create_date: { - oneOf: [{ type: 'object' }, { type: 'string', format: 'date' }], - description: 'ISO 8601 date string for the project start date' + type: 'string', + description: 'ISO 8601 date string' }, create_user: { type: 'integer', minimum: 1 }, update_date: { - oneOf: [{ type: 'object' }, { type: 'string', format: 'date' }], - description: 'ISO 8601 date string for the project start date', + type: 'string', + description: 'ISO 8601 date string', nullable: true }, update_user: { @@ -198,23 +198,23 @@ GET.apiDoc = { type: 'number' }, event_timestamp: { - oneOf: [{ type: 'object' }, { type: 'string', format: 'date' }], - description: 'ISO 8601 date string for the project start date' + type: 'string', + description: 'ISO 8601 date string' }, artifact_revision_id: { type: 'string' }, create_date: { - oneOf: [{ type: 'object' }, { type: 'string', format: 'date' }], - description: 'ISO 8601 date string for the project start date' + type: 'string', + description: 'ISO 8601 date string' }, create_user: { type: 'integer', minimum: 1 }, update_date: { - oneOf: [{ type: 'object' }, { type: 'string', format: 'date' }], - description: 'ISO 8601 date string for the project start date', + type: 'string', + description: 'ISO 8601 date string', nullable: true }, update_user: { diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/observations/{surveyObservationId}/index.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/observations/{surveyObservationId}/index.ts index 993a3d57b3..234633a6e6 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/observations/{surveyObservationId}/index.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/observations/{surveyObservationId}/index.ts @@ -137,16 +137,16 @@ GET.apiDoc = { nullable: true }, create_date: { - oneOf: [{ type: 'object' }, { type: 'string', format: 'date' }], - description: 'ISO 8601 date string for the project start date' + type: 'string', + description: 'ISO 8601 date string' }, create_user: { type: 'integer', minimum: 1 }, update_date: { - oneOf: [{ type: 'object' }, { type: 'string', format: 'date' }], - description: 'ISO 8601 date string for the project start date', + type: 'string', + description: 'ISO 8601 date string', nullable: true }, update_user: { diff --git a/api/src/paths/project/{projectId}/update.ts b/api/src/paths/project/{projectId}/update.ts index c08813966a..209cb0230a 100644 --- a/api/src/paths/project/{projectId}/update.ts +++ b/api/src/paths/project/{projectId}/update.ts @@ -83,7 +83,7 @@ GET.apiDoc = { description: 'Basic project metadata', type: 'object', additionalProperties: false, - required: ['project_name', 'project_programs', 'start_date', 'end_date', 'revision_count'], + required: ['project_name', 'revision_count'], nullable: true, properties: { project_id: { @@ -100,23 +100,6 @@ GET.apiDoc = { project_name: { type: 'string' }, - project_programs: { - type: 'array', - items: { - type: 'number' - } - }, - start_date: { - type: 'string', - format: 'date', - description: 'ISO 8601 date string for the project start date' - }, - end_date: { - type: 'string', - format: 'date', - description: 'ISO 8601 date string for the project end date', - nullable: true - }, revision_count: { type: 'number' } diff --git a/api/src/paths/project/{projectId}/view.ts b/api/src/paths/project/{projectId}/view.ts index 5307e00748..af1c845de1 100644 --- a/api/src/paths/project/{projectId}/view.ts +++ b/api/src/paths/project/{projectId}/view.ts @@ -70,7 +70,7 @@ GET.apiDoc = { description: 'Basic project metadata', type: 'object', additionalProperties: false, - required: ['project_id', 'uuid', 'project_name', 'project_programs', 'start_date', 'comments'], + required: ['project_id', 'uuid', 'project_name', 'comments'], properties: { project_id: { type: 'integer', @@ -83,23 +83,6 @@ GET.apiDoc = { project_name: { type: 'string' }, - project_programs: { - type: 'array', - items: { - type: 'number' - } - }, - start_date: { - type: 'string', - format: 'date', - description: 'ISO 8601 date string for the project start date' - }, - end_date: { - type: 'string', - format: 'date', - description: 'ISO 8601 date string for the project end date', - nullable: true - }, comments: { type: 'string', nullable: true, diff --git a/api/src/paths/resources/list.ts b/api/src/paths/resources/list.ts index 6a025e2ac1..b3ea45fb8c 100644 --- a/api/src/paths/resources/list.ts +++ b/api/src/paths/resources/list.ts @@ -36,7 +36,8 @@ GET.apiDoc = { type: 'string' }, lastModified: { - oneOf: [{ type: 'string', format: 'date' }, { type: 'object' }] + type: 'string', + description: 'ISO 8601 date string' }, fileSize: { type: 'number' diff --git a/api/src/paths/user/{userId}/get.ts b/api/src/paths/user/{userId}/get.ts index 9a1dd0122c..e1ad449945 100644 --- a/api/src/paths/user/{userId}/get.ts +++ b/api/src/paths/user/{userId}/get.ts @@ -85,7 +85,7 @@ GET.apiDoc = { nullable: true }, record_end_date: { - oneOf: [{ type: 'object' }, { type: 'string', format: 'date' }], + type: 'string', nullable: true, description: 'Determines if the user record has expired' }, diff --git a/api/src/repositories/code-repository.ts b/api/src/repositories/code-repository.ts index 93003addf7..cd5550dd74 100644 --- a/api/src/repositories/code-repository.ts +++ b/api/src/repositories/code-repository.ts @@ -27,7 +27,6 @@ export const IAllCodeSets = z.object({ agency: CodeSet(), investment_action_category: CodeSet(InvestmentActionCategoryCode.shape), type: CodeSet(), - program: CodeSet(), proprietor_type: CodeSet(ProprietorTypeCode.shape), iucn_conservation_action_level_1_classification: CodeSet(), iucn_conservation_action_level_2_subclassification: CodeSet(IucnConservationActionLevel2SubclassificationCode.shape), @@ -212,26 +211,6 @@ export class CodeRepository extends BaseRepository { return response.rows; } - /** - * Fetch project type codes. - * - * @return {*} - * @memberof CodeRepository - */ - async getProgram() { - const sqlStatement = SQL` - SELECT - program_id as id, - name - FROM program - WHERE record_end_date is null; - `; - - const response = await this.connection.sql(sqlStatement, ICode); - - return response.rows; - } - /** * Fetch investment action category codes. * diff --git a/api/src/repositories/project-repository.test.ts b/api/src/repositories/project-repository.test.ts index bed645f3d1..3c92c404fb 100644 --- a/api/src/repositories/project-repository.test.ts +++ b/api/src/repositories/project-repository.test.ts @@ -1,9 +1,7 @@ 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 { PostProjectObject } from '../models/project-create'; import { GetAttachmentsData, @@ -25,8 +23,6 @@ describe('ProjectRepository', () => { const repository = new ProjectRepository(dbConnection); const input = { - start_date: 'start', - end_date: undefined, project_name: 'string', agency_project_id: 1, agency_id: 1, @@ -46,9 +42,6 @@ describe('ProjectRepository', () => { const repository = new ProjectRepository(dbConnection); const input = { - start_date: undefined, - end_date: 'end', - project_programs: [1], project_name: 'string', agency_project_id: 1, agency_id: 1, @@ -68,8 +61,7 @@ describe('ProjectRepository', () => { const repository = new ProjectRepository(dbConnection); const input = { - start_date: 'start', - end_date: 'end' + keyword: 'a' }; const response = await repository.getProjectList(true, 1, input); @@ -246,10 +238,7 @@ describe('ProjectRepository', () => { const input = { project: { - project_programs: [1], name: 'name', - start_date: 'start_date', - end_date: 'end_date', comments: 'comments' }, objectives: { objectives: '' } @@ -268,10 +257,7 @@ describe('ProjectRepository', () => { const input = { project: { - project_programs: [1], name: 'name', - start_date: 'start_date', - end_date: 'end_date', comments: 'comments' }, objectives: { objectives: '' } @@ -290,10 +276,7 @@ describe('ProjectRepository', () => { const input = { project: { - project_programs: [1], name: 'name', - start_date: 'start_date', - end_date: 'end_date', comments: 'comments' }, objectives: { objectives: '' } @@ -360,64 +343,4 @@ describe('ProjectRepository', () => { expect(response).to.eql(undefined); }); }); - - describe('insertProgram', () => { - it('should return early', async () => { - const dbConnection = getMockDBConnection(); - const mockSql = sinon.stub(dbConnection, 'sql').resolves(); - const repository = new ProjectRepository(dbConnection); - - await repository.insertProgram(1, []); - - expect(mockSql).to.not.be.called; - }); - - it('should run properly', async () => { - const dbConnection = getMockDBConnection(); - const mockSql = sinon.stub(dbConnection, 'sql').resolves(); - const repository = new ProjectRepository(dbConnection); - - await repository.insertProgram(1, [1]); - - expect(mockSql).to.be.called; - }); - - it('should throw an SQL error', async () => { - const dbConnection = getMockDBConnection(); - sinon.stub(dbConnection, 'sql').rejects(); - const repository = new ProjectRepository(dbConnection); - - try { - await repository.insertProgram(1, [1]); - expect.fail(); - } catch (error) { - expect((error as ApiExecuteSQLError).message).to.equal('Failed to execute insert SQL for project_program'); - } - }); - }); - - describe('deletePrograms', () => { - it('should run without issue', async () => { - const dbConnection = getMockDBConnection(); - const mockSql = sinon.stub(dbConnection, 'sql').resolves(); - const repository = new ProjectRepository(dbConnection); - - await repository.deletePrograms(1); - - expect(mockSql).to.be.called; - }); - - it('should throw an SQL error', async () => { - const dbConnection = getMockDBConnection(); - sinon.stub(dbConnection, 'sql').rejects(); - const repository = new ProjectRepository(dbConnection); - - try { - await repository.deletePrograms(1); - expect.fail(); - } catch (error) { - expect((error as ApiExecuteSQLError).message).to.equal('Failed to execute delete SQL for project_program'); - } - }); - }); }); diff --git a/api/src/repositories/project-repository.ts b/api/src/repositories/project-repository.ts index ffa27ad4f2..6066302fed 100644 --- a/api/src/repositories/project-repository.ts +++ b/api/src/repositories/project-repository.ts @@ -48,21 +48,15 @@ export class ProjectRepository extends BaseRepository { .select([ 'p.project_id', 'p.name', - 'p.start_date', - 'p.end_date', - knex.raw(`COALESCE(array_remove(array_agg(DISTINCT rl.region_name), null), '{}') as regions`), - knex.raw('array_agg(distinct prog.program_id) as project_programs') + knex.raw(`COALESCE(array_remove(array_agg(DISTINCT rl.region_name), null), '{}') as regions`) ]) .from('project as p') - - .leftJoin('project_program as pp', 'p.project_id', 'pp.project_id') .leftJoin('survey as s', 's.project_id', 'p.project_id') .leftJoin('study_species as sp', 'sp.survey_id', 's.survey_id') - .leftJoin('program as prog', 'prog.program_id', 'pp.program_id') .leftJoin('survey_region as sr', 'sr.survey_id', 's.survey_id') .leftJoin('region_lookup as rl', 'sr.region_id', 'rl.region_id') - .groupBy(['p.project_id', 'p.name', 'p.objectives', 'p.start_date', 'p.end_date']); + .groupBy(['p.project_id', 'p.name', 'p.objectives']); /* * Ensure that users can only see project that they are participating in, unless @@ -74,16 +68,6 @@ export class ProjectRepository extends BaseRepository { }); } - // Start Date filter - if (filterFields.start_date) { - query.andWhere('p.start_date', '>=', filterFields.start_date); - } - - // End Date filter - if (filterFields.end_date) { - query.andWhere('p.end_date', '<=', filterFields.end_date); - } - // Project Name filter (exact match) if (filterFields.project_name) { query.andWhere('p.name', filterFields.project_name); @@ -105,11 +89,6 @@ export class ProjectRepository extends BaseRepository { }); } - // Programs filter - if (filterFields.project_programs?.length) { - query.where('prog.program_id', 'IN', filterFields.project_programs); - } - return query; } @@ -186,19 +165,10 @@ export class ProjectRepository extends BaseRepository { p.project_id, p.uuid, p.name as project_name, - p.start_date, - p.end_date, p.comments, - p.revision_count, - pp.project_programs + p.revision_count FROM project p - LEFT JOIN ( - SELECT array_remove(array_agg(p.program_id), NULL) as project_programs, pp.project_id - FROM program p, project_program pp - WHERE p.program_id = pp.program_id - GROUP BY pp.project_id - ) as pp on pp.project_id = p.project_id WHERE p.project_id = ${projectId}; `; @@ -327,14 +297,10 @@ export class ProjectRepository extends BaseRepository { INSERT INTO project ( name, objectives, - start_date, - end_date, comments ) VALUES ( ${postProjectData.project.name}, ${postProjectData.objectives.objectives}, - ${postProjectData.project.start_date}, - ${postProjectData.project.end_date}, ${postProjectData.project.comments} ) RETURNING project_id as id;`; @@ -379,61 +345,6 @@ export class ProjectRepository extends BaseRepository { return result.id; } - /** - * Links a given project with a list of given programs. - * This insert assumes previous records for a project have been removed first - * - * @param {number} projectId Project to add programs to - * @param {number[]} programs Programs to be added to a project - * @returns {*} {Promise} - */ - async insertProgram(projectId: number, programs: number[]): Promise { - if (programs.length < 1) { - return; - } - - const sql = SQL` - INSERT INTO project_program (project_id, program_id) - VALUES `; - - programs.forEach((programId, index) => { - sql.append(`(${projectId}, ${programId})`); - - if (index !== programs.length - 1) { - sql.append(','); - } - }); - - sql.append(';'); - - try { - await this.connection.sql(sql); - } catch (error) { - throw new ApiExecuteSQLError('Failed to execute insert SQL for project_program', [ - 'ProjectRepository->insertProgram' - ]); - } - } - - /** - * Removes program links for a given project. - * - * @param {number} projectId Project id to remove programs from - * @returns {*} {Promise} - */ - async deletePrograms(projectId: number): Promise { - const sql = SQL` - DELETE FROM project_program WHERE project_id = ${projectId}; - `; - try { - await this.connection.sql(sql); - } catch (error) { - throw new ApiExecuteSQLError('Failed to execute delete SQL for project_program', [ - 'ProjectRepository->deletePrograms' - ]); - } - } - async deleteIUCNData(projectId: number): Promise { const sqlDeleteStatement = SQL` DELETE @@ -465,8 +376,6 @@ export class ProjectRepository extends BaseRepository { if (project) { sqlSetStatements.push(SQL`name = ${project.name}`); - sqlSetStatements.push(SQL`start_date = ${project.start_date}`); - sqlSetStatements.push(SQL`end_date = ${project.end_date}`); } if (objectives) { diff --git a/api/src/services/code-service.test.ts b/api/src/services/code-service.test.ts index fb396dbe02..0e88eed428 100644 --- a/api/src/services/code-service.test.ts +++ b/api/src/services/code-service.test.ts @@ -31,7 +31,6 @@ describe('CodeService', () => { 'agency', 'investment_action_category', 'type', - 'program', 'proprietor_type', 'iucn_conservation_action_level_1_classification', 'iucn_conservation_action_level_2_subclassification', diff --git a/api/src/services/code-service.ts b/api/src/services/code-service.ts index 55c1481250..8802e52ed0 100644 --- a/api/src/services/code-service.ts +++ b/api/src/services/code-service.ts @@ -34,7 +34,6 @@ export class CodeService extends DBService { iucn_conservation_action_level_2_subclassification, iucn_conservation_action_level_3_subclassification, proprietor_type, - program, system_roles, project_roles, administrative_activity_status_type, @@ -55,7 +54,6 @@ export class CodeService extends DBService { await this.codeRepository.getIUCNConservationActionLevel2Subclassification(), await this.codeRepository.getIUCNConservationActionLevel3Subclassification(), await this.codeRepository.getProprietorType(), - await this.codeRepository.getProgram(), await this.codeRepository.getSystemRoles(), await this.codeRepository.getProjectRoles(), await this.codeRepository.getAdministrativeActivityStatusType(), @@ -77,7 +75,6 @@ export class CodeService extends DBService { iucn_conservation_action_level_1_classification, iucn_conservation_action_level_2_subclassification, iucn_conservation_action_level_3_subclassification, - program, proprietor_type, system_roles, project_roles, diff --git a/api/src/services/eml-service.test.ts b/api/src/services/eml-service.test.ts deleted file mode 100644 index 6ba01f0e13..0000000000 --- a/api/src/services/eml-service.test.ts +++ /dev/null @@ -1,1392 +0,0 @@ -import chai, { expect } from 'chai'; -import { describe } from 'mocha'; -import sinon from 'sinon'; -import sinonChai from 'sinon-chai'; -import { IGetProject } from '../models/project-view'; -import { SurveyObject } from '../models/survey-view'; -import { getMockDBConnection } from '../__mocks__/db'; -import { CodeService } from './code-service'; -import { EmlPackage, EmlService } from './eml-service'; -import { ProjectService } from './project-service'; -import { SurveyService } from './survey-service'; - -chai.use(sinonChai); - -describe('EmlPackage', () => { - describe('withEml', () => { - it('should build an EML section', () => { - const mockDBConnection = getMockDBConnection(); - const emlService = new EmlService(mockDBConnection); - const packageId = 'aaaabbbb-cccc-dddd-eeee-ffffgggghhhhiiii'; - - const emlPackage = new EmlPackage({ packageId }); - - const response = emlPackage.withEml(emlService._buildEmlSection(packageId)); - - expect(response._emlMetadata).to.eql(emlPackage._emlMetadata); - expect(response._emlMetadata).to.eql({ - $: { - packageId: 'urn:uuid:aaaabbbb-cccc-dddd-eeee-ffffgggghhhhiiii', - system: '', - 'xmlns:eml': 'https://eml.ecoinformatics.org/eml-2.2.0', - 'xmlns:xsi': 'http://www.w3.org/2001/XMLSchema-instance', - 'xmlns:stmml': 'http://www.xml-cml.org/schema/schema24', - 'xsi:schemaLocation': 'https://eml.ecoinformatics.org/eml-2.2.0 xsd/eml.xsd' - } - }); - }); - }); - - describe('withDataset', () => { - it('should build an EML dataset section', () => { - const mockDBConnection = getMockDBConnection(); - const emlService = new EmlService(mockDBConnection); - - const mockOrg = { - organizationName: 'Test Organization', - electronicMailAddress: 'EMAIL@address.com' - }; - - const mockPackageId = 'aaaabbbb-cccc-dddd-eeee-ffffgggghhhhiiii'; - const mockProjectData = { - project: { - project_name: 'Test Project Name' - } - } as IGetProject; - - const emlPackage = new EmlPackage({ packageId: mockPackageId }); - - sinon.stub(EmlService.prototype, '_getProjectDatasetCreator').returns(mockOrg); - - sinon.stub(EmlService.prototype, '_makeEmlDateString').returns('2023-01-01'); - - sinon.stub(EmlService.prototype, '_getProjectContact').returns({ - individualName: { - givenName: 'First Name', - surName: 'Last Name' - }, - ...mockOrg - }); - - const response = emlPackage.withDataset( - emlService._buildProjectEmlDatasetSection(mockPackageId, mockProjectData) - ); - - expect(response._datasetMetadata).to.eql(emlPackage._datasetMetadata); - expect(response._datasetMetadata).to.eql({ - $: { system: '', id: 'aaaabbbb-cccc-dddd-eeee-ffffgggghhhhiiii' }, - title: 'Test Project Name', - creator: { - organizationName: 'Test Organization', - electronicMailAddress: 'EMAIL@address.com' - }, - pubDate: '2023-01-01', - language: 'English', - contact: { - individualName: { - givenName: 'First Name', - surName: 'Last Name' - }, - organizationName: 'Test Organization', - electronicMailAddress: 'EMAIL@address.com' - } - }); - }); - }); - - describe('withProject', () => { - it('should build a project EML Project section', () => { - const mockDBConnection = getMockDBConnection(); - const emlService = new EmlService(mockDBConnection); - - const mockPackageId = 'aaaabbbb-cccc-dddd-eeee-ffffgggghhhhiiii'; - - const mockProjectData = { - project: { - uuid: mockPackageId, - project_name: 'Test Project Name' - }, - objectives: { - objectives: 'Project objectives.' - } - } as IGetProject; - - const emlPackage = new EmlPackage({ packageId: mockPackageId }); - - sinon.stub(EmlService.prototype, '_getProjectPersonnel').returns([ - { - individualName: { givenName: 'First Name', surName: 'Last Name' }, - organizationName: 'A Rocha Canada', - electronicMailAddress: 'EMAIL@address.com', - role: 'pointOfContact' - } - ]); - - sinon.stub(EmlService.prototype, '_getProjectTemporalCoverage').returns({ - rangeOfDates: { - beginDate: { calendarDate: '2023-01-01' }, - endDate: { calendarDate: '2023-01-31' } - } - }); - - const response = emlPackage.withProject(emlService._buildProjectEmlProjectSection(mockProjectData, [])); - - expect(response._projectMetadata).to.eql(emlPackage._projectMetadata); - expect(response._projectMetadata).to.eql({ - $: { id: 'aaaabbbb-cccc-dddd-eeee-ffffgggghhhhiiii', system: '' }, - title: 'Test Project Name', - personnel: [ - { - individualName: { givenName: 'First Name', surName: 'Last Name' }, - organizationName: 'A Rocha Canada', - electronicMailAddress: 'EMAIL@address.com', - role: 'pointOfContact' - } - ], - abstract: { - section: [{ title: 'Objectives', para: 'Project objectives.' }] - }, - studyAreaDescription: { - coverage: { - temporalCoverage: { - rangeOfDates: { - beginDate: { calendarDate: '2023-01-01' }, - endDate: { calendarDate: '2023-01-31' } - } - } - } - } - }); - }); - }); - - describe('withAdditionalMetadata', () => { - it('should add additional metadata to the EML package', () => { - const additionalMeta1 = [ - { - describes: '1116c94a-8cd5-480d-a1f3-dac794e57c05', - metadata: { projectTypes: { projectType: 'Aquatic Habitat' } } - }, - { - describes: '1116c94a-8cd5-480d-a1f3-dac794e57c05', - metadata: { - projectActivities: { - projectActivity: [{ name: 'Habitat Protection' }] - } - } - }, - { - describes: '1116c94a-8cd5-480d-a1f3-dac794e57c05', - metadata: { - IUCNConservationActions: { - IUCNConservationAction: [ - { - IUCNConservationActionLevel1Classification: 'Awareness Raising', - IUCNConservationActionLevel2SubClassification: 'Outreach & Communications', - IUCNConservationActionLevel3SubClassification: 'Reported and social media' - } - ] - } - } - } - ]; - - const additionalMeta2 = [ - { - describes: '1116c94a-8cd5-480d-a1f3-dac794e57c05', - metadata: { - stakeholderPartnerships: { - stakeholderPartnership: [{ name: 'BC Hydro' }] - } - } - }, - { - describes: '1116c94a-8cd5-480d-a1f3-dac794e57c05', - metadata: { - firstNationPartnerships: { - firstNationPartnership: [{ name: 'Acho Dene Koe First Nation' }] - } - } - } - ]; - - const emlPackage = new EmlPackage({ packageId: null as unknown as string }); - - const response = emlPackage.withAdditionalMetadata(additionalMeta1).withAdditionalMetadata(additionalMeta2); - - expect(response._additionalMetadata).to.eql(emlPackage._additionalMetadata); - expect(emlPackage._additionalMetadata).to.eql([...additionalMeta1, ...additionalMeta2]); - }); - }); - - describe('withRelatedProjects', () => { - it('should add a related project to the EML package', () => { - const project = { - $: { id: '1116c94a-8cd5-480d-a1f3-dac794e57c05', system: '' }, - title: 'Project Name 1' - }; - - const emlPackage = new EmlPackage({ packageId: null as unknown as string }); - - const response = emlPackage.withRelatedProjects([project]); - - expect(response._relatedProjects).to.eql(emlPackage._relatedProjects); - expect(emlPackage._relatedProjects).to.eql([ - { - $: { id: '1116c94a-8cd5-480d-a1f3-dac794e57c05', system: '' }, - title: 'Project Name 1' - } - ]); - }); - - it('should add multiple related projects to the EML package', () => { - const project1 = { - $: { id: '1116c94a-8cd5-480d-a1f3-dac794e57c05', system: '' }, - title: 'Project Name 1' - }; - - const project2 = { - $: { id: '1116c94a-8cd5-480d-a1f3-dac794e57c06', system: '' }, - title: 'Project Name 2' - }; - - const emlPackage = new EmlPackage({ packageId: null as unknown as string }); - - const response = emlPackage.withRelatedProjects([project1]).withRelatedProjects([project2]); - - expect(response._relatedProjects).to.eql(emlPackage._relatedProjects); - expect(emlPackage._relatedProjects).to.eql([ - { - $: { id: '1116c94a-8cd5-480d-a1f3-dac794e57c05', system: '' }, - title: 'Project Name 1' - }, - { - $: { id: '1116c94a-8cd5-480d-a1f3-dac794e57c06', system: '' }, - title: 'Project Name 2' - } - ]); - }); - }); - - describe('build', () => { - // - }); -}); - -describe.skip('EmlService', () => { - beforeEach(() => { - sinon.stub(EmlService.prototype, 'loadEmlDbConstants').callsFake(async function (this: EmlService) { - this._constants.EML_ORGANIZATION_URL = 'Not Supplied'; - this._constants.EML_ORGANIZATION_NAME = 'Not Supplied'; - this._constants.EML_PROVIDER_URL = 'Not Supplied'; - this._constants.EML_SECURITY_PROVIDER_URL = 'Not Supplied'; - this._constants.EML_ORGANIZATION_URL = 'Not Supplied'; - this._constants.EML_INTELLECTUAL_RIGHTS = 'Not Supplied'; - this._constants.EML_TAXONOMIC_PROVIDER_URL = 'Not Supplied'; - }); - }); - - afterEach(() => { - sinon.restore(); - }); - - it('constructs', () => { - const dbConnectionObj = getMockDBConnection(); - - const emlService = new EmlService(dbConnectionObj); - - expect(emlService).to.be.instanceof(EmlService); - expect(emlService._constants).to.eql({ - EML_VERSION: '1.0.0', - EML_PROVIDER_URL: 'Not Supplied', - EML_SECURITY_PROVIDER_URL: 'Not Supplied', - EML_ORGANIZATION_NAME: 'Not Supplied', - EML_ORGANIZATION_URL: 'Not Supplied', - EML_TAXONOMIC_PROVIDER_URL: 'Not Supplied', - EML_INTELLECTUAL_RIGHTS: 'Not Supplied' - }); - }); - - describe('buildProjectEmlPackage', () => { - it('should build an EML string with no content if no data is provided', async () => { - const mockDBConnection = getMockDBConnection(); - const emlService = new EmlService(mockDBConnection); - - sinon - .stub(ProjectService.prototype, 'getProjectById') - .resolves({ project: { uuid: 'aaaabbbb-cccc-dddd-eeee-ffffgggghhhhiiii' } } as IGetProject); - - sinon.stub(SurveyService.prototype, 'getSurveysByProjectId').resolves([]); - - sinon.stub(EmlService.prototype, '_buildEmlSection').returns({}); - sinon.stub(EmlService.prototype, '_buildProjectEmlDatasetSection').resolves({}); - sinon.stub(EmlService.prototype, '_buildProjectEmlProjectSection').returns({}); - sinon.stub(EmlService.prototype, '_getProjectAdditionalMetadata').resolves([]); - sinon.stub(EmlService.prototype, '_getSurveyAdditionalMetadata').resolves([]); - sinon.stub(EmlService.prototype, '_buildAllSurveyEmlProjectSections').resolves([]); - - const emlPackage = await emlService.buildProjectEmlPackage({ projectId: 1 }); - - expect(emlPackage.toString()).to.equal( - `` - ); - }); - - it('should build an EML package for a project successfully', async () => { - const mockDBConnection = getMockDBConnection(); - const emlService = new EmlService(mockDBConnection); - - sinon - .stub(ProjectService.prototype, 'getProjectById') - .resolves({ project: { uuid: '1116c94a-8cd5-480d-a1f3-dac794e57c05' } } as IGetProject); - - sinon.stub(SurveyService.prototype, 'getSurveysByProjectId').resolves([]); - - // Build EML section - sinon.stub(EmlService.prototype, '_buildEmlSection').returns({ - $: { - packageId: 'urn:uuid:1116c94a-8cd5-480d-a1f3-dac794e57c05', - system: '', - 'xmlns:eml': 'https://eml.ecoinformatics.org/eml-2.2.0', - 'xmlns:xsi': 'http://www.w3.org/2001/XMLSchema-instance', - 'xmlns:stmml': 'http://www.xml-cml.org/schema/schema24', - 'xsi:schemaLocation': 'https://eml.ecoinformatics.org/eml-2.2.0 xsd/eml.xsd' - } - }); - - // Build dataset EML section - sinon.stub(EmlService.prototype, '_buildProjectEmlDatasetSection').returns({ - $: { - system: '', - id: '1116c94a-8cd5-480d-a1f3-dac794e57c05' - }, - title: 'Project Name', - creator: { - organizationName: 'A Rocha Canada', - electronicMailAddress: 'EMAIL@address.com' - }, - pubDate: '2023-03-13', - language: 'English', - contact: { - individualName: { - givenName: 'First Name', - surName: 'Last Name' - }, - organizationName: 'A Rocha Canada', - electronicMailAddress: 'EMAIL@address.com' - } - }); - - // Build Project EML section - sinon.stub(EmlService.prototype, '_buildProjectEmlProjectSection').returns({ - $: { id: '1116c94a-8cd5-480d-a1f3-dac794e57c05', system: '' }, - title: 'Project Name', - personnel: [ - { - individualName: { givenName: 'First Name', surName: 'Last Name' }, - organizationName: 'A Rocha Canada', - electronicMailAddress: 'EMAIL@address.com', - role: 'pointOfContact' - } - ], - abstract: { - section: [{ title: 'Objectives', para: 'Objectives' }] - }, - studyAreaDescription: { - coverage: { - geographicCoverage: { - geographicDescription: 'Location Description', - boundingCoordinates: { - westBoundingCoordinate: -121.904297, - eastBoundingCoordinate: -120.19043, - northBoundingCoordinate: 51.971346, - southBoundingCoordinate: 50.930738 - }, - datasetGPolygon: [ - { - datasetGPolygonOuterGRing: [ - { - gRingPoint: [ - { gRingLatitude: 50.930738, gRingLongitude: -121.904297 }, - { gRingLatitude: 51.971346, gRingLongitude: -121.904297 }, - { gRingLatitude: 51.971346, gRingLongitude: -120.19043 }, - { gRingLatitude: 50.930738, gRingLongitude: -120.19043 }, - { gRingLatitude: 50.930738, gRingLongitude: -121.904297 } - ] - } - ] - } - ] - }, - temporalCoverage: { - rangeOfDates: { - beginDate: { calendarDate: '2023-01-01' }, - endDate: { calendarDate: '2023-01-31' } - } - } - } - } - }); - - // Build Project additional metadata - sinon.stub(EmlService.prototype, '_getProjectAdditionalMetadata').resolves([ - { - describes: '1116c94a-8cd5-480d-a1f3-dac794e57c05', - metadata: { projectTypes: { projectType: 'Aquatic Habitat' } } - }, - { - describes: '1116c94a-8cd5-480d-a1f3-dac794e57c05', - metadata: { - projectActivities: { - projectActivity: [{ name: 'Habitat Protection' }] - } - } - }, - { - describes: '1116c94a-8cd5-480d-a1f3-dac794e57c05', - metadata: { - IUCNConservationActions: { - IUCNConservationAction: [ - { - IUCNConservationActionLevel1Classification: 'Awareness Raising', - IUCNConservationActionLevel2SubClassification: 'Outreach & Communications', - IUCNConservationActionLevel3SubClassification: 'Reported and social media' - } - ] - } - } - }, - { - describes: '1116c94a-8cd5-480d-a1f3-dac794e57c05', - metadata: { - stakeholderPartnerships: { - stakeholderPartnership: [{ name: 'BC Hydro' }] - } - } - }, - { - describes: '1116c94a-8cd5-480d-a1f3-dac794e57c05', - metadata: { - firstNationPartnerships: { - firstNationPartnership: [{ name: 'Acho Dene Koe First Nation' }] - } - } - } - ]); - - // Build survey additional metadata - sinon.stub(EmlService.prototype, '_getSurveyAdditionalMetadata').resolves([]); - - // Build related project section - sinon.stub(EmlService.prototype, '_buildAllSurveyEmlProjectSections').resolves([ - { - $: { - id: '69b506d1-3a50-4a39-b4c7-190bd0b34b96', - system: '' - }, - title: 'Survey Name', - personnel: [ - { - individualName: { - givenName: 'First Name', - surName: 'Last Name' - }, - role: 'pointOfContact' - } - ], - abstract: { - section: [ - { - title: 'Intended Outcomes', - para: 'Habitat Assessment' - }, - { - title: 'Additional Details', - para: 'Additional Details' - } - ] - }, - studyAreaDescription: { - coverage: { - geographicCoverage: { - geographicDescription: 'Survey Area Name', - boundingCoordinates: { - westBoundingCoordinate: -121.904297, - eastBoundingCoordinate: -120.19043, - northBoundingCoordinate: 51.971346, - southBoundingCoordinate: 50.930738 - }, - datasetGPolygon: [ - { - datasetGPolygonOuterGRing: [ - { - gRingPoint: [ - { - gRingLatitude: 50.930738, - gRingLongitude: -121.904297 - }, - { - gRingLatitude: 51.971346, - gRingLongitude: -121.904297 - }, - { - gRingLatitude: 51.971346, - gRingLongitude: -120.19043 - }, - { - gRingLatitude: 50.930738, - gRingLongitude: -120.19043 - }, - { - gRingLatitude: 50.930738, - gRingLongitude: -121.904297 - } - ] - } - ] - } - ] - }, - temporalCoverage: { - rangeOfDates: { - beginDate: { - calendarDate: '2023-01-02' - }, - endDate: { - calendarDate: '2023-01-30' - } - } - }, - taxonomicCoverage: { - taxonomicClassification: [ - { - taxonRankName: 'SPECIES', - taxonRankValue: 'Alces americanus', - commonNames: 'Moose', - taxonId: { - $: { - provider: '' - }, - _: '2065' - } - } - ] - } - } - }, - designDescription: { - description: { - section: [ - { - title: 'Field Method', - para: 'Call Playback' - }, - { - title: 'Vantage Codes', - para: { - itemizedlist: { - listitem: [ - { - para: 'Aerial' - } - ] - } - } - } - ] - } - } - } - ]); - - const emlPackage = await emlService.buildProjectEmlPackage({ projectId: 1 }); - - expect(emlPackage.toString()).to.equal( - `Project NameA Rocha CanadaEMAIL@address.com2023-03-13EnglishFirst NameLast NameA Rocha CanadaEMAIL@address.comProject NameFirst NameLast NameA Rocha CanadaEMAIL@address.compointOfContact
ObjectivesObjectives
Agency NameBC Hydro
Funding Agency Project IDAGENCY PROJECT ID
Investment Action/CategoryNot Applicable
Funding Amount123456789
Funding Start Date2023-01-02
Funding End Date2023-01-30
Location Description-121.904297-120.1904351.97134650.93073850.930738-121.90429751.971346-121.90429751.971346-120.1904350.930738-120.1904350.930738-121.9042972023-01-012023-01-31Survey NameFirst NameLast NamepointOfContact
Intended OutcomesHabitat Assessment
Additional DetailsAdditional Details
Agency NameBC Hydro
Funding Agency Project IDAGENCY PROJECT ID
Investment Action/CategoryNot Applicable
Funding Amount123456789
Funding Start Date2023-01-02
Funding End Date2023-01-30
Survey Area Name-121.904297-120.1904351.97134650.93073850.930738-121.90429751.971346-121.90429751.971346-120.1904350.930738-120.1904350.930738-121.9042972023-01-022023-01-30SPECIESAlces americanusMoose2065
Field MethodCall Playback
Ecological SeasonSpring
Vantage CodesAerial
1116c94a-8cd5-480d-a1f3-dac794e57c05Aquatic Habitat1116c94a-8cd5-480d-a1f3-dac794e57c05Habitat Protection1116c94a-8cd5-480d-a1f3-dac794e57c05Awareness RaisingOutreach & CommunicationsReported and social media1116c94a-8cd5-480d-a1f3-dac794e57c05BC Hydro1116c94a-8cd5-480d-a1f3-dac794e57c05Acho Dene Koe First Nation
` - ); - }); - }); - - describe('buildSurveyEmlPackage', () => { - it('should build an EML package for a survey successfully', async () => { - const mockDBConnection = getMockDBConnection(); - const emlService = new EmlService(mockDBConnection); - - sinon - .stub(ProjectService.prototype, 'getProjectById') - .resolves({ project: { uuid: '1116c94a-8cd5-480d-a1f3-dac794e57c05' } } as IGetProject); - - sinon - .stub(SurveyService.prototype, 'getSurveyById') - .resolves({ survey_details: { uuid: '69b506d1-3a50-4a39-b4c7-190bd0b34b9' } } as SurveyObject); - - // Build EML section - sinon.stub(EmlService.prototype, '_buildEmlSection').returns({ - $: { - packageId: 'urn:uuid:1116c94a-8cd5-480d-a1f3-dac794e57c05', - system: '', - 'xmlns:eml': 'https://eml.ecoinformatics.org/eml-2.2.0', - 'xmlns:xsi': 'http://www.w3.org/2001/XMLSchema-instance', - 'xmlns:stmml': 'http://www.xml-cml.org/schema/schema24', - 'xsi:schemaLocation': 'https://eml.ecoinformatics.org/eml-2.2.0 xsd/eml.xsd' - } - }); - - // Build dataset EML section - sinon.stub(EmlService.prototype, '_buildSurveyEmlDatasetSection').returns({ - $: { - system: '', - id: '1116c94a-8cd5-480d-a1f3-dac794e57c05' - }, - title: 'Survey Name', - creator: { - individualName: { - givenName: 'First Name', - surName: 'Last Name' - } - }, - pubDate: '2023-03-13', - language: 'English', - contact: { - individualName: { - givenName: 'First Name', - surName: 'Last Name' - } - } - }); - - // Build Project EML section - sinon.stub(EmlService.prototype, '_buildSurveyEmlProjectSection').resolves({ - $: { - id: '69b506d1-3a50-4a39-b4c7-190bd0b34b96', - system: '' - }, - title: 'Survey Name', - personnel: [ - { - individualName: { - givenName: 'First Name', - surName: 'Last Name' - }, - role: 'pointOfContact' - } - ], - abstract: { - section: [ - { - title: 'Intended Outcomes', - para: 'Habitat Assessment' - }, - { - title: 'Additional Details', - para: 'Additional Details' - } - ] - }, - studyAreaDescription: { - coverage: { - geographicCoverage: { - geographicDescription: 'Survey Area Name', - boundingCoordinates: { - westBoundingCoordinate: -121.904297, - eastBoundingCoordinate: -120.19043, - northBoundingCoordinate: 51.971346, - southBoundingCoordinate: 50.930738 - }, - datasetGPolygon: [ - { - datasetGPolygonOuterGRing: [ - { - gRingPoint: [ - { - gRingLatitude: 50.930738, - gRingLongitude: -121.904297 - }, - { - gRingLatitude: 51.971346, - gRingLongitude: -121.904297 - }, - { - gRingLatitude: 51.971346, - gRingLongitude: -120.19043 - }, - { - gRingLatitude: 50.930738, - gRingLongitude: -120.19043 - }, - { - gRingLatitude: 50.930738, - gRingLongitude: -121.904297 - } - ] - } - ] - } - ] - }, - temporalCoverage: { - rangeOfDates: { - beginDate: { - calendarDate: '2023-01-02' - }, - endDate: { - calendarDate: '2023-01-30' - } - } - }, - taxonomicCoverage: { - taxonomicClassification: [ - { - taxonRankName: 'SPECIES', - taxonRankValue: 'Alces americanus', - commonNames: 'Moose', - taxonId: { - $: { - provider: '' - }, - _: '2065' - } - } - ] - } - } - }, - designDescription: { - description: { - section: [ - { - title: 'Field Method', - para: 'Call Playback' - }, - { - title: 'Ecological Season', - para: 'Spring' - }, - { - title: 'Vantage Codes', - para: { - itemizedlist: { - listitem: [ - { - para: 'Aerial' - } - ] - } - } - } - ] - } - } - }); - - // Build Project additional metadata - sinon.stub(EmlService.prototype, '_getProjectAdditionalMetadata').resolves([ - { - describes: '1116c94a-8cd5-480d-a1f3-dac794e57c05', - metadata: { projectTypes: { projectType: 'Aquatic Habitat' } } - }, - { - describes: '1116c94a-8cd5-480d-a1f3-dac794e57c05', - metadata: { - projectActivities: { - projectActivity: [{ name: 'Habitat Protection' }] - } - } - }, - { - describes: '1116c94a-8cd5-480d-a1f3-dac794e57c05', - metadata: { - IUCNConservationActions: { - IUCNConservationAction: [ - { - IUCNConservationActionLevel1Classification: 'Awareness Raising', - IUCNConservationActionLevel2SubClassification: 'Outreach & Communications', - IUCNConservationActionLevel3SubClassification: 'Reported and social media' - } - ] - } - } - }, - { - describes: '1116c94a-8cd5-480d-a1f3-dac794e57c05', - metadata: { - stakeholderPartnerships: { - stakeholderPartnership: [{ name: 'BC Hydro' }] - } - } - }, - { - describes: '1116c94a-8cd5-480d-a1f3-dac794e57c05', - metadata: { - firstNationPartnerships: { - firstNationPartnership: [{ name: 'Acho Dene Koe First Nation' }] - } - } - } - ]); - - // Build survey additional metadata - sinon.stub(EmlService.prototype, '_getSurveyAdditionalMetadata').resolves([]); - - // Build related project section - sinon.stub(EmlService.prototype, '_buildProjectEmlProjectSection').returns({ - $: { - id: '1116c94a-8cd5-480d-a1f3-dac794e57c05', - system: '' - }, - title: 'Project Name', - personnel: [ - { - individualName: { - givenName: 'First Name', - surName: 'Last Name' - }, - organizationName: 'A Rocha Canada', - electronicMailAddress: 'EMAIL@address.com', - role: 'pointOfContact' - } - ], - abstract: { - section: [ - { - title: 'Objectives', - para: 'Objectives' - } - ] - }, - studyAreaDescription: { - coverage: { - geographicCoverage: { - geographicDescription: 'Location Description', - boundingCoordinates: { - westBoundingCoordinate: -121.904297, - eastBoundingCoordinate: -120.19043, - northBoundingCoordinate: 51.971346, - southBoundingCoordinate: 50.930738 - }, - datasetGPolygon: [ - { - datasetGPolygonOuterGRing: [ - { - gRingPoint: [ - { - gRingLatitude: 50.930738, - gRingLongitude: -121.904297 - }, - { - gRingLatitude: 51.971346, - gRingLongitude: -121.904297 - }, - { - gRingLatitude: 51.971346, - gRingLongitude: -120.19043 - }, - { - gRingLatitude: 50.930738, - gRingLongitude: -120.19043 - }, - { - gRingLatitude: 50.930738, - gRingLongitude: -121.904297 - } - ] - } - ] - } - ] - }, - temporalCoverage: { - rangeOfDates: { - beginDate: { - calendarDate: '2023-01-01' - }, - endDate: { - calendarDate: '2023-01-31' - } - } - } - } - } - }); - - const emlPackage = await emlService.buildSurveyEmlPackage({ surveyId: 1 }); - expect(emlPackage.toString()).to.equal( - `Survey NameFirst NameLast Name2023-03-13EnglishFirst NameLast NameSurvey NameFirst NameLast NamepointOfContact
Intended OutcomesHabitat Assessment
Additional DetailsAdditional Details
Agency NameBC Hydro
Funding Agency Project IDAGENCY PROJECT ID
Investment Action/CategoryNot Applicable
Funding Amount123456789
Funding Start Date2023-01-02
Funding End Date2023-01-30
Survey Area Name-121.904297-120.1904351.97134650.93073850.930738-121.90429751.971346-121.90429751.971346-120.1904350.930738-120.1904350.930738-121.9042972023-01-022023-01-30SPECIESAlces americanusMoose2065
Field MethodCall Playback
Ecological SeasonSpring
Vantage CodesAerial
Project NameFirst NameLast NameA Rocha CanadaEMAIL@address.compointOfContact
ObjectivesObjectives
Agency NameBC Hydro
Funding Agency Project IDAGENCY PROJECT ID
Investment Action/CategoryNot Applicable
Funding Amount123456789
Funding Start Date2023-01-02
Funding End Date2023-01-30
Location Description-121.904297-120.1904351.97134650.93073850.930738-121.90429751.971346-121.90429751.971346-120.1904350.930738-120.1904350.930738-121.9042972023-01-012023-01-31
1116c94a-8cd5-480d-a1f3-dac794e57c05Aquatic Habitat1116c94a-8cd5-480d-a1f3-dac794e57c05Habitat Protection1116c94a-8cd5-480d-a1f3-dac794e57c05Awareness RaisingOutreach & CommunicationsReported and social media1116c94a-8cd5-480d-a1f3-dac794e57c05BC Hydro1116c94a-8cd5-480d-a1f3-dac794e57c05Acho Dene Koe First Nation
` - ); - }); - }); - - describe('codes', () => { - const mockAllCodesResponse = { - management_action_type: [], - first_nations: [], - agency: [], - investment_action_category: [], - type: [], - program: [], - proprietor_type: [], - iucn_conservation_action_level_1_classification: [], - iucn_conservation_action_level_2_subclassification: [], - iucn_conservation_action_level_3_subclassification: [], - system_roles: [], - project_roles: [], - administrative_activity_status_type: [], - intended_outcomes: [], - vantage_codes: [], - site_selection_strategies: [], - survey_jobs: [], - sample_methods: [], - survey_progress: [], - method_response_metrics: [] - }; - - it('should retrieve codes if _codes is undefined', async () => { - const mockDBConnection = getMockDBConnection(); - const emlService = new EmlService(mockDBConnection); - - const codeStub = sinon.stub(CodeService.prototype, 'getAllCodeSets').resolves(mockAllCodesResponse); - - const codes = await emlService.codes(); - - expect(emlService._codes).to.eql(mockAllCodesResponse); - expect(emlService._codes).to.eql(codes); - expect(codeStub).to.be.calledOnce; - }); - - it('should return cached codes if _codes is not undefined', async () => { - const mockDBConnection = getMockDBConnection(); - const emlService = new EmlService(mockDBConnection); - - emlService._codes = mockAllCodesResponse; - - const codeStub = sinon.stub(CodeService.prototype, 'getAllCodeSets'); - - const codes = await emlService.codes(); - - expect(emlService._codes).to.eql(mockAllCodesResponse); - expect(emlService._codes).to.eql(codes); - expect(codeStub).not.to.be.called; - }); - - it('should return cached codes upon subsequent calls', async () => { - const mockDBConnection = getMockDBConnection(); - const emlService = new EmlService(mockDBConnection); - - const codeStub = sinon.stub(CodeService.prototype, 'getAllCodeSets').resolves(mockAllCodesResponse); - - const freshCodes = await emlService.codes(); - const cachedCodes = await emlService.codes(); - - expect(freshCodes).to.eql(cachedCodes); - expect(codeStub).to.be.calledOnce; - }); - }); - - describe('loadEmlDbConstants', () => { - beforeEach(() => { - sinon.restore(); - }); - - it('should yield Not Supplied constants if the database returns no rows', async () => { - const mockQuery = sinon.stub(); - - mockQuery.onCall(0).resolves({ rowCount: 0, rows: [] }); - mockQuery.onCall(1).resolves({ rowCount: 0, rows: [] }); - mockQuery.onCall(2).resolves({ rowCount: 0, rows: [] }); - mockQuery.onCall(3).resolves({ rowCount: 0, rows: [] }); - mockQuery.onCall(4).resolves({ rowCount: 0, rows: [] }); - mockQuery.onCall(5).resolves({ rowCount: 0, rows: [] }); - - const mockDBConnection = { - ...getMockDBConnection(), - sql: mockQuery - }; - - const emlService = new EmlService(mockDBConnection); - - await emlService.loadEmlDbConstants(); - - expect(emlService._constants).to.eql({ - EML_VERSION: '1.0.0', - EML_PROVIDER_URL: 'Not Supplied', - EML_SECURITY_PROVIDER_URL: 'Not Supplied', - EML_ORGANIZATION_NAME: 'Not Supplied', - EML_ORGANIZATION_URL: 'Not Supplied', - EML_TAXONOMIC_PROVIDER_URL: 'Not Supplied', - EML_INTELLECTUAL_RIGHTS: 'Not Supplied' - }); - }); - - it('should yield Not Supplied constants if the database returns null constants', async () => { - const mockQuery = sinon.stub(); - - mockQuery.onCall(0).resolves({ rowCount: 0, rows: [{ constant: null }] }); - mockQuery.onCall(1).resolves({ rowCount: 0, rows: [{ constant: null }] }); - mockQuery.onCall(2).resolves({ rowCount: 0, rows: [{ constant: null }] }); - mockQuery.onCall(3).resolves({ rowCount: 0, rows: [{ constant: null }] }); - mockQuery.onCall(4).resolves({ rowCount: 0, rows: [{ constant: null }] }); - mockQuery.onCall(5).resolves({ rowCount: 0, rows: [{ constant: null }] }); - - const mockDBConnection = { - ...getMockDBConnection(), - sql: mockQuery - }; - - const emlService = new EmlService(mockDBConnection); - - await emlService.loadEmlDbConstants(); - - expect(emlService._constants).to.eql({ - EML_VERSION: '1.0.0', - EML_PROVIDER_URL: 'Not Supplied', - EML_SECURITY_PROVIDER_URL: 'Not Supplied', - EML_ORGANIZATION_NAME: 'Not Supplied', - EML_ORGANIZATION_URL: 'Not Supplied', - EML_TAXONOMIC_PROVIDER_URL: 'Not Supplied', - EML_INTELLECTUAL_RIGHTS: 'Not Supplied' - }); - }); - - it('should fetch DB constants successfully', async () => { - const mockQuery = sinon.stub(); - - mockQuery.onCall(0).resolves({ rowCount: 1, rows: [{ constant: 'test-org-url' }] }); - mockQuery.onCall(1).resolves({ rowCount: 1, rows: [{ constant: 'test-org-name' }] }); - mockQuery.onCall(2).resolves({ rowCount: 1, rows: [{ constant: 'test-provider-url' }] }); - mockQuery.onCall(3).resolves({ rowCount: 1, rows: [{ constant: 'test-security-provider' }] }); - mockQuery.onCall(4).resolves({ rowCount: 1, rows: [{ constant: 'test-int-rights' }] }); - mockQuery.onCall(5).resolves({ rowCount: 1, rows: [{ constant: 'test-taxon-url' }] }); - - const mockDBConnection = { - ...getMockDBConnection(), - sql: mockQuery - }; - - const emlService = new EmlService(mockDBConnection); - - await emlService.loadEmlDbConstants(); - - expect(emlService._constants).to.eql({ - EML_VERSION: '1.0.0', - EML_ORGANIZATION_URL: 'test-org-url', - EML_ORGANIZATION_NAME: 'test-org-name', - EML_PROVIDER_URL: 'test-provider-url', - EML_SECURITY_PROVIDER_URL: 'test-security-provider', - EML_INTELLECTUAL_RIGHTS: 'test-int-rights', - EML_TAXONOMIC_PROVIDER_URL: 'test-taxon-url' - }); - }); - }); - - describe('_buildEmlSection', () => { - it('should build an EML section', () => { - const mockDBConnection = getMockDBConnection(); - const emlService = new EmlService(mockDBConnection); - - const response = emlService._buildEmlSection('aaaabbbb-cccc-dddd-eeee-ffffgggghhhhiiii'); - expect(response).to.eql({ - $: { - packageId: 'urn:uuid:aaaabbbb-cccc-dddd-eeee-ffffgggghhhhiiii', - system: '', - 'xmlns:eml': 'https://eml.ecoinformatics.org/eml-2.2.0', - 'xmlns:xsi': 'http://www.w3.org/2001/XMLSchema-instance', - 'xmlns:stmml': 'http://www.xml-cml.org/schema/schema24', - 'xsi:schemaLocation': 'https://eml.ecoinformatics.org/eml-2.2.0 xsd/eml.xsd' - } - }); - }); - }); - - describe('_buildProjectEmlDatasetSection', () => { - it('should build an EML dataset section', () => { - const mockDBConnection = getMockDBConnection(); - const emlService = new EmlService(mockDBConnection); - - const mockOrg = { - organizationName: 'Test Organization', - electronicMailAddress: 'EMAIL@address.com' - }; - - const mockPackageId = 'aaaabbbb-cccc-dddd-eeee-ffffgggghhhhiiii'; - const mockProjectData = { - project: { - project_name: 'Test Project Name' - } - } as IGetProject; - - sinon.stub(EmlService.prototype, '_getProjectDatasetCreator').returns(mockOrg); - - sinon.stub(EmlService.prototype, '_makeEmlDateString').returns('2023-01-01'); - - sinon.stub(EmlService.prototype, '_getProjectContact').returns({ - individualName: { - givenName: 'First Name', - surName: 'Last Name' - }, - ...mockOrg - }); - - const response = emlService._buildProjectEmlDatasetSection(mockPackageId, mockProjectData); - - expect(response).to.eql({ - $: { system: '', id: 'aaaabbbb-cccc-dddd-eeee-ffffgggghhhhiiii' }, - title: 'Test Project Name', - creator: { - organizationName: 'Test Organization', - electronicMailAddress: 'EMAIL@address.com' - }, - pubDate: '2023-01-01', - language: 'English', - contact: { - individualName: { - givenName: 'First Name', - surName: 'Last Name' - }, - organizationName: 'Test Organization', - electronicMailAddress: 'EMAIL@address.com' - } - }); - }); - }); - - describe('_buildProjectEmlProjectSection', () => { - it('should build a project EML Project section', () => { - const mockDBConnection = getMockDBConnection(); - const emlService = new EmlService(mockDBConnection); - - const mockProjectData = { - project: { - uuid: 'aaaabbbb-cccc-dddd-eeee-ffffgggghhhhiiii', - project_name: 'Test Project Name' - }, - objectives: { - objectives: 'Project objectives.' - } - } as IGetProject; - - sinon.stub(EmlService.prototype, '_getProjectPersonnel').returns([ - { - individualName: { givenName: 'First Name', surName: 'Last Name' }, - organizationName: 'A Rocha Canada', - electronicMailAddress: 'EMAIL@address.com', - role: 'pointOfContact' - } - ]); - - sinon.stub(EmlService.prototype, '_getProjectTemporalCoverage').returns({ - rangeOfDates: { - beginDate: { calendarDate: '2023-01-01' }, - endDate: { calendarDate: '2023-01-31' } - } - }); - - const response = emlService._buildProjectEmlProjectSection(mockProjectData, []); - - expect(response).to.eql({ - $: { id: 'aaaabbbb-cccc-dddd-eeee-ffffgggghhhhiiii', system: '' }, - title: 'Test Project Name', - personnel: [ - { - individualName: { givenName: 'First Name', surName: 'Last Name' }, - organizationName: 'A Rocha Canada', - electronicMailAddress: 'EMAIL@address.com', - role: 'pointOfContact' - } - ], - abstract: { - section: [{ title: 'Objectives', para: 'Project objectives.' }] - }, - studyAreaDescription: { - coverage: { - geographicCoverage: { - geographicDescription: 'Location Description', - boundingCoordinates: { - westBoundingCoordinate: -121.904297, - eastBoundingCoordinate: -120.19043, - northBoundingCoordinate: 51.971346, - southBoundingCoordinate: 50.930738 - }, - datasetGPolygon: [ - { - datasetGPolygonOuterGRing: [ - { - gRingPoint: [ - { gRingLatitude: 50.930738, gRingLongitude: -121.904297 }, - { gRingLatitude: 51.971346, gRingLongitude: -121.904297 }, - { gRingLatitude: 51.971346, gRingLongitude: -120.19043 }, - { gRingLatitude: 50.930738, gRingLongitude: -120.19043 }, - { gRingLatitude: 50.930738, gRingLongitude: -121.904297 } - ] - } - ] - } - ] - }, - temporalCoverage: { - rangeOfDates: { - beginDate: { calendarDate: '2023-01-01' }, - endDate: { calendarDate: '2023-01-31' } - } - } - } - } - }); - }); - - it('should build if optional parameters are missing', () => { - const mockDBConnection = getMockDBConnection(); - const emlService = new EmlService(mockDBConnection); - - const mockProjectData = { - project: { - uuid: 'aaaabbbb-cccc-dddd-eeee-ffffgggghhhhiiii', - project_name: 'Test Project Name' - }, - objectives: { - objectives: 'Project objectives.' - } - } as IGetProject; - - sinon.stub(EmlService.prototype, '_getProjectPersonnel').returns([ - { - individualName: { givenName: 'First Name', surName: 'Last Name' }, - organizationName: 'A Rocha Canada', - electronicMailAddress: 'EMAIL@address.com', - role: 'pointOfContact' - } - ]); - - sinon.stub(EmlService.prototype, '_getProjectTemporalCoverage').returns({ - rangeOfDates: { - beginDate: { calendarDate: '2023-01-01' }, - endDate: { calendarDate: '2023-01-31' } - } - }); - - const response = emlService._buildProjectEmlProjectSection(mockProjectData, []); - - expect(response).to.eql({ - $: { id: 'aaaabbbb-cccc-dddd-eeee-ffffgggghhhhiiii', system: '' }, - title: 'Test Project Name', - personnel: [ - { - individualName: { givenName: 'First Name', surName: 'Last Name' }, - organizationName: 'A Rocha Canada', - electronicMailAddress: 'EMAIL@address.com', - role: 'pointOfContact' - } - ], - abstract: { - section: [{ title: 'Objectives', para: 'Project objectives.' }] - }, - studyAreaDescription: { - coverage: { - temporalCoverage: { - rangeOfDates: { - beginDate: { calendarDate: '2023-01-01' }, - endDate: { calendarDate: '2023-01-31' } - } - } - } - } - }); - }); - }); - - describe('_getSurveyAdditionalMetadata', async () => { - it('should return an empty array, since there is (currently) no additional metadata for surveys', async () => { - const mockDBConnection = getMockDBConnection(); - const emlService = new EmlService(mockDBConnection); - - const additionalMeta = await emlService._getSurveyAdditionalMetadata([]); - - expect(additionalMeta).to.eql([]); - }); - }); - - describe('_getProjectAdditionalMetadata', () => { - // TODO - }); - - describe('_getProjectDatasetCreator', () => { - // TODO - }); - - describe('_getProjectContact', () => { - // TODO - }); - - describe('_getProjectPersonnel', () => { - // TODO - }); - - describe('_getSurveyPersonnel', () => { - it('should return survey personnel', async () => { - // TODO: Replace this test once SIMSBIOHUB-275 is merged. - /* - const mockDBConnection = getMockDBConnection(); - const emlService = new EmlService(mockDBConnection); - - const mockSurveyData = { - survey_details: { - biologist_first_name: 'biologist-fname', - biologist_last_name: 'biologist-lname' - } - } as SurveyObject; - - const response = emlService._getSurveyPersonnel(mockSurveyData); - - expect(response).to.eql([ - { - individualName: { givenName: 'biologist-fname', surName: 'biologist-lname' }, - role: 'pointOfContact' - } - ]); - */ - }); - }); - - describe('_getProjectTemporalCoverage', () => { - // - }); - - describe('_getSurveyTemporalCoverage', () => { - // - }); - - describe('_makeEmlDateString', () => { - // - }); - - describe('_makePolygonFeatures', () => { - // - }); - - describe('_makeDatasetGPolygons', () => { - // - }); - - describe('_getProjectGeographicCoverage', () => { - // - }); - - describe('_getSurveyGeographicCoverage', () => { - // - }); - - describe('_getSurveyFocalTaxonomicCoverage', () => { - // - }); - - describe('_getSurveyDesignDescription', () => { - // - }); - - describe('_buildAllSurveyEmlProjectSections', () => { - // - }); - - describe('_buildSurveyEmlProjectSection', () => { - // - }); -}); diff --git a/api/src/services/eml-service.ts b/api/src/services/eml-service.ts deleted file mode 100644 index a27892e960..0000000000 --- a/api/src/services/eml-service.ts +++ /dev/null @@ -1,1015 +0,0 @@ -import bbox from '@turf/bbox'; -import circle from '@turf/circle'; -import { AllGeoJSON, featureCollection } from '@turf/helpers'; -import { coordEach } from '@turf/meta'; -import jsonpatch from 'fast-json-patch'; -import { Feature, GeoJsonProperties, Geometry } from 'geojson'; -import _ from 'lodash'; -import SQL from 'sql-template-strings'; -import xml2js from 'xml2js'; -import { PROJECT_ROLE } from '../constants/roles'; -import { IDBConnection } from '../database/db'; -import { IGetProject } from '../models/project-view'; -import { SurveyObject } from '../models/survey-view'; -import { IAllCodeSets } from '../repositories/code-repository'; -import { CodeService } from './code-service'; -import { DBService } from './db-service'; -import { ProjectService } from './project-service'; -import { SurveyService } from './survey-service'; - -const NOT_SUPPLIED = 'Not Supplied'; -const EMPTY_STRING = ``; - -const DEFAULT_DB_CONSTANTS = { - EML_VERSION: '1.0.0', - EML_PROVIDER_URL: NOT_SUPPLIED, - EML_SECURITY_PROVIDER_URL: NOT_SUPPLIED, - EML_ORGANIZATION_NAME: NOT_SUPPLIED, - EML_ORGANIZATION_URL: NOT_SUPPLIED, - EML_TAXONOMIC_PROVIDER_URL: NOT_SUPPLIED, - EML_INTELLECTUAL_RIGHTS: NOT_SUPPLIED -}; - -type EmlDbConstants = { - EML_VERSION: string; - EML_PROVIDER_URL: string; - EML_SECURITY_PROVIDER_URL: string; - EML_ORGANIZATION_NAME: string; - EML_ORGANIZATION_URL: string; - EML_TAXONOMIC_PROVIDER_URL: string; - EML_INTELLECTUAL_RIGHTS: string; -}; - -type BuildProjectEmlOptions = { - projectId: number; -}; - -type BuildSurveyEmlOptions = { - surveyId: number; -}; - -type AdditionalMetadata = { - describes: string; - metadata: Record; -}; - -type EmlPackageOptions = { - packageId: string; -}; - -/** - * Represents an EML package used to produce an EML string - * - * @class EmlPackage - */ -export class EmlPackage { - /** - * The unique identifier representing the EML package - * - * @type {string} - * @memberof EmlPackage - */ - packageId: string; - - /** - * Maintains all EML package fields - * - * @type {Record} - * @memberof EmlPackage - */ - _data: Record = {}; - - /** - * Maintains EML field data for the EML package - * - * @type {(Record | null)} - * @memberof EmlPackage - */ - _emlMetadata: Record | null = null; - - /** - * Maintains Dataset EML data for the EML package - * - * @type {(Record | null)} - * @memberof EmlPackage - */ - _datasetMetadata: Record | null = null; - - /** - * Maintains Dataset Project EML data for the EML package - * - * @type {(Record | null)} - * @memberof EmlPackage - */ - _projectMetadata: Record | null = null; - - /** - * Maintains Related Projects fields for the EML package - * - * @type {Record[]} - * @memberof EmlPackage - */ - _relatedProjects: Record[] = []; - - /** - * Maintains Additional Metadata fields for the EML package - * - * @type {AdditionalMetadata[]} - * @memberof EmlPackage - */ - _additionalMetadata: AdditionalMetadata[] = []; - - /** - * The XML2JS Builder which builds the EML string - * - * @type {xml2js.Builder} - * @memberof EmlPackage - */ - _xml2jsBuilder: xml2js.Builder; - - constructor(options: EmlPackageOptions) { - this.packageId = options.packageId; - - this._xml2jsBuilder = new xml2js.Builder({ renderOpts: { pretty: false } }); - } - - /** - * Sets the EML data field for the EML package - * - * @param {Record} emlMetadata - * @return {*} - * @memberof EmlPackage - */ - withEml(emlMetadata: Record): this { - this._emlMetadata = emlMetadata; - - return this; - } - - /** - * Sets the Dataset data field for the EML package - * - * @param {Record} datasetMetadata - * @return {*} - * @memberof EmlPackage - */ - withDataset(datasetMetadata: Record): this { - this._datasetMetadata = datasetMetadata; - - return this; - } - - /** - * Sets the Dataset Project data field for the EML package - * - * @param {Record} projectMetadata - * @return {*} - * @memberof EmlPackage - */ - withProject(projectMetadata: Record): this { - this._projectMetadata = projectMetadata; - - return this; - } - - /** - * Appends Additional Metadata fields on the EML package - * - * @param {AdditionalMetadata[]} additionalMetadata - * @return {*} - * @memberof EmlPackage - */ - withAdditionalMetadata(additionalMetadata: AdditionalMetadata[]): this { - additionalMetadata.forEach((meta) => this._additionalMetadata.push(meta)); - - return this; - } - - /** - * Appends Related Project fields on the EML package - * - * @param {Record[]} relatedProjects - * @return {*} - * @memberof EmlPackage - */ - withRelatedProjects(relatedProjects: Record[]): this { - relatedProjects.forEach((project) => this._relatedProjects.push(project)); - - return this; - } - - /** - * Compiles the EML package - * - * @return {*} {EmlPackage} - * @memberof EmlPackage - */ - build(): this { - if (this._data) { - // Support subsequent compilations - this._data = {}; - } - - // Add project metadata to dataset - if (this._projectMetadata) { - if (!this._datasetMetadata) { - throw new Error("Can't build Project EML without first building dataset EML."); - } - - this._datasetMetadata.project = this._projectMetadata; - } - - // Add related projects metadata to project - if (this._relatedProjects.length) { - if (!this._datasetMetadata?.project) { - throw new Error("Can't build Project EML without first building Dataset Project EML."); - } else if (!this._datasetMetadata) { - throw new Error("Can't build Related Project EML without first building dataset EML."); - } - - this._datasetMetadata.project.relatedProject = this._relatedProjects; - } - - jsonpatch.applyOperation(this._data, { - op: 'add', - path: '/eml:eml', - value: this._emlMetadata - }); - - jsonpatch.applyOperation(this._data, { - op: 'add', - path: '/eml:eml/dataset', - value: this._datasetMetadata - }); - - jsonpatch.applyOperation(this._data, { - op: 'add', - path: '/eml:eml/additionalMetadata', - value: this._additionalMetadata - }); - - return this; - } - - /** - * Returns the EML package as an EML-compliant XML string. - * - * @return {*} {string} - * @memberof EmlPackage - */ - toString(): string { - return this._xml2jsBuilder.buildObject(this._data); - } - - /** - * Returns the EML as a JSON object - * - * @return {*} {Record} - * @memberof EmlPackage - */ - toJson(): Record { - return this._data; - } -} - -/** - * Service to produce Ecological Metadata Language (EML) data for projects and surveys. - * - * @see https://eml.ecoinformatics.org for EML specification - * @see https://knb.ecoinformatics.org/emlparser/ for an online EML validator. - * @export - * @class EmlService - * @extends {DBService} - */ -export class EmlService extends DBService { - _projectService: ProjectService; - _surveyService: SurveyService; - _codeService: CodeService; - - _constants: EmlDbConstants = DEFAULT_DB_CONSTANTS; - - _codes: IAllCodeSets | null; - - constructor(connection: IDBConnection) { - super(connection); - - this._projectService = new ProjectService(this.connection); - this._surveyService = new SurveyService(this.connection); - this._codeService = new CodeService(this.connection); - this._codes = null; - } - - /** - * Produces an EML package representing the project with the given project ID - * - * @param {BuildProjectEmlOptions} options - * @return {*} {Promise} - * @memberof EmlService - */ - async buildProjectEmlPackage(options: BuildProjectEmlOptions): Promise { - const { projectId } = options; - await this.loadEmlDbConstants(); - - const projectData = await this._projectService.getProjectById(projectId); - const packageId = projectData.project.uuid; - - const surveysData = await this._surveyService.getSurveysByProjectId(projectId); - - const emlPackage = new EmlPackage({ packageId }); - - return ( - emlPackage - // Build EML field - .withEml(this._buildEmlSection(packageId)) - - // Build EML->Dataset field - .withDataset(this._buildProjectEmlDatasetSection(packageId, projectData)) - - // Build EML->Dataset->Project field - .withProject(this._buildProjectEmlProjectSection(projectData, surveysData)) - - // Build EML->Dataset->Project->AdditionalMetadata field - .withAdditionalMetadata(await this._getProjectAdditionalMetadata(projectData)) - .withAdditionalMetadata(await this._getSurveyAdditionalMetadata(surveysData)) - - // Build EML->Dataset->Project->RelatedProject field - .withRelatedProjects(await this._buildAllSurveyEmlProjectSections(surveysData)) - - // Compile the EML package - .build() - ); - } - - /** - * Produces an EML package representing the survey with the given survey ID - * - * @param {BuildSurveyEmlOptions} options - * @return {*} {Promise} - * @memberof EmlService - */ - async buildSurveyEmlPackage(options: BuildSurveyEmlOptions): Promise { - const { surveyId } = options; - await this.loadEmlDbConstants(); - - const surveyData = await this._surveyService.getSurveyById(surveyId); - - const packageId = surveyData.survey_details.uuid; - - const projectId = surveyData.survey_details.project_id; - const projectData = await this._projectService.getProjectById(projectId); - - const emlPackage = new EmlPackage({ packageId }); - - return ( - emlPackage - // Build EML field - .withEml(this._buildEmlSection(packageId)) - - // Build EML->Dataset field - .withDataset(this._buildSurveyEmlDatasetSection(packageId, surveyData)) - - // Build EML->Dataset->Project field - .withProject(await this._buildSurveyEmlProjectSection(surveyData)) - - // Build EML->Dataset->Project->AdditionalMetadata field - .withAdditionalMetadata(await this._getProjectAdditionalMetadata(projectData)) - .withAdditionalMetadata(await this._getSurveyAdditionalMetadata([surveyData])) - - // Build EML->Dataset->Project->RelatedProject field// - .withRelatedProjects([this._buildProjectEmlProjectSection(projectData, [surveyData])]) - - // Compile the EML package - .build() - ); - } - - /** - * Loads all codesets. - * - * @return {*} {Promise} - * @memberof EmlService - */ - async codes(): Promise { - if (!this._codes) { - this._codes = await this._codeService.getAllCodeSets(); - } - - return this._codes; - } - - /** - * Loads constants pertaining to EML generation from the database. - */ - async loadEmlDbConstants() { - const [ - organizationUrl, - organizationName, - providerURL, - securityProviderURL, - intellectualRights, - taxonomicProviderURL - ] = await Promise.all([ - this.connection.sql<{ constant: string }>( - SQL`SELECT api_get_character_system_metadata_constant(${'ORGANIZATION_URL'}) as constant;` - ), - this.connection.sql<{ constant: string }>( - SQL`SELECT api_get_character_system_metadata_constant(${'ORGANIZATION_NAME_FULL'}) as constant;` - ), - this.connection.sql<{ constant: string }>( - SQL`SELECT api_get_character_system_metadata_constant(${'PROVIDER_URL'}) as constant;` - ), - this.connection.sql<{ constant: string }>( - SQL`SELECT api_get_character_system_metadata_constant(${'SECURITY_PROVIDER_URL'}) as constant;` - ), - this.connection.sql<{ constant: string }>( - SQL`SELECT api_get_character_system_metadata_constant(${'INTELLECTUAL_RIGHTS'}) as constant;` - ), - this.connection.sql<{ constant: string }>( - SQL`SELECT api_get_character_system_metadata_constant(${'TAXONOMIC_PROVIDER_URL'}) as constant;` - ) - ]); - - this._constants.EML_ORGANIZATION_URL = organizationUrl.rows[0]?.constant || NOT_SUPPLIED; - this._constants.EML_ORGANIZATION_NAME = organizationName.rows[0]?.constant || NOT_SUPPLIED; - this._constants.EML_PROVIDER_URL = providerURL.rows[0]?.constant || NOT_SUPPLIED; - this._constants.EML_SECURITY_PROVIDER_URL = securityProviderURL.rows[0]?.constant || NOT_SUPPLIED; - this._constants.EML_INTELLECTUAL_RIGHTS = intellectualRights.rows[0]?.constant || NOT_SUPPLIED; - this._constants.EML_TAXONOMIC_PROVIDER_URL = taxonomicProviderURL.rows[0]?.constant || NOT_SUPPLIED; - } - - /** - * Builds the EML section of an EML package for either a project or survey - * - * @param {string} packageId - * @return {*} {Record} - * @memberof EmlService - */ - _buildEmlSection(packageId: string): Record { - return { - $: { - packageId: `urn:uuid:${packageId}`, - system: EMPTY_STRING, - 'xmlns:eml': 'https://eml.ecoinformatics.org/eml-2.2.0', - 'xmlns:xsi': 'http://www.w3.org/2001/XMLSchema-instance', - 'xmlns:stmml': 'http://www.xml-cml.org/schema/schema24', - 'xsi:schemaLocation': 'https://eml.ecoinformatics.org/eml-2.2.0 xsd/eml.xsd' - } - }; - } - - /** - * Builds the EML Dataset section for a project - * - * @param {IGetProject} projectData - * @param {string} packageId - * @return {*} {Promise>} - * @memberof EmlService - */ - _buildProjectEmlDatasetSection(packageId: string, projectData: IGetProject): Record { - return { - $: { system: EMPTY_STRING, id: packageId }, - title: projectData.project.project_name, - creator: this._getProjectDatasetCreator(projectData), - - // EML specification expects short ISO format - pubDate: this._makeEmlDateString(), - language: 'English', - contact: this._getProjectContact(projectData) - }; - } - - /** - * Builds the EML Dataset section for a survey - * - * @param {string} packageId - * @param {SurveyObject} surveyData - * @return {*} {Record} - * @memberof EmlService - */ - _buildSurveyEmlDatasetSection(packageId: string, surveyData: SurveyObject): Record { - return { - $: { system: EMPTY_STRING, id: packageId }, - title: surveyData.survey_details.survey_name, - creator: this._getSurveyContact(surveyData), - - // EML specification expects short ISO format - pubDate: this._makeEmlDateString(), - language: 'English', - contact: this._getSurveyContact(surveyData) - }; - } - - /** - * Builds the EML Project section for the given project data - * - * @param {IGetProject} projectData - * @return {*} {Record} - * @memberof EmlService - */ - _buildProjectEmlProjectSection(projectData: IGetProject, surveys: SurveyObject[]): Record { - return { - $: { id: projectData.project.uuid, system: EMPTY_STRING }, - title: projectData.project.project_name, - personnel: this._getProjectPersonnel(projectData), - abstract: { - section: [{ title: 'Objectives', para: projectData.objectives.objectives }] - }, - studyAreaDescription: { - coverage: { - ...this._getProjectGeographicCoverage(surveys), - temporalCoverage: this._getProjectTemporalCoverage(projectData) - } - } - }; - } - - /** - * Generates additional metadata fields for the given array of surveys - * - * @param {SurveyObjectWithAttachments[]} _surveysData - * @return {*} {AdditionalMetadata[]} - * @memberof EmlService - */ - // eslint-disable-next-line @typescript-eslint/no-unused-vars - async _getSurveyAdditionalMetadata(_surveysData: SurveyObject[]): Promise { - const additionalMetadata: AdditionalMetadata[] = []; - const codes = await this.codes(); - - await Promise.all( - _surveysData.map(async (item) => { - // add this metadata field so biohub is aware if EML is a project or survey - additionalMetadata.push({ - describes: item.survey_details.uuid, - metadata: { - types: { - type: 'SURVEY' - } - } - }); - - const partnetshipsMetadata = await this._buildPartnershipMetadata(item); - additionalMetadata.push(partnetshipsMetadata); - - if (item.survey_details.survey_types.length) { - const names = codes.type - .filter((code) => item.survey_details.survey_types.includes(code.id)) - .map((code) => code.name); - - additionalMetadata.push({ - describes: item.survey_details.uuid, - metadata: { - surveyTypes: { - surveyType: names.map((item) => { - return { name: item }; - }) - } - } - }); - } - }) - ); - - return additionalMetadata; - } - - /** - * Generates additional metadata fields for the given project - * - * @param {IGetProject} projectData - * @return {*} {Promise} - * @memberof EmlService - */ - async _getProjectAdditionalMetadata(projectData: IGetProject): Promise { - const additionalMetadata: AdditionalMetadata[] = []; - const codes = await this.codes(); - - if (projectData.project.project_programs) { - additionalMetadata.push({ - describes: projectData.project.uuid, - metadata: { - projectPrograms: { - projectProgram: projectData.project.project_programs.map( - (item) => codes.program.find((code) => code.id === item)?.name - ) - } - } - }); - } - - if (projectData.iucn.classificationDetails.length) { - const iucnNames = projectData.iucn.classificationDetails.map((iucnItem) => { - return { - level_1_name: codes.iucn_conservation_action_level_1_classification.find( - (code) => iucnItem.classification === code.id - )?.name, - level_2_name: codes.iucn_conservation_action_level_2_subclassification.find( - (code) => iucnItem.subClassification1 === code.id - )?.name, - level_3_name: codes.iucn_conservation_action_level_3_subclassification.find( - (code) => iucnItem.subClassification2 === code.id - )?.name - }; - }); - - additionalMetadata.push({ - describes: projectData.project.uuid, - metadata: { - IUCNConservationActions: { - IUCNConservationAction: iucnNames.map((item) => { - return { - IUCNConservationActionLevel1Classification: item.level_1_name, - IUCNConservationActionLevel2SubClassification: item.level_2_name, - IUCNConservationActionLevel3SubClassification: item.level_3_name - }; - }) - } - } - }); - } - - // add this metadata field so biohub is aware if EML is a project or survey - additionalMetadata.push({ - describes: projectData.project.uuid, - metadata: { - types: { - type: 'PROJECT' - } - } - }); - - return additionalMetadata; - } - - async _buildPartnershipMetadata(surveyData: SurveyObject): Promise { - const stakeholders = surveyData.partnerships.stakeholder_partnerships; - const codes = await this.codes(); - const indigenousPartnerships = surveyData.partnerships.indigenous_partnerships; - const firstNationsNames = codes.first_nations - .filter((code) => indigenousPartnerships.includes(code.id)) - .map((code) => code.name); - - const sortedPartnerships = _.sortBy([...firstNationsNames, ...stakeholders]); - - return { - describes: surveyData.survey_details.uuid, - metadata: { - partnerships: { - partnership: sortedPartnerships.map((name) => { - return { name }; - }) - } - } - }; - } - - /** - * Creates an object representing the dataset creator from the given projectData. - * - * - * @param {IGetProject} projectData - * @return {*} {Record} - * @memberof EmlService - */ - _getProjectDatasetCreator(projectData: IGetProject): Record { - const coordinator = projectData.participants.find((participant) => { - return participant.role_names.includes(PROJECT_ROLE.COORDINATOR); - }); - - if (!coordinator) { - // Return default organization name - return { organizationName: this._constants.EML_ORGANIZATION_NAME }; - } - - return { - individualName: { givenName: coordinator.given_name, surName: coordinator.family_name }, - electronicMailAddress: coordinator.email - }; - } - - /** - * Creates an object representing the primary contact for the given project. - * - * - * @param {IGetProject} projectData - * @return {*} {Record} - * @memberof EmlService - */ - _getProjectContact(projectData: IGetProject): Record { - const coordinator = projectData.participants.find((participant) => { - return participant.role_names.includes(PROJECT_ROLE.COORDINATOR); - }); - - if (!coordinator) { - // Return default organization name - return { organizationName: this._constants.EML_ORGANIZATION_NAME }; - } - - return { - individualName: { givenName: coordinator.given_name, surName: coordinator.family_name }, - electronicMailAddress: coordinator.email, - role: 'pointOfContact' - }; - } - - /** - * Creates an object representing the biologist name for the given survey. - * - * @param {SurveyObject} surveyData - * @return {*} {Record} - * @memberof EmlService - */ - _getSurveyContact(surveyData: SurveyObject): Record { - const coordinator = surveyData.participants.find((participant) => { - return participant.role_names.includes(PROJECT_ROLE.COORDINATOR); - }); - - if (!coordinator) { - // Return default organization name - return { organizationName: this._constants.EML_ORGANIZATION_NAME }; - } - - return { - individualName: { givenName: coordinator.given_name, surName: coordinator.family_name }, - electronicMailAddress: coordinator.email, - role: 'pointOfContact' - }; - } - - /** - * Creates an object representing all contacts for the given project. - * - * - * @param {IGetProject} projectData - * @return {*} {Record[]} - * @memberof EmlService - */ - _getProjectPersonnel(projectData: IGetProject): Record[] { - const participants = projectData.participants; - - return participants.map((participant) => ({ - individualName: { givenName: participant.given_name, surName: participant.family_name }, - electronicMailAddress: participant.email - })); - } - - /** - * Creates an object representing all contacts for the given survey. - * - * @param {SurveyObject} surveyData - * @return {*} {Record[]} - * @memberof EmlService - */ - _getSurveyPersonnel(surveyData: SurveyObject): Record[] { - const participants = surveyData.participants; - - return participants.map((participant) => ({ - individualName: { givenName: participant.given_name, surName: participant.family_name }, - electronicMailAddress: participant.email - })); - } - - /** - * Creates an object representing temporal coverage for the given project - * - * @param {IGetProject} projectData - * @return {*} {Record} - * @memberof EmlService - */ - _getProjectTemporalCoverage(projectData: IGetProject): Record { - if (!projectData.project.end_date) { - return { - singleDateTime: { - calendarDate: projectData.project.start_date - } - }; - } - - return { - rangeOfDates: { - beginDate: { calendarDate: projectData.project.start_date }, - endDate: { calendarDate: projectData.project.end_date } - } - }; - } - - /** - * Creates an object representing temporal coverage for the given survey - * - * @param {SurveyObject} surveyData - * @return {*} {Record} - * @memberof EmlService - */ - _getSurveyTemporalCoverage(surveyData: SurveyObject): Record { - if (!surveyData.survey_details.end_date) { - return { - singleDateTime: { - calendarDate: surveyData.survey_details.start_date - } - }; - } - - return { - rangeOfDates: { - beginDate: { calendarDate: surveyData.survey_details.start_date }, - endDate: { calendarDate: surveyData.survey_details.end_date } - } - }; - } - - /** - * Converts a Date or string into a date string compatible with EML. - * - * @param {Date | string} [date] - * @return {*} {string} - * @memberof EmlService - */ - _makeEmlDateString(date?: Date | string): string { - return (date ? new Date(date) : new Date()).toISOString().split('T')[0]; - } - - /** - * Creates an array of polygon features for the given project or survey geometry. - * - * @param {Feature[]} geometry - * @return {*} {Feature[]} - * @memberof EmlService - */ - _makePolygonFeatures(geometry: Feature[]): Feature[] { - return geometry.map((feature) => { - if (feature.geometry.type === 'Point' && feature.properties?.radius) { - return circle(feature.geometry, feature.properties.radius, { units: 'meters' }); - } - - return feature; - }); - } - - /** - * Creates a set of datasetGPoloygons for the given project or survey - * - * @param {Feature[]} polygonFeatures - * @return {*} {Record[]} - * @memberof EmlService - */ - _makeDatasetGPolygons(polygonFeatures: Feature[]): Record[] { - return polygonFeatures.map((feature) => { - const featureCoords: number[][] = []; - - coordEach(feature as AllGeoJSON, (currentCoord) => { - featureCoords.push(currentCoord); - }); - - return { - datasetGPolygonOuterGRing: [ - { - gRingPoint: featureCoords.map((coords) => { - return { gRingLatitude: coords[1], gRingLongitude: coords[0] }; - }) - } - ] - }; - }); - } - - _getBoundingBoxForFeatures(description: string, features: Feature[]): Record { - const polygonFeatures = this._makePolygonFeatures(features); - const datasetPolygons = this._makeDatasetGPolygons(polygonFeatures); - const boundingBox = bbox(featureCollection(polygonFeatures)); - - return { - geographicCoverage: { - geographicDescription: description, - boundingCoordinates: { - westBoundingCoordinate: boundingBox[0], - eastBoundingCoordinate: boundingBox[2], - northBoundingCoordinate: boundingBox[3], - southBoundingCoordinate: boundingBox[1] - }, - datasetGPolygon: datasetPolygons - } - }; - } - - /** - * Creates an object representing geographic coverage pertaining to the given survey - * - * @param {SurveyObject} surveyData - * @return {*} {Record} - * @memberof EmlService - */ - _getSurveyGeographicCoverage(surveyData: SurveyObject): Record { - if (!surveyData.locations?.length) { - return {}; - } - - let features: Feature[] = []; - - for (const item of surveyData.locations) { - features = features.concat(item.geometry as Feature[]); - } - - return this._getBoundingBoxForFeatures('Survey location Geographic Coverage', features); - } - - /** - * Creates an object representing geographic coverage pertaining to the given project - * - * @param {IGetProject} projectData - * @return {*} {Record} - * @memberof EmlService - */ - _getProjectGeographicCoverage(surveys: SurveyObject[]): Record { - if (!surveys.length) { - return {}; - } - let features: Feature[] = []; - - for (const survey of surveys) { - for (const location of survey.locations) { - features = features.concat(location.geometry as Feature[]); - } - } - - return this._getBoundingBoxForFeatures('Geographic coverage of all underlying project surveys', features); - } - - /** - * Creates an object representing the design description for the given survey - * - * @param {SurveyObject} surveyData - * @return {*} {Promise>} - * @memberof EmlService - */ - async _getSurveyDesignDescription(survey: SurveyObject): Promise> { - const codes = await this.codes(); - - return { - description: { - section: [ - { - title: 'Vantage Codes', - para: { - itemizedlist: { - listitem: codes.vantage_codes - .filter((code) => survey.purpose_and_methodology.vantage_code_ids.includes(code.id)) - .map((code) => { - return { para: code.name }; - }) - } - } - } - ] - } - }; - } - - /** - * Builds the EML Project section for the given array of surveys - * - * @param {SurveyObjectWithAttachments[]} surveys - * @return {*} {Promise[]>} - * @memberof EmlService - */ - async _buildAllSurveyEmlProjectSections(surveysData: SurveyObject[]): Promise[]> { - return Promise.all(surveysData.map(async (survey) => await this._buildSurveyEmlProjectSection(survey))); - } - - /** - * Builds the EML Project section for the given survey - * - * @param {SurveyObject} surveyData - * @return {*} {Promise>} - * @memberof EmlService - */ - async _buildSurveyEmlProjectSection(surveyData: SurveyObject): Promise> { - const codes = await this.codes(); - - return { - $: { id: surveyData.survey_details.uuid, system: EMPTY_STRING }, - title: surveyData.survey_details.survey_name, - personnel: this._getSurveyPersonnel(surveyData), - abstract: { - section: [ - { - title: 'Intended Outcomes', - para: surveyData.purpose_and_methodology.intended_outcome_ids - .map((outcomeId) => codes.intended_outcomes.find((code) => code.id === outcomeId)?.name) - .join(', ') - }, - { - title: 'Additional Details', - para: surveyData.purpose_and_methodology.additional_details || NOT_SUPPLIED - } - ] - }, - studyAreaDescription: { - coverage: { - ...this._getSurveyGeographicCoverage(surveyData), - temporalCoverage: this._getSurveyTemporalCoverage(surveyData), - taxonomicCoverage: [] //await this._getSurveyFocalTaxonomicCoverage(surveyData) - } - }, - designDescription: await this._getSurveyDesignDescription(surveyData) - }; - } -} diff --git a/api/src/services/project-service.test.ts b/api/src/services/project-service.test.ts index 8c46a63625..577928b8dc 100644 --- a/api/src/services/project-service.test.ts +++ b/api/src/services/project-service.test.ts @@ -23,18 +23,12 @@ describe('ProjectService', () => { { project_id: 123, name: 'Project 1', - project_programs: [], - regions: [], - start_date: '1900-01-01', - end_date: '2200-10-10' + regions: [] }, { project_id: 456, name: 'Project 2', - project_programs: [], - regions: [], - start_date: '1900-01-01', - end_date: '2000-12-31' + regions: [] } ]; @@ -45,11 +39,9 @@ describe('ProjectService', () => { expect(repoStub).to.be.calledOnce; expect(response[0].project_id).to.equal(123); expect(response[0].name).to.equal('Project 1'); - expect(response[0].completion_status).to.equal('Active'); expect(response[1].project_id).to.equal(456); expect(response[1].name).to.equal('Project 2'); - expect(response[1].completion_status).to.equal('Completed'); }); }); diff --git a/api/src/services/project-service.ts b/api/src/services/project-service.ts index 7ce90c32a3..ab0109c023 100644 --- a/api/src/services/project-service.ts +++ b/api/src/services/project-service.ts @@ -1,4 +1,3 @@ -import { default as dayjs } from 'dayjs'; import { IDBConnection } from '../database/db'; import { HTTP400 } from '../errors/http-error'; import { IPostIUCN, PostProjectObject } from '../models/project-create'; @@ -26,17 +25,6 @@ import { PlatformService } from './platform-service'; import { ProjectParticipationService } from './project-participation-service'; import { SurveyService } from './survey-service'; -/** - * Project Completion Status - * - * @export - * @enum {string} - */ -export enum COMPLETION_STATUS { - COMPLETED = 'Completed', - ACTIVE = 'Active' -} - export class ProjectService extends DBService { attachmentService: AttachmentService; projectRepository: ProjectRepository; @@ -62,7 +50,7 @@ export class ProjectService extends DBService { * @param {(number | null)} systemUserId * @param {IProjectAdvancedFilters} filterFields * @param {ApiPaginationOptions} [pagination] - * @return {*} {(Promise<(ProjectListData & { completion_status: COMPLETION_STATUS })[]>)} + * @return {*} {(Promise<(ProjectListData)[]>)} * @memberof ProjectService */ async getProjectList( @@ -70,15 +58,10 @@ export class ProjectService extends DBService { systemUserId: number | null, filterFields: IProjectAdvancedFilters, pagination?: ApiPaginationOptions - ): Promise<(ProjectListData & { completion_status: COMPLETION_STATUS })[]> { + ): Promise { const response = await this.projectRepository.getProjectList(isUserAdmin, systemUserId, filterFields, pagination); - return response.map((row) => ({ - ...row, - completion_status: - (row.end_date && dayjs(row.end_date).endOf('day').isBefore(dayjs()) && COMPLETION_STATUS.COMPLETED) || - COMPLETION_STATUS.ACTIVE - })); + return response; } /** @@ -212,9 +195,6 @@ export class ProjectService extends DBService { ) ); - // Handle project programs - promises.push(this.insertPrograms(projectId, postProjectData.project.project_programs)); - //Handle project participants promises.push(this.projectParticipationService.postProjectParticipants(projectId, postProjectData.participants)); @@ -259,19 +239,6 @@ export class ProjectService extends DBService { return this.projectParticipationService.postProjectParticipant(projectId, systemUserId, projectParticipantRole); } - /** - * Insert programs data. - * - * @param {number} projectId - * @param {number[]} projectPrograms - * @return {*} {Promise} - * @memberof ProjectService - */ - async insertPrograms(projectId: number, projectPrograms: number[]): Promise { - await this.projectRepository.deletePrograms(projectId); - await this.projectRepository.insertProgram(projectId, projectPrograms); - } - /** * Updates the project * @@ -291,10 +258,6 @@ export class ProjectService extends DBService { promises.push(this.updateIUCNData(projectId, entities)); } - if (entities?.project?.project_programs) { - promises.push(this.insertPrograms(projectId, entities?.project?.project_programs)); - } - if (entities?.participants) { promises.push(this.projectParticipationService.upsertProjectParticipantData(projectId, entities.participants)); } diff --git a/app/src/components/search-filter/ProjectAdvancedFilters.tsx b/app/src/components/search-filter/ProjectAdvancedFilters.tsx index 114fe21f10..604678f6c7 100644 --- a/app/src/components/search-filter/ProjectAdvancedFilters.tsx +++ b/app/src/components/search-filter/ProjectAdvancedFilters.tsx @@ -1,4 +1,3 @@ -import FormControl from '@mui/material/FormControl'; import Grid from '@mui/material/Grid'; import CustomTextField from 'components/fields/CustomTextField'; import MultiAutocompleteFieldVariableSize, { @@ -13,9 +12,6 @@ import { debounce } from 'lodash-es'; import { useMemo } from 'react'; export interface IProjectAdvancedFilters { - project_programs: number[]; - start_date: string; - end_date: string; keyword: string; project_name: string; agency_id: number; @@ -24,9 +20,6 @@ export interface IProjectAdvancedFilters { } export const ProjectAdvancedFiltersInitialValues: IProjectAdvancedFilters = { - project_programs: [], - start_date: '', - end_date: '', keyword: '', project_name: '', agency_id: '' as unknown as number, @@ -106,19 +99,6 @@ const ProjectAdvancedFilters = () => { search={handleSearch} /> - - - { - return { value: item.id, label: item.name }; - }) ?? [] - } - /> - - { it('renders correctly with default empty values', async () => { const { getByLabelText } = render( @@ -31,7 +15,7 @@ describe('ProjectDetailsForm', () => { validateOnBlur={true} validateOnChange={false} onSubmit={async () => {}}> - {() => } + {() => } ); @@ -43,27 +27,23 @@ describe('ProjectDetailsForm', () => { it('renders correctly with existing details values', async () => { const existingFormValues: IProjectDetailsForm = { project: { - project_name: 'name 1', - project_programs: [2], - start_date: '2021-03-14', - end_date: '2021-04-14' + project_name: 'name 1' } }; - const { getByLabelText, getByText } = render( + const { getByLabelText } = render( {}}> - {() => } + {() => } ); await waitFor(() => { expect(getByLabelText('Project Name', { exact: false })).toBeVisible(); - expect(getByText('type 2', { exact: false })).toBeVisible(); }); }); }); diff --git a/app/src/features/projects/components/ProjectDetailsForm.tsx b/app/src/features/projects/components/ProjectDetailsForm.tsx index fb2878008f..1bc437e569 100644 --- a/app/src/features/projects/components/ProjectDetailsForm.tsx +++ b/app/src/features/projects/components/ProjectDetailsForm.tsx @@ -1,58 +1,36 @@ -import FormControl from '@mui/material/FormControl'; import Grid from '@mui/material/Grid'; import CustomTextField from 'components/fields/CustomTextField'; -import MultiAutocompleteFieldVariableSize, { - IMultiAutocompleteFieldOption -} from 'components/fields/MultiAutocompleteFieldVariableSize'; -import StartEndDateFields from 'components/fields/StartEndDateFields'; import { useFormikContext } from 'formik'; import { ICreateProjectRequest } from 'interfaces/useProjectApi.interface'; -import React from 'react'; import yup from 'utils/YupSchema'; export interface IProjectDetailsForm { project: { project_name: string; - project_programs: number[]; - start_date: string; - end_date: string; }; } export const ProjectDetailsFormInitialValues: IProjectDetailsForm = { project: { - project_name: '', - project_programs: [], - start_date: '', - end_date: '' + project_name: '' } }; export const ProjectDetailsFormYupSchema = yup.object().shape({ project: yup.object().shape({ - project_name: yup.string().max(300, 'Cannot exceed 300 characters').required('Project Name is Required'), - project_programs: yup - .array(yup.number()) - .min(1, 'At least 1 Project Program is Required') - .required('Project Program is Required'), - start_date: yup.string().isValidDateString().required('Start Date is Required'), - end_date: yup.string().nullable().isValidDateString().isEndDateSameOrAfterStartDate('start_date') + project_name: yup.string().max(300, 'Cannot exceed 300 characters').required('Project Name is Required') }) }); -export interface IProjectDetailsFormProps { - program: IMultiAutocompleteFieldOption[]; -} - /** * Create project - General information section * * @return {*} */ -const ProjectDetailsForm: React.FC = (props) => { +const ProjectDetailsForm = () => { const formikProps = useFormikContext(); - const { touched, errors, handleSubmit } = formikProps; + const { handleSubmit } = formikProps; return ( @@ -66,29 +44,6 @@ const ProjectDetailsForm: React.FC = (props) => { }} /> - - - - - - - - ); diff --git a/app/src/features/projects/edit/EditProjectForm.tsx b/app/src/features/projects/edit/EditProjectForm.tsx index 32d0c168d4..021ecd5020 100644 --- a/app/src/features/projects/edit/EditProjectForm.tsx +++ b/app/src/features/projects/edit/EditProjectForm.tsx @@ -53,13 +53,7 @@ const EditProjectForm = - { - return { value: item.id, label: item.name }; - }) || [] - } - /> + diff --git a/app/src/features/projects/list/ProjectsListPage.test.tsx b/app/src/features/projects/list/ProjectsListPage.test.tsx index d041bf690a..12154fed19 100644 --- a/app/src/features/projects/list/ProjectsListPage.test.tsx +++ b/app/src/features/projects/list/ProjectsListPage.test.tsx @@ -115,9 +115,7 @@ describe('ProjectsListPage', () => { name: 'Project 1', start_date: null, end_date: null, - project_programs: [1], - regions: ['region'], - completion_status: 'Completed' + regions: ['region'] } ], pagination: { diff --git a/app/src/features/projects/list/ProjectsListPage.tsx b/app/src/features/projects/list/ProjectsListPage.tsx index a35054bac4..88589e08d3 100644 --- a/app/src/features/projects/list/ProjectsListPage.tsx +++ b/app/src/features/projects/list/ProjectsListPage.tsx @@ -32,10 +32,6 @@ import { ApiPaginationRequestOptions } from 'types/misc'; import { firstOrNull, getFormattedDate } from 'utils/Utils'; import ProjectsListFilterForm from './ProjectsListFilterForm'; -interface IProjectsListTableRow extends Omit { - project_programs: string; -} - const pageSizeOptions = [10, 25, 50]; /** @@ -76,24 +72,9 @@ const ProjectsListPage = () => { }; }); - const getProjectPrograms = (project: IProjectsListItemData) => { - return ( - codesContext.codesDataLoader.data?.program - .filter((code) => project.project_programs.includes(code.id)) - .map((code) => code.name) - .join(', ') || '' - ); - }; - - const projectRows = - projectsDataLoader.data?.projects.map((project) => { - return { - ...project, - project_programs: getProjectPrograms(project) - }; - }) ?? []; - - const columns: GridColDef[] = [ + const projectRows = projectsDataLoader.data?.projects ?? []; + + const columns: GridColDef[] = [ { field: 'name', headerName: 'Name', @@ -111,11 +92,6 @@ const ProjectsListPage = () => { /> ) }, - { - field: 'project_programs', - headerName: 'Programs', - flex: 1 - }, { field: 'regions', headerName: 'Regions', diff --git a/app/src/features/projects/view/ProjectDetails.tsx b/app/src/features/projects/view/ProjectDetails.tsx index 8e706111b6..b07dfa9faf 100644 --- a/app/src/features/projects/view/ProjectDetails.tsx +++ b/app/src/features/projects/view/ProjectDetails.tsx @@ -6,7 +6,6 @@ import Typography from '@mui/material/Typography'; import assert from 'assert'; import { ProjectContext } from 'contexts/projectContext'; import { useContext } from 'react'; -import GeneralInformation from './components/GeneralInformation'; import ProjectObjectives from './components/ProjectObjectives'; import TeamMembers from './components/TeamMember'; @@ -77,14 +76,6 @@ const ProjectDetails = () => {
- - - General Information - - - - - Team Members diff --git a/app/src/features/projects/view/ProjectHeader.tsx b/app/src/features/projects/view/ProjectHeader.tsx index 1ff904fc41..60d2144ef7 100644 --- a/app/src/features/projects/view/ProjectHeader.tsx +++ b/app/src/features/projects/view/ProjectHeader.tsx @@ -1,7 +1,5 @@ import { mdiAccountMultipleOutline, - mdiCalendarRange, - mdiCalendarTodayOutline, mdiChevronDown, mdiCogOutline, mdiPencilOutline, @@ -9,17 +7,14 @@ import { } from '@mdi/js'; import Icon from '@mdi/react'; import Button from '@mui/material/Button'; -import grey from '@mui/material/colors/grey'; import ListItemIcon from '@mui/material/ListItemIcon'; import Menu from '@mui/material/Menu'; import MenuItem from '@mui/material/MenuItem'; -import Stack from '@mui/material/Stack'; import Typography from '@mui/material/Typography'; import assert from 'assert'; import { IErrorDialogProps } from 'components/dialog/ErrorDialog'; import PageHeader from 'components/layout/PageHeader'; import { ProjectRoleGuard } from 'components/security/Guards'; -import { DATE_FORMAT } from 'constants/dateTimeFormats'; import { DeleteProjectI18N } from 'constants/i18n'; import { PROJECT_PERMISSION, SYSTEM_ROLE } from 'constants/roles'; import { DialogContext } from 'contexts/dialogContext'; @@ -28,7 +23,6 @@ import { APIError } from 'hooks/api/useAxios'; import { useBiohubApi } from 'hooks/useBioHubApi'; import React, { useContext } from 'react'; import { useHistory } from 'react-router'; -import { getFormattedDateRangeString } from 'utils/Utils'; /** * Project header for a single-project view. @@ -104,30 +98,6 @@ const ProjectHeader = () => { <> - {projectData.projectData.project.end_date ? ( - - - - {getFormattedDateRangeString( - DATE_FORMAT.MediumDateFormat, - projectData.projectData.project.start_date, - projectData.projectData.project.end_date - )} - - ) : ( - - - Start Date: - {getFormattedDateRangeString( - DATE_FORMAT.ShortMediumDateFormat, - projectData.projectData.project.start_date - )} - - )} - - } buttonJSX={ { - const codesContext = useContext(CodesContext); - const projectContext = useContext(ProjectContext); - - // Codes data must be loaded by a parent before this component is rendered - assert(codesContext.codesDataLoader.data); - // Project data must be loaded by a parent before this component is rendered - assert(projectContext.projectDataLoader.data); - - const codes = codesContext.codesDataLoader.data; - const projectData = projectContext.projectDataLoader.data.projectData; - - const projectPrograms = - codes.program - .filter((code) => projectData.project.project_programs.includes(code.id)) - .map((code) => code.name) - .join(', ') || ''; - - return ( - - - - Program - - {projectPrograms ? <>{projectPrograms} : 'No Programs'} - - - - Timeline - - - {projectData.project.end_date ? ( - <> - {getFormattedDateRangeString( - DATE_FORMAT.ShortMediumDateFormat, - projectData.project.start_date, - projectData.project.end_date - )} - - ) : ( - <> - Start Date: - {getFormattedDateRangeString(DATE_FORMAT.ShortMediumDateFormat, projectData.project.start_date)} - - )} - - - - ); -}; - -export default GeneralInformation; diff --git a/app/src/features/surveys/components/general-information/GeneralInformationForm.tsx b/app/src/features/surveys/components/general-information/GeneralInformationForm.tsx index 32f82f85d0..0dedfb5b46 100644 --- a/app/src/features/surveys/components/general-information/GeneralInformationForm.tsx +++ b/app/src/features/surveys/components/general-information/GeneralInformationForm.tsx @@ -79,7 +79,7 @@ export const GeneralInformationYupSchema = () => { survey_details: yup.object().shape({ survey_name: yup.string().required('Survey Name is Required'), start_date: yup.string().isValidDateString().required('Start Date is Required'), - end_date: yup.string().nullable().isValidDateString().isEndDateSameOrAfterStartDate('start_date'), + end_date: yup.string().nullable().isValidDateString(), survey_types: yup .array(yup.number()) .min(1, 'One or more Types are required') @@ -100,8 +100,6 @@ export const GeneralInformationYupSchema = () => { export interface IGeneralInformationFormProps { type: IMultiAutocompleteFieldOption[]; - projectStartDate: string; - projectEndDate: string; progress: ISelectWithSubtextFieldOption[]; } diff --git a/app/src/features/surveys/edit/EditSurveyForm.tsx b/app/src/features/surveys/edit/EditSurveyForm.tsx index ae7287d58e..0335e9eec6 100644 --- a/app/src/features/surveys/edit/EditSurveyForm.tsx +++ b/app/src/features/surveys/edit/EditSurveyForm.tsx @@ -84,8 +84,6 @@ const EditSurveyForm = (props: IEditSurveyForm) => { return { value: item.id, label: item.name, subText: item.description }; }) || [] } - projectStartDate={projectData.project.start_date} - projectEndDate={projectData.project.end_date} /> }> diff --git a/app/src/interfaces/useCodesApi.interface.ts b/app/src/interfaces/useCodesApi.interface.ts index ec4845fec5..9df48a4aa8 100644 --- a/app/src/interfaces/useCodesApi.interface.ts +++ b/app/src/interfaces/useCodesApi.interface.ts @@ -27,7 +27,6 @@ export interface IGetAllCodeSetsResponse { investment_action_category: CodeSet<{ id: number; agency_id: number; name: string }>; type: CodeSet; proprietor_type: CodeSet<{ id: number; name: string; is_first_nation: boolean }>; - program: CodeSet; iucn_conservation_action_level_1_classification: CodeSet; iucn_conservation_action_level_2_subclassification: CodeSet<{ id: number; iucn1_id: number; name: string }>; iucn_conservation_action_level_3_subclassification: CodeSet<{ id: number; iucn2_id: number; name: string }>; diff --git a/app/src/interfaces/useProjectApi.interface.ts b/app/src/interfaces/useProjectApi.interface.ts index 82fa76e45e..c15075b738 100644 --- a/app/src/interfaces/useProjectApi.interface.ts +++ b/app/src/interfaces/useProjectApi.interface.ts @@ -91,11 +91,7 @@ export interface IGetProjectsListResponse { export interface IProjectsListItemData { project_id: number; name: string; - start_date: string; - end_date?: string; - completion_status: string; regions: string[]; - project_programs: number[]; } export interface IProjectUserRoles { @@ -143,9 +139,6 @@ export interface IGetProjectForUpdateResponse { export interface IGetProjectForUpdateResponseDetails { project_name: string; - project_programs: number[]; - start_date: string; - end_date: string; revision_count: number; } export interface IGetProjectForUpdateResponseObjectives { @@ -199,10 +192,6 @@ export interface ProjectViewObject { export interface IGetProjectForViewResponseDetails { project_id: number; project_name: string; - project_programs: number[]; - start_date: string; - end_date: string; - completion_status: string; } export interface IGetProjectForViewResponseObjectives { objectives: string; diff --git a/app/src/test-helpers/code-helpers.ts b/app/src/test-helpers/code-helpers.ts index 61b4e85863..63212d6756 100644 --- a/app/src/test-helpers/code-helpers.ts +++ b/app/src/test-helpers/code-helpers.ts @@ -6,7 +6,6 @@ export const codes: IGetAllCodeSetsResponse = { agency: [{ id: 1, name: 'Funding source code' }], investment_action_category: [{ id: 1, agency_id: 1, name: 'Investment action category' }], type: [{ id: 1, name: 'Type code' }], - program: [{ id: 1, name: 'Program' }], proprietor_type: [ { id: 1, name: 'Proprietor code 1', is_first_nation: false }, { id: 2, name: 'First Nations Land', is_first_nation: true } diff --git a/app/src/test-helpers/project-helpers.ts b/app/src/test-helpers/project-helpers.ts index 00ae48211d..c5a5547948 100644 --- a/app/src/test-helpers/project-helpers.ts +++ b/app/src/test-helpers/project-helpers.ts @@ -4,11 +4,7 @@ export const getProjectForViewResponse: IGetProjectForViewResponse = { projectData: { project: { project_id: 1, - project_name: 'Test Project Name', - project_programs: [], - start_date: '1998-10-10', - end_date: '2021-02-26', - completion_status: 'Active' + project_name: 'Test Project Name' }, objectives: { objectives: 'Et ad et in culpa si' diff --git a/database/src/migrations/20240620000000_project_changes.ts b/database/src/migrations/20240620000000_project_changes.ts new file mode 100644 index 0000000000..27b4524413 --- /dev/null +++ b/database/src/migrations/20240620000000_project_changes.ts @@ -0,0 +1,53 @@ +import { Knex } from 'knex'; + +/** + * Drop deprecated columns, tables, and triggers. + * + * Remove `project` columns + * - start_date + * - end_date + * Remove tables + * - project_program + * - program + * Remove triggers + * - project_val + * - permit_val + * + * @export + * @param {Knex} knex + * @return {*} {Promise} + */ +export async function up(knex: Knex): Promise { + await knex.raw(`--sql + + SET SEARCH_PATH='biohub,biohub_dapi_v1'; + + -- Drop the views + DROP VIEW IF EXISTS biohub_dapi_v1.project; + DROP VIEW IF EXISTS biohub_dapi_v1.project_program; + DROP VIEW IF EXISTS biohub_dapi_v1.program; + + -- Drop the project_program table and program codes table + DROP TABLE IF EXISTS biohub.project_program; + DROP TABLE IF EXISTS biohub.program; + + -- Drop the triggers + DROP TRIGGER IF EXISTS project_val ON biohub.project; + DROP TRIGGER IF EXISTS permit_val ON biohub.permit; + + -- Drop the functions associated with the triggers + DROP FUNCTION IF EXISTS biohub.tr_project(); + DROP FUNCTION IF EXISTS biohub.tr_permit(); + + -- Drop columns start_date and end_date from the project table + ALTER TABLE biohub.project DROP COLUMN start_date; + ALTER TABLE biohub.project DROP COLUMN end_date; + + -- Recreate the view + CREATE OR REPLACE VIEW biohub_dapi_v1.project AS SELECT * FROM biohub.project; + `); +} + +export async function down(knex: Knex): Promise { + await knex.raw(``); +} diff --git a/database/src/procedures/delete_project_procedure.ts b/database/src/procedures/delete_project_procedure.ts index 0b4e4b2c23..ff1ff2737e 100644 --- a/database/src/procedures/delete_project_procedure.ts +++ b/database/src/procedures/delete_project_procedure.ts @@ -36,8 +36,6 @@ export async function seed(knex: Knex): Promise { delete from project_report_attachment where project_id = p_project_id; delete from project_participation where project_id = p_project_id; delete from project_metadata_publish where project_id = p_project_id; - delete from project_region where project_id = p_project_id; - delete from project_program where project_id = p_project_id; delete from grouping_project where project_id = p_project_id; delete from project where project_id = p_project_id; diff --git a/database/src/procedures/tr_project.ts b/database/src/procedures/tr_project.ts deleted file mode 100644 index ddb76a2a2d..0000000000 --- a/database/src/procedures/tr_project.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { Knex } from 'knex'; - -/** - * Inserts a trigger function that validates the start and end date of a project. - * - * - The project start date cannot be greater than the project end date. - * - * @export - * @param {Knex} knex - * @return {*} {Promise} - */ -export async function seed(knex: Knex): Promise { - await knex.raw(`--sql - CREATE OR REPLACE FUNCTION biohub.tr_project() - RETURNS trigger - LANGUAGE plpgsql - SECURITY invoker - AS $function$ - BEGIN - -- Assert project start date is not greater than the end date, if the end date is not null. - IF (new.end_date IS NOT NULL) THEN - IF (new.end_date < new.start_date) THEN - RAISE EXCEPTION 'The project start date cannot be greater than the end date.'; - END IF; - END IF; - - RETURN new; - END; - $function$; - - DROP TRIGGER IF EXISTS project_val ON biohub.project; - CREATE TRIGGER project_val BEFORE INSERT OR UPDATE ON biohub.project FOR EACH ROW EXECUTE PROCEDURE tr_project(); - `); -} diff --git a/database/src/seeds/03_basic_project_survey_setup.ts b/database/src/seeds/03_basic_project_survey_setup.ts index d5d5d8b1a8..ea98085d57 100644 --- a/database/src/seeds/03_basic_project_survey_setup.ts +++ b/database/src/seeds/03_basic_project_survey_setup.ts @@ -60,11 +60,10 @@ export async function seed(knex: Knex): Promise { const createProjectResponse = await knex.raw(insertProjectData(`Seed Project ${i + 1}`)); const projectId = createProjectResponse.rows[0].project_id; - // Insert project IUCN, participant and program data + // Insert project IUCN and participants await knex.raw(` ${insertProjectIUCNData(projectId)} ${insertProjectParticipationData(projectId)} - ${insertProjectProgramData(projectId)} `); // Insert survey data @@ -158,22 +157,6 @@ const insertSurveySiteStrategy = (surveyId: number) => ` ); `; -/** - * SQL to insert Project Program data - * - */ -const insertProjectProgramData = (projectId: number) => ` - INSERT into project_program - ( - project_id, - program_id - ) - VALUES ( - ${projectId}, - (select program_id from program order by random() limit 1) - ); -`; - const insertSurveyParticipationData = (surveyId: number) => ` INSERT into survey_participation ( survey_id, system_user_id, survey_job_id ) @@ -711,8 +694,6 @@ const insertProjectData = (projectName?: string) => ` name, objectives, location_description, - start_date, - end_date, geography, geojson ) @@ -720,8 +701,6 @@ const insertProjectData = (projectName?: string) => ` '${projectName ?? 'Seed Project'}', $$${faker.lorem.sentences(2)}$$, $$${faker.lorem.sentences(2)}$$, - $$${faker.date.between({ from: '2000-01-01T00:00:00-08:00', to: '2005-01-01T00:00:00-08:00' }).toISOString()}$$, - $$${faker.date.between({ from: '2025-01-01T00:00:00-08:00', to: '2030-01-01T00:00:00-08:00' }).toISOString()}$$, 'POLYGON ((-121.904297 50.930738, -121.904297 51.971346, -120.19043 51.971346, -120.19043 50.930738, -121.904297 50.930738))', '[ { diff --git a/scripts/bctw-deployments/main.js b/scripts/bctw-deployments/main.js index 9ca84111b0..37ccad0508 100755 --- a/scripts/bctw-deployments/main.js +++ b/scripts/bctw-deployments/main.js @@ -16,7 +16,6 @@ const CONFIG = { last_name: "Aubertin-Young", email: "Macgregor.Aubertin-Young@gov.bc.ca", project_role: "Coordinator", - project_program: "Wildlife", survey_status: "Completed", survey_type: "Monitoring", survey_intended_outcome_1: "Mortality", @@ -87,7 +86,7 @@ const jqPreParseInputFile = async (fileName) => { }) }) }' < ${fileName} - `, + ` ); return JSON.parse(data); @@ -161,9 +160,9 @@ async function main() { for (let pIndex = 0; pIndex < data.length; pIndex++) { const project = data[pIndex]; - sql += `WITH p AS (INSERT INTO project (name, objectives, coordinator_first_name, coordinator_last_name, coordinator_email_address, start_date, end_date) VALUES ($$Caribou - ${project.herd} - BCTW Telemetry$$, $$BCTW telemetry deployments for ${project.herd} Caribou$$, $$${CONFIG.first_name}$$, $$${CONFIG.last_name}$$, $$${CONFIG.email}$$, $$${project.start_date}$$, $$${project.end_date}$$) RETURNING project_id + sql += `WITH p AS (INSERT INTO project (name, objectives, coordinator_first_name, coordinator_last_name, coordinator_email_address) VALUES ($$Caribou - ${project.herd} - BCTW Telemetry$$, $$BCTW telemetry deployments for ${project.herd} Caribou$$, $$${CONFIG.first_name}$$, $$${CONFIG.last_name}$$, $$${CONFIG.email}$$) RETURNING project_id ), ppp AS (INSERT INTO project_participation (project_id, system_user_id, project_role_id) SELECT project_id, (select system_user_id from system_user where user_identifier = $$mauberti$$), (select project_role_id from project_role where name = $$${CONFIG.project_role}$$) FROM p - ), pp AS (INSERT INTO project_program (project_id, program_id) SELECT project_id, (select program_id from program where name = $$${CONFIG.project_program}$$) FROM p + ) `; for (let sIndex = 0; sIndex < project.surveys.length; sIndex++) { const survey = project.surveys[sIndex]; @@ -206,7 +205,7 @@ async function main() { } process.stdout.write( - `SET search_path=public,biohub; BEGIN; ${sql} COMMIT;`, + `SET search_path=public,biohub; BEGIN; ${sql} COMMIT;` ); } catch (err) { process.stderr.write(`main.js -> ${err}`); From e4a401e3528d9d730db7614e2c95052becba3bef Mon Sep 17 00:00:00 2001 From: Macgregor Aubertin-Young <108430771+mauberti-bc@users.noreply.github.com> Date: Fri, 21 Jun 2024 16:56:23 -0700 Subject: [PATCH 23/31] SIMSBIOHUB-593: Add constraint for a user to have only one role per Project (#1307) * Add database constraint to enforce one role per user per project * Add project member role icons to form control --------- Co-authored-by: Nick Phura --- api/src/repositories/code-repository.ts | 4 +- .../project-participation-service.test.ts | 123 +++++++++++++++++- .../services/project-participation-service.ts | 122 +++++++++++++---- app/src/components/user/UserRoleSelector.tsx | 22 +++- app/src/constants/roles.ts | 13 ++ .../projects/components/ProjectUserForm.tsx | 7 +- .../features/projects/view/ProjectDetails.tsx | 8 +- .../projects/view/components/TeamMember.tsx | 43 +++--- app/src/hooks/useDataLoader.ts | 1 - ...000000_project_participation_constraint.ts | 33 +++++ 10 files changed, 322 insertions(+), 54 deletions(-) create mode 100644 database/src/migrations/20240618000000_project_participation_constraint.ts diff --git a/api/src/repositories/code-repository.ts b/api/src/repositories/code-repository.ts index cd5550dd74..8b3894ede8 100644 --- a/api/src/repositories/code-repository.ts +++ b/api/src/repositories/code-repository.ts @@ -327,7 +327,9 @@ export class CodeRepository extends BaseRepository { project_role_id as id, name FROM project_role - WHERE record_end_date is null; + WHERE record_end_date is null + ORDER BY + CASE WHEN name = 'Coordinator' THEN 0 ELSE 1 END; `; const response = await this.connection.sql(sqlStatement, ICode); diff --git a/api/src/services/project-participation-service.test.ts b/api/src/services/project-participation-service.test.ts index 7601c46ac4..f76b9625c7 100644 --- a/api/src/services/project-participation-service.test.ts +++ b/api/src/services/project-participation-service.test.ts @@ -944,7 +944,7 @@ describe('ProjectParticipationService', () => { }); }); - describe('doProjectParticipantsHaveARole', () => { + describe('_doProjectParticipantsHaveARole', () => { it('should return true if one project user has a specified role', () => { const projectUsers: PostParticipantData[] = [ { @@ -962,7 +962,7 @@ describe('ProjectParticipationService', () => { const dbConnection = getMockDBConnection(); const service = new ProjectParticipationService(dbConnection); - const result = service.doProjectParticipantsHaveARole(projectUsers, PROJECT_ROLE.COLLABORATOR); + const result = service._doProjectParticipantsHaveARole(projectUsers, PROJECT_ROLE.COLLABORATOR); expect(result).to.be.true; }); @@ -984,7 +984,7 @@ describe('ProjectParticipationService', () => { const dbConnection = getMockDBConnection(); const service = new ProjectParticipationService(dbConnection); - const result = service.doProjectParticipantsHaveARole(projectUsers, PROJECT_ROLE.COLLABORATOR); + const result = service._doProjectParticipantsHaveARole(projectUsers, PROJECT_ROLE.COLLABORATOR); expect(result).to.be.true; }); @@ -1006,7 +1006,97 @@ describe('ProjectParticipationService', () => { const dbConnection = getMockDBConnection(); const service = new ProjectParticipationService(dbConnection); - const result = service.doProjectParticipantsHaveARole(projectUsers, PROJECT_ROLE.COLLABORATOR); + const result = service._doProjectParticipantsHaveARole(projectUsers, PROJECT_ROLE.COLLABORATOR); + + expect(result).to.be.false; + }); + }); + + describe('_doProjectParticipantsHaveOneRole', () => { + it('should return true if one project user has one specified role', () => { + const projectUsers: PostParticipantData[] = [ + { + project_participation_id: 23, + system_user_id: 22, + project_role_names: [PROJECT_ROLE.COLLABORATOR] + } + ]; + + const dbConnection = getMockDBConnection(); + const service = new ProjectParticipationService(dbConnection); + + const result = service._doProjectParticipantsHaveOneRole(projectUsers); + + expect(result).to.be.true; + }); + + it('should return true if multiple project users have one specified role', () => { + const projectUsers: PostParticipantData[] = [ + { + project_participation_id: 12, + system_user_id: 11, + project_role_names: [PROJECT_ROLE.COLLABORATOR] + }, + { + project_participation_id: 23, + system_user_id: 22, + project_role_names: [PROJECT_ROLE.OBSERVER] + } + ]; + + const dbConnection = getMockDBConnection(); + const service = new ProjectParticipationService(dbConnection); + + const result = service._doProjectParticipantsHaveOneRole(projectUsers); + + expect(result).to.be.true; + }); + + it('should return false if a participant has multiple specified role', () => { + const projectUsers: PostParticipantData[] = [ + { + project_participation_id: 12, + system_user_id: 11, + project_role_names: [PROJECT_ROLE.COORDINATOR] + }, + { + project_participation_id: 23, + system_user_id: 22, + project_role_names: [PROJECT_ROLE.OBSERVER] + }, + { + project_participation_id: 23, + system_user_id: 22, + project_role_names: [PROJECT_ROLE.COLLABORATOR] + } + ]; + + const dbConnection = getMockDBConnection(); + const service = new ProjectParticipationService(dbConnection); + + const result = service._doProjectParticipantsHaveOneRole(projectUsers); + + expect(result).to.be.false; + }); + + it('should return false if a participant has multiple specified roles in the same record', () => { + const projectUsers: PostParticipantData[] = [ + { + project_participation_id: 12, + system_user_id: 11, + project_role_names: [PROJECT_ROLE.COORDINATOR] + }, + { + project_participation_id: 23, + system_user_id: 22, + project_role_names: [PROJECT_ROLE.OBSERVER, PROJECT_ROLE.COLLABORATOR] + } + ]; + + const dbConnection = getMockDBConnection(); + const service = new ProjectParticipationService(dbConnection); + + const result = service._doProjectParticipantsHaveOneRole(projectUsers); expect(result).to.be.false; }); @@ -1058,6 +1148,10 @@ describe('ProjectParticipationService', () => { project_participation_id: 12, project_role_names: [PROJECT_ROLE.COORDINATOR] // Existing user to be updated }, + { + system_user_id: 33, + project_role_names: [PROJECT_ROLE.COLLABORATOR] // Existing user to be unaffected + }, { system_user_id: 44, project_role_names: [PROJECT_ROLE.OBSERVER] // New user @@ -1086,6 +1180,25 @@ describe('ProjectParticipationService', () => { user_guid: '123-456-789-1', user_identifier: 'testuser1' }, + { + project_participation_id: 6, // Existing user to be unaffected + project_id: 1, + system_user_id: 33, + project_role_ids: [2], + project_role_names: [PROJECT_ROLE.COLLABORATOR], + project_role_permissions: ['Permission1'], + agency: null, + display_name: 'test user 1', + email: 'email@email.com', + family_name: 'lname', + given_name: 'fname', + identity_source: SYSTEM_IDENTITY_SOURCE.IDIR, + record_end_date: null, + role_ids: [2], + role_names: [SYSTEM_ROLE.PROJECT_CREATOR], + user_guid: '123-456-789-1', + user_identifier: 'testuser1' + }, { project_participation_id: 23, // Existing user to be removed project_id: 1, @@ -1121,6 +1234,8 @@ describe('ProjectParticipationService', () => { expect(getProjectParticipantsStub).to.have.been.calledOnceWith(projectId); expect(deleteProjectParticipationRecordStub).to.have.been.calledWith(1, 23); expect(updateProjectParticipationRoleStub).to.have.been.calledOnceWith(12, PROJECT_ROLE.COORDINATOR); + expect(updateProjectParticipationRoleStub).to.not.have.been.calledWith(6, PROJECT_ROLE.COLLABORATOR); + expect(postProjectParticipantStub).to.not.have.been.calledWith(projectId, 6, PROJECT_ROLE.COLLABORATOR); expect(postProjectParticipantStub).to.have.been.calledOnceWith(projectId, 44, PROJECT_ROLE.OBSERVER); }); }); diff --git a/api/src/services/project-participation-service.ts b/api/src/services/project-participation-service.ts index 4b008caa4b..faa4e29cac 100644 --- a/api/src/services/project-participation-service.ts +++ b/api/src/services/project-participation-service.ts @@ -314,50 +314,128 @@ export class ProjectParticipationService extends DBService { return true; } - doProjectParticipantsHaveARole(participants: PostParticipantData[], roleToCheck: PROJECT_ROLE): boolean { + /** + * Internal function for validating that all Project members have a role + * + * @param {PostParticipantData[]} participants + * @param {PROJECT_ROLE} roleToCheck + * @return {*} {boolean} + * @memberof ProjectParticipationService + */ + _doProjectParticipantsHaveARole(participants: PostParticipantData[], roleToCheck: PROJECT_ROLE): boolean { return participants.some((item) => item.project_role_names.some((role) => role === roleToCheck)); } - async upsertProjectParticipantData(projectId: number, participants: PostParticipantData[]): Promise { - if (!this.doProjectParticipantsHaveARole(participants, PROJECT_ROLE.COORDINATOR)) { + /** + * Internal function for validating that all project participants have one unique role. + * + * @param {PostParticipantData[]} participants + * @return {*} {boolean} + * @memberof ProjectParticipationService + */ + _doProjectParticipantsHaveOneRole(participants: PostParticipantData[]): boolean { + // Map of system_user_id to set of unique role names + const participantUniqueRoles = new Map>(); + + for (const participant of participants) { + const system_user_id = participant.system_user_id; + const project_role_names = participant.project_role_names; + + // Get the set of unique role names, or initialize a new set if the user is not in the map + const uniqueRoleNamesForParticipant = participantUniqueRoles.get(system_user_id) ?? new Set(); + + for (const role of project_role_names) { + // Add the role names to the set, converting to lowercase to ensure case-insensitive comparison + uniqueRoleNamesForParticipant.add(role.toLowerCase()); + } + + // Update the map with the new set of unique role names + participantUniqueRoles.set(system_user_id, uniqueRoleNamesForParticipant); + } + + // Returns true if all participants have one unique role + return Array.from(participantUniqueRoles.values()).every((roleNames) => roleNames.size === 1); + } + + /** + * Updates existing participants, deletes those participants not in the incoming list, and inserts new participants. + * + * @param {number} projectId + * @param {PostParticipantData[]} incomingParticipants + * @return {*} {Promise} + * @throws ApiGeneralError If no participant has a coordinator role or if any participant has multiple roles. + * @memberof ProjectParticipationService + */ + async upsertProjectParticipantData(projectId: number, incomingParticipants: PostParticipantData[]): Promise { + // Confirm that at least one participant has a coordinator role + if (!this._doProjectParticipantsHaveARole(incomingParticipants, PROJECT_ROLE.COORDINATOR)) { throw new ApiGeneralError( `Projects require that at least one participant has a ${PROJECT_ROLE.COORDINATOR} role.` ); } - // all actions to take - const promises: Promise[] = []; + // Check for multiple roles for any participant + if (!this._doProjectParticipantsHaveOneRole(incomingParticipants)) { + throw new ApiGeneralError( + 'Users can only have one role per Project but multiple roles were specified for at least one user.' + ); + } - // get the existing participants for a project + // Fetch existing participants for the project const existingParticipants = await this.projectParticipationRepository.getProjectParticipants(projectId); - // Compare incoming with existing to find any outliers to delete + // Prepare promises for all database operations + const promises: Promise[] = []; + + // Identify participants to delete const participantsToDelete = existingParticipants.filter( - (item) => !participants.find((incoming) => incoming.system_user_id === item.system_user_id) + (existingParticipant) => + !incomingParticipants.some( + (incomingParticipant) => incomingParticipant.system_user_id === existingParticipant.system_user_id + ) ); - // delete - participantsToDelete.forEach((item) => { + // Delete participants not present in the incoming payload + participantsToDelete.forEach((participantToDelete) => { promises.push( - this.projectParticipationRepository.deleteProjectParticipationRecord(projectId, item.project_participation_id) + this.projectParticipationRepository.deleteProjectParticipationRecord( + projectId, + participantToDelete.project_participation_id + ) ); }); - participants.forEach((item) => { - if (item.project_participation_id) { + // Upsert participants based on conditions + incomingParticipants.forEach((incomingParticipant) => { + const existingParticipant = existingParticipants.find( + (existingParticipant) => existingParticipant.system_user_id === incomingParticipant.system_user_id + ); + + if (existingParticipant) { + // Update existing participant's role + if ( + !existingParticipant.project_role_names.some((existingRole) => + incomingParticipant.project_role_names.includes(existingRole as PROJECT_ROLE) + ) + ) { + promises.push( + this.projectParticipationRepository.updateProjectParticipationRole( + incomingParticipant.project_participation_id ?? existingParticipant.project_participation_id, + incomingParticipant.project_role_names[0] + ) + ); + } + } else if (!existingParticipant) { + // Insert new participant if the user does not already exist in the project, otherwise triggers database constraint error promises.push( - this.projectParticipationRepository.updateProjectParticipationRole( - item.project_participation_id, - item.project_role_names[0] + this.projectParticipationRepository.postProjectParticipant( + projectId, + incomingParticipant.system_user_id, + incomingParticipant.project_role_names[0] ) ); - } else { - this.projectParticipationRepository.postProjectParticipant( - projectId, - item.system_user_id, - item.project_role_names[0] - ); } + // If the participant already exists with the desired role, do nothing }); await Promise.all(promises); diff --git a/app/src/components/user/UserRoleSelector.tsx b/app/src/components/user/UserRoleSelector.tsx index 122533cf93..fbfcf2adf1 100644 --- a/app/src/components/user/UserRoleSelector.tsx +++ b/app/src/components/user/UserRoleSelector.tsx @@ -1,11 +1,13 @@ import { mdiClose } from '@mdi/js'; import Icon from '@mdi/react'; import Box from '@mui/material/Box'; -import { grey } from '@mui/material/colors'; +import grey from '@mui/material/colors/grey'; import IconButton from '@mui/material/IconButton'; import MenuItem from '@mui/material/MenuItem'; import Paper from '@mui/material/Paper'; import Select from '@mui/material/Select'; +import Typography from '@mui/material/Typography'; +import { PROJECT_ROLE_ICONS } from 'constants/roles'; import { ICode } from 'interfaces/useCodesApi.interface'; import { IGetProjectParticipant } from 'interfaces/useProjectApi.interface'; import { IGetSurveyParticipant } from 'interfaces/useSurveyApi.interface'; @@ -64,11 +66,27 @@ const UserRoleSelector: React.FC = (props) => { if (!selected) { return props.label; } - return selected; + return ( + + {selected} + {PROJECT_ROLE_ICONS[selected] && ( + <> +   + + + )} + + ); }}> {roles.map((item) => ( {item.name} + {PROJECT_ROLE_ICONS[item.name] && ( + <> +   + + + )} ))} diff --git a/app/src/constants/roles.ts b/app/src/constants/roles.ts index dd329207e9..ea1110d987 100644 --- a/app/src/constants/roles.ts +++ b/app/src/constants/roles.ts @@ -1,3 +1,5 @@ +import { mdiAccountEdit, mdiCrown } from '@mdi/js'; + /** * System level roles. * @@ -33,3 +35,14 @@ export enum PROJECT_PERMISSION { COLLABORATOR = 'Collaborator', OBSERVER = 'Observer' } + +/** + * Project role icons + * + * @export + */ +export const PROJECT_ROLE_ICONS: Record = { + Coordinator: mdiCrown, + Collaborator: mdiAccountEdit, + Observer: undefined +}; diff --git a/app/src/features/projects/components/ProjectUserForm.tsx b/app/src/features/projects/components/ProjectUserForm.tsx index a98c716593..6baa68ebb4 100644 --- a/app/src/features/projects/components/ProjectUserForm.tsx +++ b/app/src/features/projects/components/ProjectUserForm.tsx @@ -244,7 +244,12 @@ const ProjectUserForm = (props: IProjectUserFormProps) => { {values.participants.map((user: ISystemUser | IGetProjectParticipant, index: number) => { const error = rowItemError(index); return ( - + { Project Details - + Project Objectives - + @@ -80,7 +80,7 @@ const ProjectDetails = () => { Team Members - + @@ -89,7 +89,7 @@ const ProjectDetails = () => { IUCN Classification - + */}
diff --git a/app/src/features/projects/view/components/TeamMember.tsx b/app/src/features/projects/view/components/TeamMember.tsx index b90c081535..08b47ebba7 100644 --- a/app/src/features/projects/view/components/TeamMember.tsx +++ b/app/src/features/projects/view/components/TeamMember.tsx @@ -1,9 +1,10 @@ -import { mdiAccountEdit, mdiCrown } from '@mdi/js'; import Icon from '@mdi/react'; import Box from '@mui/material/Box'; +import grey from '@mui/material/colors/grey'; import Stack from '@mui/material/Stack'; import Typography from '@mui/material/Typography'; import assert from 'assert'; +import { PROJECT_ROLE_ICONS } from 'constants/roles'; import { ProjectContext } from 'contexts/projectContext'; import { useContext, useMemo } from 'react'; import { getRandomHexColor } from 'utils/Utils'; @@ -55,30 +56,34 @@ const TeamMembers = () => { return ( {projectTeamMembers.map((member) => { - const isCoordinator = member.roles.includes('Coordinator'); - const isCollaborator = member.roles.includes('Collaborator'); return ( + {/* Avatar Box */} + sx={{ + height: '35px', + width: '35px', + minWidth: '35px', + borderRadius: '50%', + bgcolor: member.avatarColor, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + mr: 1 + }}> {member.initials} - + + {/* Member Display Name and Roles */} + {member.display_name} - {(isCoordinator || isCollaborator) && ( - - )} + + {/* Roles with Icons */} + {member.roles.map((role) => ( + + + + ))} ); diff --git a/app/src/hooks/useDataLoader.ts b/app/src/hooks/useDataLoader.ts index a0600e8a6c..22c3ee7f2b 100644 --- a/app/src/hooks/useDataLoader.ts +++ b/app/src/hooks/useDataLoader.ts @@ -137,7 +137,6 @@ export default function useDataLoader} + */ +export async function up(knex: Knex): Promise { + await knex.raw(`--sql + SET SEARCH_PATH = biohub,biohub_dapi_v1; + + DROP VIEW IF EXISTS biohub_dapi_v1.project_participation; + + ---------------------------------------------------------------------------------------- + -- Add constraint to ensure a user can only have one role within a Project + ---------------------------------------------------------------------------------------- + + ALTER TABLE biohub.project_participation + ADD CONSTRAINT project_participation_uk2 UNIQUE (system_user_id, project_id); + + ---------------------------------------------------------------------------------------- + -- Update view + ---------------------------------------------------------------------------------------- + + CREATE OR REPLACE VIEW biohub_dapi_v1.project_participation AS SELECT * FROM biohub.project_participation; + `); +} + +export async function down(knex: Knex): Promise { + await knex.raw(``); +} From 41136303f84d2dfd1cf913a8e261425f636737d1 Mon Sep 17 00:00:00 2001 From: Nick Phura Date: Tue, 25 Jun 2024 11:50:02 -0700 Subject: [PATCH 24/31] BugFixes: Bug Fixes and Misc Cleanup (#1312) * Migrate deprecated attachment file_type values (was impacting Test and Prod). * Remove deprecated wldtaxonomic_units_id and field_method_id columns. * Update minimum replicas to stop some of the platform alert spam about horizontal pod autoscalars * Comment out HorizontalPodAutoscaler in API/APP * Update clamav readme and files. --- api/.pipeline/config.js | 2 +- api/.pipeline/templates/api.dc.yaml | 41 ++-- api/src/models/biohub-create.test.ts | 2 - .../repositories/observation-repository.ts | 1 - api/src/repositories/survey-repository.ts | 1 - api/src/services/observation-service.test.ts | 2 - app/.pipeline/config.js | 4 +- app/.pipeline/templates/app.dc.yaml | 41 ++-- containers/clamav/Dockerfile | 32 ++-- containers/clamav/README.md | 84 +++++++-- containers/clamav/config/clamd.conf | 2 +- containers/clamav/config/freshclam.conf | 11 -- .../clamav/openshift/templates/clamav-bc.yaml | 63 ++----- .../clamav/openshift/templates/clamav-dc.yaml | 177 +++++++++--------- .../20240624000000_attachment_type_fix.ts | 49 +++++ .../seeds/03_basic_project_survey_setup.ts | 11 -- 16 files changed, 287 insertions(+), 236 deletions(-) create mode 100644 database/src/migrations/20240624000000_attachment_type_fix.ts diff --git a/api/.pipeline/config.js b/api/.pipeline/config.js index ae315d1a09..05d8dc2980 100644 --- a/api/.pipeline/config.js +++ b/api/.pipeline/config.js @@ -100,7 +100,7 @@ const phases = { memoryRequest: '100Mi', memoryLimit: '4Gi', replicas: '1', - replicasMax: (isStaticDeployment && '2') || '1' + replicasMax: '1' }, test: { namespace: 'af2668-test', diff --git a/api/.pipeline/templates/api.dc.yaml b/api/.pipeline/templates/api.dc.yaml index bcf5fd08f8..dadc130ce9 100644 --- a/api/.pipeline/templates/api.dc.yaml +++ b/api/.pipeline/templates/api.dc.yaml @@ -464,23 +464,24 @@ objects: status: ingress: null - - kind: HorizontalPodAutoscaler - apiVersion: autoscaling/v2 - metadata: - annotations: {} - labels: {} - name: ${NAME}${SUFFIX} - spec: - minReplicas: ${{REPLICAS}} - maxReplicas: ${{REPLICAS_MAX}} - scaleTargetRef: - apiVersion: apps.openshift.io/v1 - kind: DeploymentConfig - name: ${NAME}${SUFFIX} - metrics: - - type: Resource - resource: - name: cpu - target: - type: Utilization - averageUtilization: 80 + # Disable the HPA for now, as it is preferrable to run an exact number of pods (e.g. min:2, max:2) + # - kind: HorizontalPodAutoscaler + # apiVersion: autoscaling/v2 + # metadata: + # annotations: {} + # labels: {} + # name: ${NAME}${SUFFIX} + # spec: + # minReplicas: ${{REPLICAS}} + # maxReplicas: ${{REPLICAS_MAX}} + # scaleTargetRef: + # apiVersion: apps.openshift.io/v1 + # kind: DeploymentConfig + # name: ${NAME}${SUFFIX} + # metrics: + # - type: Resource + # resource: + # name: cpu + # target: + # type: Utilization + # averageUtilization: 80 diff --git a/api/src/models/biohub-create.test.ts b/api/src/models/biohub-create.test.ts index 81261b3d65..c5dedaba10 100644 --- a/api/src/models/biohub-create.test.ts +++ b/api/src/models/biohub-create.test.ts @@ -16,7 +16,6 @@ describe('PostSurveyObservationToBiohubObject', () => { const obj = { survey_observation_id: 1, survey_id: 1, - wldtaxonomic_units_id: 1, survey_sample_site_id: 1, survey_sample_method_id: 1, survey_sample_period_id: 1, @@ -159,7 +158,6 @@ describe('PostSurveySubmissionToBioHubObject', () => { { survey_observation_id: 1, survey_id: 1, - wldtaxonomic_units_id: 1, survey_sample_site_id: 1, survey_sample_method_id: 1, survey_sample_period_id: 1, diff --git a/api/src/repositories/observation-repository.ts b/api/src/repositories/observation-repository.ts index 66f2d37cbf..3ed31fc931 100644 --- a/api/src/repositories/observation-repository.ts +++ b/api/src/repositories/observation-repository.ts @@ -24,7 +24,6 @@ const defaultLog = getLogger('repositories/observation-repository'); export const ObservationRecord = z.object({ survey_observation_id: z.number(), survey_id: z.number(), - wldtaxonomic_units_id: z.number().nullable(), itis_tsn: z.number(), itis_scientific_name: z.string().nullable(), survey_sample_site_id: z.number().nullable(), diff --git a/api/src/repositories/survey-repository.ts b/api/src/repositories/survey-repository.ts index f93ed62c39..57b223394e 100644 --- a/api/src/repositories/survey-repository.ts +++ b/api/src/repositories/survey-repository.ts @@ -48,7 +48,6 @@ export interface ISurveyProprietorModel { const SurveyRecord = z.object({ survey_id: z.number(), project_id: z.number(), - field_method_id: z.number().nullable(), uuid: z.string().uuid().nullable(), name: z.string().nullable(), additional_details: z.string().nullable(), diff --git a/api/src/services/observation-service.test.ts b/api/src/services/observation-service.test.ts index 80cc19d552..be821fbf48 100644 --- a/api/src/services/observation-service.test.ts +++ b/api/src/services/observation-service.test.ts @@ -40,7 +40,6 @@ describe('ObservationService', () => { { survey_observation_id: 11, survey_id: 1, - wldtaxonomic_units_id: 2, latitude: 3, longitude: 4, count: 5, @@ -60,7 +59,6 @@ describe('ObservationService', () => { { survey_observation_id: 6, survey_id: 1, - wldtaxonomic_units_id: 2, latitude: 8, longitude: 9, count: 10, diff --git a/app/.pipeline/config.js b/app/.pipeline/config.js index d5c194ad0c..26853318e4 100644 --- a/app/.pipeline/config.js +++ b/app/.pipeline/config.js @@ -87,8 +87,8 @@ const phases = { cpuLimit: '300m', memoryRequest: '100Mi', memoryLimit: '500Mi', - replicas: (isStaticDeployment && '1') || '1', - replicasMax: (isStaticDeployment && '2') || '1', + replicas: '1', + replicasMax: '1', biohubFeatureFlag: 'true', backbonePublicApiHost: 'https://api-dev-biohub-platform.apps.silver.devops.gov.bc.ca', biohubTaxonPath: '/api/taxonomy/taxon', diff --git a/app/.pipeline/templates/app.dc.yaml b/app/.pipeline/templates/app.dc.yaml index 6bc31cf023..b5dc3a60c9 100644 --- a/app/.pipeline/templates/app.dc.yaml +++ b/app/.pipeline/templates/app.dc.yaml @@ -264,23 +264,24 @@ objects: status: ingress: null - - kind: HorizontalPodAutoscaler - apiVersion: autoscaling/v2 - metadata: - annotations: {} - labels: {} - name: ${NAME}${SUFFIX} - spec: - minReplicas: ${{REPLICAS}} - maxReplicas: ${{REPLICAS_MAX}} - scaleTargetRef: - apiVersion: apps.openshift.io/v1 - kind: DeploymentConfig - name: ${NAME}${SUFFIX} - metrics: - - type: Resource - resource: - name: cpu - target: - type: Utilization - averageUtilization: 80 + # Disable the HPA for now, as it is preferrable to run an exact number of pods (e.g. min:2, max:2) + # - kind: HorizontalPodAutoscaler + # apiVersion: autoscaling/v2 + # metadata: + # annotations: {} + # labels: {} + # name: ${NAME}${SUFFIX} + # spec: + # minReplicas: ${{REPLICAS}} + # maxReplicas: ${{REPLICAS_MAX}} + # scaleTargetRef: + # apiVersion: apps.openshift.io/v1 + # kind: DeploymentConfig + # name: ${NAME}${SUFFIX} + # metrics: + # - type: Resource + # resource: + # name: cpu + # target: + # type: Utilization + # averageUtilization: 80 diff --git a/containers/clamav/Dockerfile b/containers/clamav/Dockerfile index 25c47ff5a2..c143369be5 100644 --- a/containers/clamav/Dockerfile +++ b/containers/clamav/Dockerfile @@ -1,28 +1,30 @@ -FROM registry.access.redhat.com/ubi8/ubi +FROM registry.access.redhat.com/ubi9/ubi +ARG VERSION=1.0.5 LABEL name="ubi8-clamav" \ vendor="Red Hat" \ - version="0.1.0" \ + version="${VERSION}" \ release="1" \ - summary="UBI 8 ClamAV" \ - description="ClamAV for UBI 8" \ + summary="UBI 9 ClamAV" \ + description="ClamAV for UBI 9" \ maintainer="EPIC" -RUN yum -y update \ - && yum -y install yum-utils \ - && rpm --import http://dl.fedoraproject.org/pub/epel/RPM-GPG-KEY-EPEL-8 \ - && yum -y install https://dl.fedoraproject.org/pub/epel/epel-release-latest-8.noarch.rpm -RUN yum install -y clamav-server clamav-data clamav-update clamav-filesystem clamav clamav-scanner-systemd clamav-devel clamav-lib clamav-server-systemd -RUN yum install -y wget +RUN yum -y update +RUN yum -y install https://www.clamav.net/downloads/production/clamav-${VERSION}.linux.x86_64.rpm +RUN yum -y install nc wget -COPY config/clamd.conf /etc/clamd.conf -COPY config/freshclam.conf /etc/freshclam.conf +# copy our configs to where clamav expects +COPY config/clamd.conf /usr/local/etc/clamd.conf +COPY config/freshclam.conf /usr/local/etc/freshclam.conf -RUN mkdir /opt/app-root -RUN mkdir /opt/app-root/src +RUN mkdir -p /opt/app-root/src RUN chown -R 1001:0 /opt/app-root/src RUN chmod -R ug+rwx /opt/app-root/src +# copy health check script to app-root +COPY clamdcheck.sh /opt/app-root +RUN chmod ug+rwx /opt/app-root/clamdcheck.sh + # # To fix check permissions error for clamAV RUN mkdir /var/log/clamav RUN touch /var/log/clamav/clamav.log @@ -36,4 +38,4 @@ USER 1001 EXPOSE 3310 -CMD freshclam && clamd -c /etc/clamd.conf +CMD freshclam && clamd diff --git a/containers/clamav/README.md b/containers/clamav/README.md index edfb17c750..13e97d4342 100644 --- a/containers/clamav/README.md +++ b/containers/clamav/README.md @@ -2,34 +2,84 @@ ClamAV® is an open source antivirus engine for detecting trojans, viruses, malware & other malicious threats. -This is a repo setup for utilization in Red Hat Openshift. This solution allows you to create a pod in your openshift environment to scan any file for known virus signatures, quickly and effectively. +See this repo for the OpenShift templates needed to deploy ClamAV: https://github.com/bcgov/clamav -The builds package the barebones service, and the deployment config will download latest signatures on first run. +The source repo should be used as it will have the latest versions, etc. +Note: at the time of writing this, the `clamav-dc.conf` in the source repo has the `IMAGE_NAMESPACE` variable hard-coded to a random project, which will need to be updated to this projects tools environment. Similarly, depending on the current version of OpenShift, some of the `apiVersion` in the build config and/or deploy config may be out of date and need updating. -Freshclam can be run within the container at any time to update the existing signatures. Alternatively, you can re-deploy which will fetch the latest into the running container. +A copy of the templates patched templates (converted to yaml) are included here as backup, in case the source repo is moved or becomes unavailable. -This clamav setup is cloned from the repo: https://github.com/bcgov/clamav +## Installation -## Prerequisites For Deploying On OpenShift +### Checkout the clamav repo. -### Import Base Image for `ubi8/ubi` Used By `clamav-bc.yaml` +### Import the Build Config -- Fetch latest version +1. Log into OpenShift +2. Switch to your tools environment. - ``` - oc import-image ubi8/ubi:latest --from=registry.access.redhat.com/ubi8/ubi:latest --confirm - ``` + ``` + oc project -tools + ``` -Openshift documentation on importing images +3. Navigate to the `/openshift/templates` folder +4. Import the clamav build config (clamav-bc.yaml) -- https://catalog.redhat.com/software/containers/ubi8/ubi/5c359854d70cc534b3a3784e?tag=latest&push_date=1673532745000&architecture=amd64&container-tabs=gti>i-tabs=unauthenticated + ``` + oc process -f clamav-bc.conf | oc create -f - + ``` - - See `oc import-image` command + This will create a new BuildConfig (`clamav-build`) and ImageStream (`clamav`). -## Build/Deployment +#### Build the Image -The templates in the `./openshift/templates` will build and deploy the app. Modify to suit your own environment. +1. Run the build -The build config `./openshift/templates/clamav-bc.yaml` will create your builder image (ideally in your tools project), and the deployment config `./openshift/templates/clamav-dc.yaml` will create the pod deployment. + ``` + OpenShift Web UI (Administrator) -> Builds -> BuildConfigs -> clamav-build -> Actions -> Start build + ``` -Modify the environment variables defined in both the build config and deployment config appropriately. + This will build the image, adding a new tag to the `clamav` ImageStream (`clamav:latest`) + +### Import the Deployment Config + +1. Log into OpenShift +2. Switch to your dev environment. + + ``` + oc project -dev + ``` + +3. Navigate to the `/openshift/templates` folder +4. Import the clamav deployment config (clamav-dc.yaml) + + ``` + oc process -f clamav-dc.conf | oc create -f - + ``` + + This will create a new DeploymentConfig (`clamav`) and Service (`clamav`). + +#### Deploy the Image + +1. Deploy the image + + ``` + OpenShift Web UI (Administrator) -> Workloads -> DeploymentConfigs -> clamav -> Actions -> Start Rollout + ``` + + This will deploy a Pod running the ClamaAV image. + +#### Repeat for the Test and Prod environments + +## Testing Files For Viruses Against ClamAV + +See NPM Package: [clamscan](https://www.npmjs.com/package/clamscan) + +When creating a new instance of clamscan, the default Host and Port of the above installation are: + +- Host: `clamav` +- Port: `3310` + +## Other + +Depending on your OpenShift setup, you may need to add a NetworkPolicy to allow dev/test/prod to see images from tools, etc. diff --git a/containers/clamav/config/clamd.conf b/containers/clamav/config/clamd.conf index 11d678cfce..85ecfa870f 100644 --- a/containers/clamav/config/clamd.conf +++ b/containers/clamav/config/clamd.conf @@ -118,7 +118,7 @@ TCPSocket 3310 # Close the connection when the data size limit is exceeded. # The value should match your MTA's limit for a maximum attachment size. # Default: 25M -StreamMaxLength 300M +StreamMaxLength 4000M # Limit port range. # Default: 1024 diff --git a/containers/clamav/config/freshclam.conf b/containers/clamav/config/freshclam.conf index 632fe74a85..161f238d1c 100644 --- a/containers/clamav/config/freshclam.conf +++ b/containers/clamav/config/freshclam.conf @@ -69,17 +69,6 @@ DatabaseOwner clamupdate # Use the ClamAV Mirror provided in OCP4 Silver cluster DatabaseMirror https://clamav-mirror.apps.silver.devops.gov.bc.ca -# Uncomment the following line and replace XY with your country -# code. See http://www.iana.org/cctld/cctld-whois.htm for the full list. -# You can use db.XY.ipv6.clamav.net for IPv6 connections. -DatabaseMirror db.ca.clamav.net - -# database.clamav.net is a round-robin record which points to our most -# reliable mirrors. It's used as a fall back in case db.XY.clamav.net is -# not working. DO NOT TOUCH the following line unless you know what you -# are doing. -DatabaseMirror database.clamav.net - # How many attempts to make before giving up. # Default: 3 (per mirror) #MaxAttempts 5 diff --git a/containers/clamav/openshift/templates/clamav-bc.yaml b/containers/clamav/openshift/templates/clamav-bc.yaml index 633bcdb60f..af9b5bc210 100644 --- a/containers/clamav/openshift/templates/clamav-bc.yaml +++ b/containers/clamav/openshift/templates/clamav-bc.yaml @@ -1,86 +1,61 @@ kind: Template apiVersion: template.openshift.io/v1 metadata: - name: clamav-build + name: clamav parameters: - name: NAME displayName: Name description: The name assigned to all of the objects defined in this template. - value: clamav required: true + value: clamav - name: GIT_SOURCE_URL displayName: GIT Source Repo URL description: A GIT URL to your source code. - value: "https://github.com/bcgov/biohubbc.git" required: true + value: https://github.com/bcgov/clamav.git - name: GIT_REF displayName: Git Reference description: The git reference or branch. - value: dev - required: true - - name: SOURCE_CONTEXT_DIR - displayName: Source Context Directory - description: The source context directory. - value: containers/clamav - - name: NAME_SPACE - displayName: Namespace for source image - value: af2668-tools required: true - - name: CPU_REQUEST - value: "100m" - - name: CPU_LIMIT - value: "1000m" - - name: MEMORY_REQUEST - value: "2G" - - name: MEMORY_LIMIT - value: "4G" + value: master objects: - kind: ImageStream - apiVersion: image.openshift.io/v1 + apiVersion: v1 metadata: name: "${NAME}" - - kind: BuildConfig - apiVersion: build.openshift.io/v1 + apiVersion: v1 metadata: name: "${NAME}-build" + creationTimestamp: labels: app: "${NAME}" spec: runPolicy: Serial completionDeadlineSeconds: 1800 + triggers: + - type: ConfigChange source: type: Git git: - ref: "${GIT_REF}" uri: "${GIT_SOURCE_URL}" - contextDir: "${SOURCE_CONTEXT_DIR}" - secrets: - - secret: - name: platform-services-controlled-etc-pki-entitlement - destinationDir: etc-pki-entitlement - configMaps: - - configMap: - name: platform-services-controlled-rhsm-conf - destinationDir: rhsm-conf - - configMap: - name: platform-services-controlled-rhsm-ca - destinationDir: rhsm-ca + ref: "${GIT_REF}" strategy: type: Docker dockerStrategy: - from: - kind: ImageStreamTag - name: "ubi:latest" - namespace: "${NAME_SPACE}" + env: + - name: CLAMAV_NO_MILTERD + value: "true" output: to: kind: ImageStreamTag name: "${NAME}:latest" resources: requests: - cpu: ${CPU_REQUEST} - memory: ${MEMORY_REQUEST} + cpu: 100m + memory: 2Gi limits: - cpu: ${CPU_LIMIT} - memory: ${MEMORY_LIMIT} + cpu: "1" + memory: 4Gi + status: + lastVersion: 0 diff --git a/containers/clamav/openshift/templates/clamav-dc.yaml b/containers/clamav/openshift/templates/clamav-dc.yaml index e0f5260544..0bf4505e4f 100644 --- a/containers/clamav/openshift/templates/clamav-dc.yaml +++ b/containers/clamav/openshift/templates/clamav-dc.yaml @@ -1,109 +1,110 @@ kind: Template -apiVersion: template.openshift.io/v1 +apiVersion: v1 metadata: - name: clamav-deploy + name: clamav parameters: - - name: NAME_SPACE - value: af2668-tools + - description: + The name assigned to all of the openshift objects defined in this template. + It is also the name of runtime image you want. + displayName: Name + name: NAME required: true - - name: CPU_REQUEST - value: "100m" - - name: CPU_LIMIT - value: "1100m" - - name: MEMORY_REQUEST - value: "500M" - - name: MEMORY_LIMIT - value: "2G" - - name: REPLICAS - value: "1" + value: clamav + - description: The namespace where to get the above image name + displayName: Image Namespace + name: IMAGE_NAMESPACE + required: true + value: biohubbc-tools + - description: The name of the role label, used to uniquely identify this deployment + displayName: Role Name + name: ROLE_NAME + value: clamav + - description: The TAG name for this environment, e.g., dev, test, prod + displayName: Env TAG name + name: TAG_NAME + value: dev objects: - - kind: Service - apiVersion: v1 + - apiVersion: apps.openshift.io/v1 + kind: DeploymentConfig metadata: - name: clamav + creationTimestamp: labels: - app: clamav + app: "${NAME}" + role: "${ROLE_NAME}" + name: "${NAME}" spec: - ports: - - name: 3310-tcp - protocol: TCP - port: 3310 - targetPort: 3310 + replicas: 1 selector: - deploymentconfig: clamav - type: ClusterIP - sessionAffinity: None - status: - loadBalancer: {} - - - kind: DeploymentConfig - apiVersion: apps.openshift.io/v1 - metadata: - name: clamav - generation: 1 - labels: - app: clamav - spec: + app: "${NAME}" + deploymentconfig: "${NAME}" strategy: + rollingParams: + intervalSeconds: 1 + maxSurge: 25% + maxUnavailable: 25% + timeoutSeconds: 600 + updatePeriodSeconds: 1 type: Rolling - activeDeadlineSeconds: 21600 - triggers: - - type: ImageChange - imageChangeParams: - automatic: true - containerNames: - - clamav - from: - kind: ImageStreamTag - namespace: "${NAME_SPACE}" - name: "clamav:latest" - - type: ConfigChange - replicas: ${{REPLICAS}} - test: false - selector: - app: clamav - deploymentconfig: clamav template: metadata: + creationTimestamp: labels: - app: clamav - deploymentconfig: clamav - annotations: - openshift.io/generated-by: OpenShiftWebConsole + app: "${NAME}" + role: "${ROLE_NAME}" + deploymentconfig: "${NAME}" spec: containers: - - resources: - requests: - cpu: ${CPU_REQUEST} - memory: ${MEMORY_REQUEST} - limits: - cpu: ${CPU_LIMIT} - memory: ${MEMORY_LIMIT} - readinessProbe: - tcpSocket: - port: 3310 - initialDelaySeconds: 240 - timeoutSeconds: 3 - periodSeconds: 10 - successThreshold: 1 - failureThreshold: 3 - terminationMessagePath: /dev/termination-log - name: clamav - livenessProbe: - tcpSocket: - port: 3310 - initialDelaySeconds: 240 - timeoutSeconds: 3 - periodSeconds: 10 - successThreshold: 1 - failureThreshold: 3 + - image: "${NAME}" + imagePullPolicy: Always + name: "${NAME}" ports: - containerPort: 3310 protocol: TCP - imagePullPolicy: Always - terminationMessagePolicy: File - restartPolicy: Always - terminationGracePeriodSeconds: 30 + env: + - name: RealIpFrom + value: "${REAL_IP_FROM}" + - name: AdditionalRealIpFromRules + value: "${AdditionalRealIpFromRules}" + - name: IpFilterRules + value: "${IpFilterRules}" + resources: + requests: + cpu: 1000m + memory: 1Gi + limits: + cpu: 2000m + memory: 2Gi dnsPolicy: ClusterFirst + restartPolicy: Always securityContext: {} - schedulerName: default-scheduler + terminationGracePeriodSeconds: 30 + test: false + triggers: + - type: ConfigChange + - type: ImageChange + imageChangeParams: + automatic: true + containerNames: + - "${NAME}" + from: + kind: ImageStreamTag + namespace: "${IMAGE_NAMESPACE}" + name: "${NAME}:${TAG_NAME}" + - apiVersion: v1 + kind: Service + metadata: + creationTimestamp: + labels: + app: "${NAME}" + name: "${NAME}" + spec: + ports: + - name: 3310-tcp + port: 3310 + protocol: TCP + targetPort: 3310 + selector: + app: "${NAME}" + deploymentconfig: "${NAME}" + sessionAffinity: None + type: ClusterIP diff --git a/database/src/migrations/20240624000000_attachment_type_fix.ts b/database/src/migrations/20240624000000_attachment_type_fix.ts new file mode 100644 index 0000000000..da8a4cf061 --- /dev/null +++ b/database/src/migrations/20240624000000_attachment_type_fix.ts @@ -0,0 +1,49 @@ +import { Knex } from 'knex'; + +/** + * Drop deprecated study_species.wldtaxonomic_units_id column. + * Drop deprecated survey.field_method_id column. + * + * Migrate outdated project_attachment.file_type and survey_attachment.file_type values: + * - Deprecated values 'Spatial Data' and 'Data File' are now 'Other'. + * + * @export + * @param {Knex} knex + * @return {*} {Promise} + */ +export async function up(knex: Knex): Promise { + await knex.raw(`--sql + ---------------------------------------------------------------------------------------- + -- Drop views + ---------------------------------------------------------------------------------------- + SET SEARCH_PATH=biohub_dapi_v1; + + DROP VIEW IF EXISTS study_species; + DROP VIEW IF EXISTS survey; + + ---------------------------------------------------------------------------------------- + -- Alter tables/data + ---------------------------------------------------------------------------------------- + SET SEARCH_PATH=biohub; + + -- Drop deprecated columns + ALTER TABLE study_species DROP COLUMN wldtaxonomic_units_id; + ALTER TABLE survey DROP COLUMN field_method_id; + + -- Migrate deprecated file_type values + UPDATE project_attachment SET file_type = 'Other' WHERE file_type NOT IN ('Other', 'KeyX', 'Report'); + UPDATE survey_attachment SET file_type = 'Other' WHERE file_type NOT IN ('Other', 'KeyX', 'Report'); + + ---------------------------------------------------------------------------------------- + -- Update views + ---------------------------------------------------------------------------------------- + SET SEARCH_PATH=biohub_dapi_v1; + + CREATE OR REPLACE VIEW study_species as SELECT * FROM biohub.study_species; + CREATE OR REPLACE VIEW survey as SELECT * FROM biohub.survey; + `); +} + +export async function down(knex: Knex): Promise { + await knex.raw(``); +} diff --git a/database/src/seeds/03_basic_project_survey_setup.ts b/database/src/seeds/03_basic_project_survey_setup.ts index ea98085d57..8756f40285 100644 --- a/database/src/seeds/03_basic_project_survey_setup.ts +++ b/database/src/seeds/03_basic_project_survey_setup.ts @@ -254,21 +254,16 @@ const insertSurveyFundingData = (surveyId: number) => ` */ const insertSurveyFocalSpeciesData = (surveyId: number) => { const focalSpecies = focalTaxonIdOptions[Math.floor(Math.random() * focalTaxonIdOptions.length)]; - const testValue = [ - 2012, 2013, 828, 2019, 1594, 1718, 2037, 2062, 2068, 2065, 2070, 2069, 23918, 23922, 23920, 35369, 35370, 28516 - ][Math.floor(Math.random() * 18)]; return ` INSERT into study_species ( survey_id, - wldtaxonomic_units_id, itis_tsn, is_focal ) VALUES ( ${surveyId}, - ${testValue}, ${focalSpecies.itis_tsn}, 'Y' ); @@ -632,15 +627,10 @@ const insertObservationSubCount = (surveyObservationId: number) => ` * */ const insertSurveyObservationData = (surveyId: number, count: number) => { - const testValue = [ - 2012, 2013, 828, 2019, 1594, 1718, 2037, 2062, 2068, 2065, 2070, 2069, 23918, 23922, 23920, 35369, 35370, 28516 - ][Math.floor(Math.random() * 18)]; - return ` INSERT INTO survey_observation ( survey_id, - wldtaxonomic_units_id, itis_tsn, itis_scientific_name, latitude, @@ -655,7 +645,6 @@ const insertSurveyObservationData = (surveyId: number, count: number) => { VALUES ( ${surveyId}, - ${testValue}, $$${focalTaxonIdOptions[0].itis_tsn}$$, $$${focalTaxonIdOptions[0].itis_scientific_name}$$, $$${faker.number.int({ min: 48, max: 60 })}$$, From ce03ca44c598ae5441b9f213d72a01214a7d9ee8 Mon Sep 17 00:00:00 2001 From: Nick Phura Date: Tue, 25 Jun 2024 16:18:41 -0700 Subject: [PATCH 25/31] BugFix: Fix migration to drop missed instance of wldtaxonomic_units_id column (#1314) * Fix migration to drop missed instance of wldtaxonomic_units_id column * Disable PR builds when merging Dev to Test --- .github/workflows/deploy.yml | 1 + .../20240624000000_attachment_type_fix.ts | 23 --------- .../20240625000000_deprecated_columns.ts | 50 +++++++++++++++++++ 3 files changed, 51 insertions(+), 23 deletions(-) create mode 100644 database/src/migrations/20240625000000_deprecated_columns.ts diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index a30014517b..8637ad769a 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -6,6 +6,7 @@ on: pull_request: types: [opened, reopened, synchronize, ready_for_review] branches-ignore: + - test - prod concurrency: diff --git a/database/src/migrations/20240624000000_attachment_type_fix.ts b/database/src/migrations/20240624000000_attachment_type_fix.ts index da8a4cf061..09e64e1a99 100644 --- a/database/src/migrations/20240624000000_attachment_type_fix.ts +++ b/database/src/migrations/20240624000000_attachment_type_fix.ts @@ -1,9 +1,6 @@ import { Knex } from 'knex'; /** - * Drop deprecated study_species.wldtaxonomic_units_id column. - * Drop deprecated survey.field_method_id column. - * * Migrate outdated project_attachment.file_type and survey_attachment.file_type values: * - Deprecated values 'Spatial Data' and 'Data File' are now 'Other'. * @@ -13,34 +10,14 @@ import { Knex } from 'knex'; */ export async function up(knex: Knex): Promise { await knex.raw(`--sql - ---------------------------------------------------------------------------------------- - -- Drop views - ---------------------------------------------------------------------------------------- - SET SEARCH_PATH=biohub_dapi_v1; - - DROP VIEW IF EXISTS study_species; - DROP VIEW IF EXISTS survey; - ---------------------------------------------------------------------------------------- -- Alter tables/data ---------------------------------------------------------------------------------------- SET SEARCH_PATH=biohub; - -- Drop deprecated columns - ALTER TABLE study_species DROP COLUMN wldtaxonomic_units_id; - ALTER TABLE survey DROP COLUMN field_method_id; - -- Migrate deprecated file_type values UPDATE project_attachment SET file_type = 'Other' WHERE file_type NOT IN ('Other', 'KeyX', 'Report'); UPDATE survey_attachment SET file_type = 'Other' WHERE file_type NOT IN ('Other', 'KeyX', 'Report'); - - ---------------------------------------------------------------------------------------- - -- Update views - ---------------------------------------------------------------------------------------- - SET SEARCH_PATH=biohub_dapi_v1; - - CREATE OR REPLACE VIEW study_species as SELECT * FROM biohub.study_species; - CREATE OR REPLACE VIEW survey as SELECT * FROM biohub.survey; `); } diff --git a/database/src/migrations/20240625000000_deprecated_columns.ts b/database/src/migrations/20240625000000_deprecated_columns.ts new file mode 100644 index 0000000000..bf54642a9d --- /dev/null +++ b/database/src/migrations/20240625000000_deprecated_columns.ts @@ -0,0 +1,50 @@ +import { Knex } from 'knex'; + +/** + * Drop deprecated study_species.wldtaxonomic_units_id column. + * Drop deprecated survey_observation.wldtaxonomic_units_id column. + * Drop deprecated survey.field_method_id column. + * + * @export + * @param {Knex} knex + * @return {*} {Promise} + */ +export async function up(knex: Knex): Promise { + await knex.raw(`--sql + ---------------------------------------------------------------------------------------- + -- Drop views + ---------------------------------------------------------------------------------------- + SET SEARCH_PATH=biohub_dapi_v1; + + DROP VIEW IF EXISTS study_species; + DROP VIEW IF EXISTS survey_observation; + + DROP VIEW IF EXISTS survey; + + ---------------------------------------------------------------------------------------- + -- Alter tables/data + ---------------------------------------------------------------------------------------- + SET SEARCH_PATH=biohub; + + -- Drop deprecated wldtaxonomic_units_id column + ALTER TABLE study_species DROP COLUMN IF EXISTS wldtaxonomic_units_id; + ALTER TABLE survey_observation DROP COLUMN IF EXISTS wldtaxonomic_units_id; + + -- Drop deprecated field_method_id column + ALTER TABLE survey DROP COLUMN IF EXISTS field_method_id; + + ---------------------------------------------------------------------------------------- + -- Update views + ---------------------------------------------------------------------------------------- + SET SEARCH_PATH=biohub_dapi_v1; + + CREATE OR REPLACE VIEW study_species as SELECT * FROM biohub.study_species; + CREATE OR REPLACE VIEW survey_observation as SELECT * FROM biohub.survey_observation; + + CREATE OR REPLACE VIEW survey as SELECT * FROM biohub.survey; + `); +} + +export async function down(knex: Knex): Promise { + await knex.raw(``); +} From 9113f6a096e3ad66715d98d675f850101a7ea307 Mon Sep 17 00:00:00 2001 From: Nick Phura Date: Fri, 28 Jun 2024 09:15:54 -0700 Subject: [PATCH 26/31] TechDebt: AWS V3 Update (#1304) * AWS V3 Update * Remove unused attachment resubmit code * WIP: Fix unit tests * Fix tests --------- Co-authored-by: Macgregor Aubertin-Young <108430771+mauberti-bc@users.noreply.github.com> --- api/package-lock.json | 2809 ++++++++++++----- api/package.json | 3 +- .../survey/{surveyId}/delete.test.ts | 8 +- api/src/paths/resources/list.test.ts | 23 +- api/src/paths/resources/list.ts | 5 +- api/src/services/attachment-service.test.ts | 85 +- api/src/services/observation-service.ts | 2 +- api/src/services/platform-service.ts | 10 +- api/src/services/telemetry-service.ts | 2 +- api/src/utils/file-utils.test.ts | 34 +- api/src/utils/file-utils.ts | 137 +- api/src/utils/media/media-utils.test.ts | 62 +- api/src/utils/media/media-utils.ts | 40 +- .../attachments/list/AttachmentsList.test.tsx | 5 - .../attachments/list/AttachmentsList.tsx | 4 +- .../list/AttachmentsListItemMenuButton.tsx | 48 +- .../components/RemoveOrResubmitDialog.tsx | 134 - .../components/RemoveOrResubmitForm.tsx | 92 - .../projects/view/ProjectAttachmentsList.tsx | 23 +- .../features/projects/view/ProjectPage.tsx | 2 - .../surveys/view/SurveyAttachmentsList.tsx | 22 - app/src/features/surveys/view/SurveyPage.tsx | 2 - app/src/hooks/api/usePublishApi.ts | 35 +- 23 files changed, 2351 insertions(+), 1236 deletions(-) delete mode 100644 app/src/components/publish/components/RemoveOrResubmitDialog.tsx delete mode 100644 app/src/components/publish/components/RemoveOrResubmitForm.tsx diff --git a/api/package-lock.json b/api/package-lock.json index 7a33b1d415..bd71e90473 100644 --- a/api/package-lock.json +++ b/api/package-lock.json @@ -9,13 +9,14 @@ "version": "0.0.0", "license": "Apache-2.0", "dependencies": { + "@aws-sdk/client-s3": "^3.583.0", + "@aws-sdk/s3-request-presigner": "^3.583.0", "@turf/bbox": "^6.5.0", "@turf/circle": "^6.5.0", "@turf/helpers": "^6.5.0", "@turf/meta": "^6.5.0", "adm-zip": "^0.5.5", "ajv": "^8.12.0", - "aws-sdk": "^2.1565.0", "axios": "^1.6.7", "clamscan": "^2.2.1", "dayjs": "^1.11.10", @@ -113,859 +114,2365 @@ "node": ">=6.0.0" } }, + "node_modules/@aws-crypto/crc32": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-3.0.0.tgz", + "integrity": "sha512-IzSgsrxUcsrejQbPVilIKy16kAT52EwB6zSaI+M3xxIhKh5+aldEyvI+z6erM7TCLB2BJsFrtHjp6/4/sr+3dA==", + "dependencies": { + "@aws-crypto/util": "^3.0.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^1.11.1" + } + }, + "node_modules/@aws-crypto/crc32/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" + }, + "node_modules/@aws-crypto/crc32c": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/crc32c/-/crc32c-3.0.0.tgz", + "integrity": "sha512-ENNPPManmnVJ4BTXlOjAgD7URidbAznURqD0KvfREyc4o20DPYdEldU1f5cQ7Jbj0CJJSPaMIk/9ZshdB3210w==", + "dependencies": { + "@aws-crypto/util": "^3.0.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^1.11.1" + } + }, + "node_modules/@aws-crypto/crc32c/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" + }, + "node_modules/@aws-crypto/ie11-detection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/ie11-detection/-/ie11-detection-3.0.0.tgz", + "integrity": "sha512-341lBBkiY1DfDNKai/wXM3aujNBkXR7tq1URPQDL9wi3AUbI80NR74uF1TXHMm7po1AcnFk8iu2S2IeU/+/A+Q==", + "dependencies": { + "tslib": "^1.11.1" + } + }, + "node_modules/@aws-crypto/ie11-detection/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" + }, + "node_modules/@aws-crypto/sha1-browser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha1-browser/-/sha1-browser-3.0.0.tgz", + "integrity": "sha512-NJth5c997GLHs6nOYTzFKTbYdMNA6/1XlKVgnZoaZcQ7z7UJlOgj2JdbHE8tiYLS3fzXNCguct77SPGat2raSw==", + "dependencies": { + "@aws-crypto/ie11-detection": "^3.0.0", + "@aws-crypto/supports-web-crypto": "^3.0.0", + "@aws-crypto/util": "^3.0.0", + "@aws-sdk/types": "^3.222.0", + "@aws-sdk/util-locate-window": "^3.0.0", + "@aws-sdk/util-utf8-browser": "^3.0.0", + "tslib": "^1.11.1" + } + }, + "node_modules/@aws-crypto/sha1-browser/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" + }, + "node_modules/@aws-crypto/sha256-browser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-3.0.0.tgz", + "integrity": "sha512-8VLmW2B+gjFbU5uMeqtQM6Nj0/F1bro80xQXCW6CQBWgosFWXTx77aeOF5CAIAmbOK64SdMBJdNr6J41yP5mvQ==", + "dependencies": { + "@aws-crypto/ie11-detection": "^3.0.0", + "@aws-crypto/sha256-js": "^3.0.0", + "@aws-crypto/supports-web-crypto": "^3.0.0", + "@aws-crypto/util": "^3.0.0", + "@aws-sdk/types": "^3.222.0", + "@aws-sdk/util-locate-window": "^3.0.0", + "@aws-sdk/util-utf8-browser": "^3.0.0", + "tslib": "^1.11.1" + } + }, + "node_modules/@aws-crypto/sha256-browser/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" + }, + "node_modules/@aws-crypto/sha256-js": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-3.0.0.tgz", + "integrity": "sha512-PnNN7os0+yd1XvXAy23CFOmTbMaDxgxXtTKHybrJ39Y8kGzBATgBFibWJKH6BhytLI/Zyszs87xCOBNyBig6vQ==", + "dependencies": { + "@aws-crypto/util": "^3.0.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^1.11.1" + } + }, + "node_modules/@aws-crypto/sha256-js/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" + }, + "node_modules/@aws-crypto/supports-web-crypto": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-3.0.0.tgz", + "integrity": "sha512-06hBdMwUAb2WFTuGG73LSC0wfPu93xWwo5vL2et9eymgmu3Id5vFAHBbajVWiGhPO37qcsdCap/FqXvJGJWPIg==", + "dependencies": { + "tslib": "^1.11.1" + } + }, + "node_modules/@aws-crypto/supports-web-crypto/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" + }, + "node_modules/@aws-crypto/util": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-3.0.0.tgz", + "integrity": "sha512-2OJlpeJpCR48CC8r+uKVChzs9Iungj9wkZrl8Z041DWEWvyIHILYKCPNzJghKsivj+S3mLo6BVc7mBNzdxA46w==", + "dependencies": { + "@aws-sdk/types": "^3.222.0", + "@aws-sdk/util-utf8-browser": "^3.0.0", + "tslib": "^1.11.1" + } + }, + "node_modules/@aws-crypto/util/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" + }, + "node_modules/@aws-sdk/client-s3": { + "version": "3.590.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.590.0.tgz", + "integrity": "sha512-so+pNua0ihsHaSdskw8HCwruoYTAfYSEs3ix4GD1++83C96KaJp3udAutYiCA+84JXg9zitFa7eK7ORJAVZmTw==", + "dependencies": { + "@aws-crypto/sha1-browser": "3.0.0", + "@aws-crypto/sha256-browser": "3.0.0", + "@aws-crypto/sha256-js": "3.0.0", + "@aws-sdk/client-sso-oidc": "3.590.0", + "@aws-sdk/client-sts": "3.590.0", + "@aws-sdk/core": "3.588.0", + "@aws-sdk/credential-provider-node": "3.590.0", + "@aws-sdk/middleware-bucket-endpoint": "3.587.0", + "@aws-sdk/middleware-expect-continue": "3.577.0", + "@aws-sdk/middleware-flexible-checksums": "3.587.0", + "@aws-sdk/middleware-host-header": "3.577.0", + "@aws-sdk/middleware-location-constraint": "3.577.0", + "@aws-sdk/middleware-logger": "3.577.0", + "@aws-sdk/middleware-recursion-detection": "3.577.0", + "@aws-sdk/middleware-sdk-s3": "3.587.0", + "@aws-sdk/middleware-signing": "3.587.0", + "@aws-sdk/middleware-ssec": "3.577.0", + "@aws-sdk/middleware-user-agent": "3.587.0", + "@aws-sdk/region-config-resolver": "3.587.0", + "@aws-sdk/signature-v4-multi-region": "3.587.0", + "@aws-sdk/types": "3.577.0", + "@aws-sdk/util-endpoints": "3.587.0", + "@aws-sdk/util-user-agent-browser": "3.577.0", + "@aws-sdk/util-user-agent-node": "3.587.0", + "@aws-sdk/xml-builder": "3.575.0", + "@smithy/config-resolver": "^3.0.1", + "@smithy/core": "^2.1.1", + "@smithy/eventstream-serde-browser": "^3.0.0", + "@smithy/eventstream-serde-config-resolver": "^3.0.0", + "@smithy/eventstream-serde-node": "^3.0.0", + "@smithy/fetch-http-handler": "^3.0.1", + "@smithy/hash-blob-browser": "^3.0.0", + "@smithy/hash-node": "^3.0.0", + "@smithy/hash-stream-node": "^3.0.0", + "@smithy/invalid-dependency": "^3.0.0", + "@smithy/md5-js": "^3.0.0", + "@smithy/middleware-content-length": "^3.0.0", + "@smithy/middleware-endpoint": "^3.0.1", + "@smithy/middleware-retry": "^3.0.3", + "@smithy/middleware-serde": "^3.0.0", + "@smithy/middleware-stack": "^3.0.0", + "@smithy/node-config-provider": "^3.1.0", + "@smithy/node-http-handler": "^3.0.0", + "@smithy/protocol-http": "^4.0.0", + "@smithy/smithy-client": "^3.1.1", + "@smithy/types": "^3.0.0", + "@smithy/url-parser": "^3.0.0", + "@smithy/util-base64": "^3.0.0", + "@smithy/util-body-length-browser": "^3.0.0", + "@smithy/util-body-length-node": "^3.0.0", + "@smithy/util-defaults-mode-browser": "^3.0.3", + "@smithy/util-defaults-mode-node": "^3.0.3", + "@smithy/util-endpoints": "^2.0.1", + "@smithy/util-retry": "^3.0.0", + "@smithy/util-stream": "^3.0.1", + "@smithy/util-utf8": "^3.0.0", + "@smithy/util-waiter": "^3.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/client-sso": { + "version": "3.590.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso/-/client-sso-3.590.0.tgz", + "integrity": "sha512-6xbC6oQVJKBRTyXyR3C15ksUsPOyW4p+uCj7dlKYWGJvh4vGTV8KhZKS53oPG8t4f1+OMJWjr5wKuXRoaFsmhQ==", + "dependencies": { + "@aws-crypto/sha256-browser": "3.0.0", + "@aws-crypto/sha256-js": "3.0.0", + "@aws-sdk/core": "3.588.0", + "@aws-sdk/middleware-host-header": "3.577.0", + "@aws-sdk/middleware-logger": "3.577.0", + "@aws-sdk/middleware-recursion-detection": "3.577.0", + "@aws-sdk/middleware-user-agent": "3.587.0", + "@aws-sdk/region-config-resolver": "3.587.0", + "@aws-sdk/types": "3.577.0", + "@aws-sdk/util-endpoints": "3.587.0", + "@aws-sdk/util-user-agent-browser": "3.577.0", + "@aws-sdk/util-user-agent-node": "3.587.0", + "@smithy/config-resolver": "^3.0.1", + "@smithy/core": "^2.1.1", + "@smithy/fetch-http-handler": "^3.0.1", + "@smithy/hash-node": "^3.0.0", + "@smithy/invalid-dependency": "^3.0.0", + "@smithy/middleware-content-length": "^3.0.0", + "@smithy/middleware-endpoint": "^3.0.1", + "@smithy/middleware-retry": "^3.0.3", + "@smithy/middleware-serde": "^3.0.0", + "@smithy/middleware-stack": "^3.0.0", + "@smithy/node-config-provider": "^3.1.0", + "@smithy/node-http-handler": "^3.0.0", + "@smithy/protocol-http": "^4.0.0", + "@smithy/smithy-client": "^3.1.1", + "@smithy/types": "^3.0.0", + "@smithy/url-parser": "^3.0.0", + "@smithy/util-base64": "^3.0.0", + "@smithy/util-body-length-browser": "^3.0.0", + "@smithy/util-body-length-node": "^3.0.0", + "@smithy/util-defaults-mode-browser": "^3.0.3", + "@smithy/util-defaults-mode-node": "^3.0.3", + "@smithy/util-endpoints": "^2.0.1", + "@smithy/util-middleware": "^3.0.0", + "@smithy/util-retry": "^3.0.0", + "@smithy/util-utf8": "^3.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/client-sso-oidc": { + "version": "3.590.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso-oidc/-/client-sso-oidc-3.590.0.tgz", + "integrity": "sha512-3yCLPjq6WFfDpdUJKk/gSz4eAPDTjVknXaveMPi2QoVBCshneOnJsV16uNKlpVF1frTHrrDRfKYmbaVh6nFBvQ==", + "dependencies": { + "@aws-crypto/sha256-browser": "3.0.0", + "@aws-crypto/sha256-js": "3.0.0", + "@aws-sdk/client-sts": "3.590.0", + "@aws-sdk/core": "3.588.0", + "@aws-sdk/credential-provider-node": "3.590.0", + "@aws-sdk/middleware-host-header": "3.577.0", + "@aws-sdk/middleware-logger": "3.577.0", + "@aws-sdk/middleware-recursion-detection": "3.577.0", + "@aws-sdk/middleware-user-agent": "3.587.0", + "@aws-sdk/region-config-resolver": "3.587.0", + "@aws-sdk/types": "3.577.0", + "@aws-sdk/util-endpoints": "3.587.0", + "@aws-sdk/util-user-agent-browser": "3.577.0", + "@aws-sdk/util-user-agent-node": "3.587.0", + "@smithy/config-resolver": "^3.0.1", + "@smithy/core": "^2.1.1", + "@smithy/fetch-http-handler": "^3.0.1", + "@smithy/hash-node": "^3.0.0", + "@smithy/invalid-dependency": "^3.0.0", + "@smithy/middleware-content-length": "^3.0.0", + "@smithy/middleware-endpoint": "^3.0.1", + "@smithy/middleware-retry": "^3.0.3", + "@smithy/middleware-serde": "^3.0.0", + "@smithy/middleware-stack": "^3.0.0", + "@smithy/node-config-provider": "^3.1.0", + "@smithy/node-http-handler": "^3.0.0", + "@smithy/protocol-http": "^4.0.0", + "@smithy/smithy-client": "^3.1.1", + "@smithy/types": "^3.0.0", + "@smithy/url-parser": "^3.0.0", + "@smithy/util-base64": "^3.0.0", + "@smithy/util-body-length-browser": "^3.0.0", + "@smithy/util-body-length-node": "^3.0.0", + "@smithy/util-defaults-mode-browser": "^3.0.3", + "@smithy/util-defaults-mode-node": "^3.0.3", + "@smithy/util-endpoints": "^2.0.1", + "@smithy/util-middleware": "^3.0.0", + "@smithy/util-retry": "^3.0.0", + "@smithy/util-utf8": "^3.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/client-sts": { + "version": "3.590.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sts/-/client-sts-3.590.0.tgz", + "integrity": "sha512-f4R1v1LSn4uLYZ5qj4DyL6gp7PXXzJeJsm2seheiJX+53LSF5L7XSDnQVtX1p9Tevv0hp2YUWUTg6QYwIVSuGg==", + "dependencies": { + "@aws-crypto/sha256-browser": "3.0.0", + "@aws-crypto/sha256-js": "3.0.0", + "@aws-sdk/client-sso-oidc": "3.590.0", + "@aws-sdk/core": "3.588.0", + "@aws-sdk/credential-provider-node": "3.590.0", + "@aws-sdk/middleware-host-header": "3.577.0", + "@aws-sdk/middleware-logger": "3.577.0", + "@aws-sdk/middleware-recursion-detection": "3.577.0", + "@aws-sdk/middleware-user-agent": "3.587.0", + "@aws-sdk/region-config-resolver": "3.587.0", + "@aws-sdk/types": "3.577.0", + "@aws-sdk/util-endpoints": "3.587.0", + "@aws-sdk/util-user-agent-browser": "3.577.0", + "@aws-sdk/util-user-agent-node": "3.587.0", + "@smithy/config-resolver": "^3.0.1", + "@smithy/core": "^2.1.1", + "@smithy/fetch-http-handler": "^3.0.1", + "@smithy/hash-node": "^3.0.0", + "@smithy/invalid-dependency": "^3.0.0", + "@smithy/middleware-content-length": "^3.0.0", + "@smithy/middleware-endpoint": "^3.0.1", + "@smithy/middleware-retry": "^3.0.3", + "@smithy/middleware-serde": "^3.0.0", + "@smithy/middleware-stack": "^3.0.0", + "@smithy/node-config-provider": "^3.1.0", + "@smithy/node-http-handler": "^3.0.0", + "@smithy/protocol-http": "^4.0.0", + "@smithy/smithy-client": "^3.1.1", + "@smithy/types": "^3.0.0", + "@smithy/url-parser": "^3.0.0", + "@smithy/util-base64": "^3.0.0", + "@smithy/util-body-length-browser": "^3.0.0", + "@smithy/util-body-length-node": "^3.0.0", + "@smithy/util-defaults-mode-browser": "^3.0.3", + "@smithy/util-defaults-mode-node": "^3.0.3", + "@smithy/util-endpoints": "^2.0.1", + "@smithy/util-middleware": "^3.0.0", + "@smithy/util-retry": "^3.0.0", + "@smithy/util-utf8": "^3.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/core": { + "version": "3.588.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.588.0.tgz", + "integrity": "sha512-O1c2+9ce46Z+iiid+W3iC1IvPbfIo5ev9CBi54GdNB9SaI8/3+f8MJcux0D6c9toCF0ArMersN/gp8ek57e9uQ==", + "dependencies": { + "@smithy/core": "^2.1.1", + "@smithy/protocol-http": "^4.0.0", + "@smithy/signature-v4": "^3.0.0", + "@smithy/smithy-client": "^3.1.1", + "@smithy/types": "^3.0.0", + "fast-xml-parser": "4.2.5", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/core/node_modules/fast-xml-parser": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.2.5.tgz", + "integrity": "sha512-B9/wizE4WngqQftFPmdaMYlXoJlJOYxGQOanC77fq9k8+Z0v5dDSVh+3glErdIROP//s/jgb7ZuxKfB8nVyo0g==", + "funding": [ + { + "type": "paypal", + "url": "https://paypal.me/naturalintelligence" + }, + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "dependencies": { + "strnum": "^1.0.5" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, + "node_modules/@aws-sdk/credential-provider-env": { + "version": "3.587.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.587.0.tgz", + "integrity": "sha512-Hyg/5KFECIk2k5o8wnVEiniV86yVkhn5kzITUydmNGCkXdBFHMHRx6hleQ1bqwJHbBskyu8nbYamzcwymmGwmw==", + "dependencies": { + "@aws-sdk/types": "3.577.0", + "@smithy/property-provider": "^3.1.0", + "@smithy/types": "^3.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http": { + "version": "3.587.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.587.0.tgz", + "integrity": "sha512-Su1SRWVRCuR1e32oxX3C1V4c5hpPN20WYcRfdcr2wXwHqSvys5DrnmuCC+JoEnS/zt3adUJhPliTqpfKgSdMrA==", + "dependencies": { + "@aws-sdk/types": "3.577.0", + "@smithy/fetch-http-handler": "^3.0.1", + "@smithy/node-http-handler": "^3.0.0", + "@smithy/property-provider": "^3.1.0", + "@smithy/protocol-http": "^4.0.0", + "@smithy/smithy-client": "^3.1.1", + "@smithy/types": "^3.0.0", + "@smithy/util-stream": "^3.0.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.590.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.590.0.tgz", + "integrity": "sha512-Y5cFciAK38VIvRgZeND7HvFNR32thGtQb8Xop6cMn33FC78uwcRIu9Hc9699XTclCZqz4+Xl1WU+dZ+rnFn2AA==", + "dependencies": { + "@aws-sdk/credential-provider-env": "3.587.0", + "@aws-sdk/credential-provider-http": "3.587.0", + "@aws-sdk/credential-provider-process": "3.587.0", + "@aws-sdk/credential-provider-sso": "3.590.0", + "@aws-sdk/credential-provider-web-identity": "3.587.0", + "@aws-sdk/types": "3.577.0", + "@smithy/credential-provider-imds": "^3.1.0", + "@smithy/property-provider": "^3.1.0", + "@smithy/shared-ini-file-loader": "^3.1.0", + "@smithy/types": "^3.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "@aws-sdk/client-sts": "^3.590.0" + } + }, + "node_modules/@aws-sdk/credential-provider-node": { + "version": "3.590.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.590.0.tgz", + "integrity": "sha512-Ky38mNFoXobGrDQ11P3dU1e+q1nRJ7eZl8l15KUpvZCe/hOudbxQi/epQrCazD/gRYV2fTyczdLlZzB5ZZ8DhQ==", + "dependencies": { + "@aws-sdk/credential-provider-env": "3.587.0", + "@aws-sdk/credential-provider-http": "3.587.0", + "@aws-sdk/credential-provider-ini": "3.590.0", + "@aws-sdk/credential-provider-process": "3.587.0", + "@aws-sdk/credential-provider-sso": "3.590.0", + "@aws-sdk/credential-provider-web-identity": "3.587.0", + "@aws-sdk/types": "3.577.0", + "@smithy/credential-provider-imds": "^3.1.0", + "@smithy/property-provider": "^3.1.0", + "@smithy/shared-ini-file-loader": "^3.1.0", + "@smithy/types": "^3.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-process": { + "version": "3.587.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.587.0.tgz", + "integrity": "sha512-V4xT3iCqkF8uL6QC4gqBJg/2asd/damswP1h9HCfqTllmPWzImS+8WD3VjgTLw5b0KbTy+ZdUhKc0wDnyzkzxg==", + "dependencies": { + "@aws-sdk/types": "3.577.0", + "@smithy/property-provider": "^3.1.0", + "@smithy/shared-ini-file-loader": "^3.1.0", + "@smithy/types": "^3.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.590.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.590.0.tgz", + "integrity": "sha512-v+0j/I+je9okfwXsgmLppmwIE+TuMp5WqLz7r7PHz9KjzLyKaKTDvfllFD+8oPpBqnmOWiJ9qTGPkrfhB7a/fQ==", + "dependencies": { + "@aws-sdk/client-sso": "3.590.0", + "@aws-sdk/token-providers": "3.587.0", + "@aws-sdk/types": "3.577.0", + "@smithy/property-provider": "^3.1.0", + "@smithy/shared-ini-file-loader": "^3.1.0", + "@smithy/types": "^3.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.587.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.587.0.tgz", + "integrity": "sha512-XqIx/I2PG7kyuw3WjAP9wKlxy8IvFJwB8asOFT1xPFoVfZYKIogjG9oLP5YiRtfvDkWIztHmg5MlVv3HdJDGRw==", + "dependencies": { + "@aws-sdk/types": "3.577.0", + "@smithy/property-provider": "^3.1.0", + "@smithy/types": "^3.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "@aws-sdk/client-sts": "^3.587.0" + } + }, + "node_modules/@aws-sdk/middleware-bucket-endpoint": { + "version": "3.587.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-bucket-endpoint/-/middleware-bucket-endpoint-3.587.0.tgz", + "integrity": "sha512-HkFXLPl8pr6BH/Q0JpOESqEKL0ZK3sk7aSZ1S6GE4RXET7H5R94THULXqQFZzD48gZcyFooO/yNKZTqrZFaWKg==", + "dependencies": { + "@aws-sdk/types": "3.577.0", + "@aws-sdk/util-arn-parser": "3.568.0", + "@smithy/node-config-provider": "^3.1.0", + "@smithy/protocol-http": "^4.0.0", + "@smithy/types": "^3.0.0", + "@smithy/util-config-provider": "^3.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/middleware-expect-continue": { + "version": "3.577.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-expect-continue/-/middleware-expect-continue-3.577.0.tgz", + "integrity": "sha512-6dPp8Tv4F0of4un5IAyG6q++GrRrNQQ4P2NAMB1W0VO4JoEu1C8GievbbDLi88TFIFmtKpnHB0ODCzwnoe8JsA==", + "dependencies": { + "@aws-sdk/types": "3.577.0", + "@smithy/protocol-http": "^4.0.0", + "@smithy/types": "^3.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/middleware-flexible-checksums": { + "version": "3.587.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-flexible-checksums/-/middleware-flexible-checksums-3.587.0.tgz", + "integrity": "sha512-URMwp/budDvKhIvZ4a6zIBfFTun/iDlPWXqsGKYjEtHt8jz27OSjCZtDtIeqW4WTBdKL8KZgQcl+DdaE5M1qiQ==", + "dependencies": { + "@aws-crypto/crc32": "3.0.0", + "@aws-crypto/crc32c": "3.0.0", + "@aws-sdk/types": "3.577.0", + "@smithy/is-array-buffer": "^3.0.0", + "@smithy/protocol-http": "^4.0.0", + "@smithy/types": "^3.0.0", + "@smithy/util-utf8": "^3.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/middleware-host-header": { + "version": "3.577.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.577.0.tgz", + "integrity": "sha512-9ca5MJz455CODIVXs0/sWmJm7t3QO4EUa1zf8pE8grLpzf0J94bz/skDWm37Pli13T3WaAQBHCTiH2gUVfCsWg==", + "dependencies": { + "@aws-sdk/types": "3.577.0", + "@smithy/protocol-http": "^4.0.0", + "@smithy/types": "^3.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/middleware-location-constraint": { + "version": "3.577.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-location-constraint/-/middleware-location-constraint-3.577.0.tgz", + "integrity": "sha512-DKPTD2D2s+t2QUo/IXYtVa/6Un8GZ+phSTBkyBNx2kfZz4Kwavhl/JJzSqTV3GfCXkVdFu7CrjoX7BZ6qWeTUA==", + "dependencies": { + "@aws-sdk/types": "3.577.0", + "@smithy/types": "^3.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/middleware-logger": { + "version": "3.577.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.577.0.tgz", + "integrity": "sha512-aPFGpGjTZcJYk+24bg7jT4XdIp42mFXSuPt49lw5KygefLyJM/sB0bKKqPYYivW0rcuZ9brQ58eZUNthrzYAvg==", + "dependencies": { + "@aws-sdk/types": "3.577.0", + "@smithy/types": "^3.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/middleware-recursion-detection": { + "version": "3.577.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.577.0.tgz", + "integrity": "sha512-pn3ZVEd2iobKJlR3H+bDilHjgRnNrQ6HMmK9ZzZw89Ckn3Dcbv48xOv4RJvu0aU8SDLl/SNCxppKjeLDTPGBNA==", + "dependencies": { + "@aws-sdk/types": "3.577.0", + "@smithy/protocol-http": "^4.0.0", + "@smithy/types": "^3.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/middleware-sdk-s3": { + "version": "3.587.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.587.0.tgz", + "integrity": "sha512-vtXTGEiw1E9Fax4LmcU2Z208gbrC8ShrdsSLmGcRPpu5NPOGBFBSDG5sy5EDNClrFxIl/Le8coQnD0EDBtx+uQ==", + "dependencies": { + "@aws-sdk/types": "3.577.0", + "@aws-sdk/util-arn-parser": "3.568.0", + "@smithy/node-config-provider": "^3.1.0", + "@smithy/protocol-http": "^4.0.0", + "@smithy/signature-v4": "^3.0.0", + "@smithy/smithy-client": "^3.1.1", + "@smithy/types": "^3.0.0", + "@smithy/util-config-provider": "^3.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/middleware-signing": { + "version": "3.587.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-signing/-/middleware-signing-3.587.0.tgz", + "integrity": "sha512-tiZaTDj4RvhXGRAlncFn7CSEfL3iNPO67WSaxAq+Ls5j1VgczPhu5262cWONNoMgth3nXR1hhLC4ITSl/a6AzA==", + "dependencies": { + "@aws-sdk/types": "3.577.0", + "@smithy/property-provider": "^3.1.0", + "@smithy/protocol-http": "^4.0.0", + "@smithy/signature-v4": "^3.0.0", + "@smithy/types": "^3.0.0", + "@smithy/util-middleware": "^3.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/middleware-ssec": { + "version": "3.577.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-ssec/-/middleware-ssec-3.577.0.tgz", + "integrity": "sha512-i2BPJR+rp8xmRVIGc0h1kDRFcM2J9GnClqqpc+NLSjmYadlcg4mPklisz9HzwFVcRPJ5XcGf3U4BYs5G8+iTyg==", + "dependencies": { + "@aws-sdk/types": "3.577.0", + "@smithy/types": "^3.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/middleware-user-agent": { + "version": "3.587.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.587.0.tgz", + "integrity": "sha512-SyDomN+IOrygLucziG7/nOHkjUXES5oH5T7p8AboO8oakMQJdnudNXiYWTicQWO52R51U6CR27rcMPTGeMedYA==", + "dependencies": { + "@aws-sdk/types": "3.577.0", + "@aws-sdk/util-endpoints": "3.587.0", + "@smithy/protocol-http": "^4.0.0", + "@smithy/types": "^3.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/region-config-resolver": { + "version": "3.587.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.587.0.tgz", + "integrity": "sha512-93I7IPZtulZQoRK+O20IJ4a1syWwYPzoO2gc3v+/GNZflZPV3QJXuVbIm0pxBsu0n/mzKGUKqSOLPIaN098HcQ==", + "dependencies": { + "@aws-sdk/types": "3.577.0", + "@smithy/node-config-provider": "^3.1.0", + "@smithy/types": "^3.0.0", + "@smithy/util-config-provider": "^3.0.0", + "@smithy/util-middleware": "^3.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/s3-request-presigner": { + "version": "3.590.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/s3-request-presigner/-/s3-request-presigner-3.590.0.tgz", + "integrity": "sha512-bb8NEG2IUHqFQJsLzr1nlkTZYyokeo3bGbHwMBKZHbdF+OXrQx0kQUcaDCXYWmeydSfHXxweQEJ2U5i1YEvT/A==", + "dependencies": { + "@aws-sdk/signature-v4-multi-region": "3.587.0", + "@aws-sdk/types": "3.577.0", + "@aws-sdk/util-format-url": "3.577.0", + "@smithy/middleware-endpoint": "^3.0.1", + "@smithy/protocol-http": "^4.0.0", + "@smithy/smithy-client": "^3.1.1", + "@smithy/types": "^3.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.587.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.587.0.tgz", + "integrity": "sha512-TR9+ZSjdXvXUz54ayHcCihhcvxI9W7102J1OK6MrLgBlPE7uRhAx42BR9L5lLJ86Xj3LuqPWf//o9d/zR9WVIg==", + "dependencies": { + "@aws-sdk/middleware-sdk-s3": "3.587.0", + "@aws-sdk/types": "3.577.0", + "@smithy/protocol-http": "^4.0.0", + "@smithy/signature-v4": "^3.0.0", + "@smithy/types": "^3.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/token-providers": { + "version": "3.587.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.587.0.tgz", + "integrity": "sha512-ULqhbnLy1hmJNRcukANBWJmum3BbjXnurLPSFXoGdV0llXYlG55SzIla2VYqdveQEEjmsBuTZdFvXAtNpmS5Zg==", + "dependencies": { + "@aws-sdk/types": "3.577.0", + "@smithy/property-provider": "^3.1.0", + "@smithy/shared-ini-file-loader": "^3.1.0", + "@smithy/types": "^3.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "@aws-sdk/client-sso-oidc": "^3.587.0" + } + }, + "node_modules/@aws-sdk/types": { + "version": "3.577.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.577.0.tgz", + "integrity": "sha512-FT2JZES3wBKN/alfmhlo+3ZOq/XJ0C7QOZcDNrpKjB0kqYoKjhVKZ/Hx6ArR0czkKfHzBBEs6y40ebIHx2nSmA==", + "dependencies": { + "@smithy/types": "^3.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/util-arn-parser": { + "version": "3.568.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-arn-parser/-/util-arn-parser-3.568.0.tgz", + "integrity": "sha512-XUKJWWo+KOB7fbnPP0+g/o5Ulku/X53t7i/h+sPHr5xxYTJJ9CYnbToo95mzxe7xWvkLrsNtJ8L+MnNn9INs2w==", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/util-endpoints": { + "version": "3.587.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.587.0.tgz", + "integrity": "sha512-8I1HG6Em8wQWqKcRW6m358mqebRVNpL8XrrEoT4In7xqkKkmYtHRNVYP6lcmiQh5pZ/c/FXu8dSchuFIWyEtqQ==", + "dependencies": { + "@aws-sdk/types": "3.577.0", + "@smithy/types": "^3.0.0", + "@smithy/util-endpoints": "^2.0.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/util-format-url": { + "version": "3.577.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-format-url/-/util-format-url-3.577.0.tgz", + "integrity": "sha512-SyEGC2J+y/krFRuPgiF02FmMYhqbiIkOjDE6k4nYLJQRyS6XEAGxZoG+OHeOVEM+bsDgbxokXZiM3XKGu6qFIg==", + "dependencies": { + "@aws-sdk/types": "3.577.0", + "@smithy/querystring-builder": "^3.0.0", + "@smithy/types": "^3.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/util-locate-window": { + "version": "3.568.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.568.0.tgz", + "integrity": "sha512-3nh4TINkXYr+H41QaPelCceEB2FXP3fxp93YZXB/kqJvX0U9j0N0Uk45gvsjmEPzG8XxkPEeLIfT2I1M7A6Lig==", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/util-user-agent-browser": { + "version": "3.577.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.577.0.tgz", + "integrity": "sha512-zEAzHgR6HWpZOH7xFgeJLc6/CzMcx4nxeQolZxVZoB5pPaJd3CjyRhZN0xXeZB0XIRCWmb4yJBgyiugXLNMkLA==", + "dependencies": { + "@aws-sdk/types": "3.577.0", + "@smithy/types": "^3.0.0", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-sdk/util-user-agent-node": { + "version": "3.587.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.587.0.tgz", + "integrity": "sha512-Pnl+DUe/bvnbEEDHP3iVJrOtE3HbFJBPgsD6vJ+ml/+IYk1Eq49jEG+EHZdNTPz3SDG0kbp2+7u41MKYJHR/iQ==", + "dependencies": { + "@aws-sdk/types": "3.577.0", + "@smithy/node-config-provider": "^3.1.0", + "@smithy/types": "^3.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "aws-crt": ">=1.0.0" + }, + "peerDependenciesMeta": { + "aws-crt": { + "optional": true + } + } + }, + "node_modules/@aws-sdk/util-utf8-browser": { + "version": "3.259.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-utf8-browser/-/util-utf8-browser-3.259.0.tgz", + "integrity": "sha512-UvFa/vR+e19XookZF8RzFZBrw2EUkQWxiBW0yYQAhvk3C+QVGl0H3ouca8LDBlBfQKXwmW3huo/59H8rwb1wJw==", + "dependencies": { + "tslib": "^2.3.1" + } + }, + "node_modules/@aws-sdk/xml-builder": { + "version": "3.575.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.575.0.tgz", + "integrity": "sha512-cWgAwmbFYNCFzPwxL705+lWps0F3ZvOckufd2KKoEZUmtpVw9/txUXNrPySUXSmRTSRhoatIMABNfStWR043bQ==", + "dependencies": { + "@smithy/types": "^3.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/@babel/code-frame": { "version": "7.24.2", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.24.2.tgz", - "integrity": "sha512-y5+tLQyV8pg3fsiln67BVLD1P13Eg4lh5RW9mF0zUuvLrv9uIQ4MCL+CRT+FTsBlBjcIan6PGsLcBN0m3ClUyQ==", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.24.2.tgz", + "integrity": "sha512-y5+tLQyV8pg3fsiln67BVLD1P13Eg4lh5RW9mF0zUuvLrv9uIQ4MCL+CRT+FTsBlBjcIan6PGsLcBN0m3ClUyQ==", + "dev": true, + "dependencies": { + "@babel/highlight": "^7.24.2", + "picocolors": "^1.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.24.4", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.24.4.tgz", + "integrity": "sha512-vg8Gih2MLK+kOkHJp4gBEIkyaIi00jgWot2D9QOmmfLC8jINSOzmCLta6Bvz/JSBCqnegV0L80jhxkol5GWNfQ==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.24.4", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.24.4.tgz", + "integrity": "sha512-MBVlMXP+kkl5394RBLSxxk/iLTeVGuXTV3cIDXavPpMMqnSnt6apKgan/U8O3USWZCWZT/TbgfEpKa4uMgN4Dg==", + "dev": true, + "dependencies": { + "@ampproject/remapping": "^2.2.0", + "@babel/code-frame": "^7.24.2", + "@babel/generator": "^7.24.4", + "@babel/helper-compilation-targets": "^7.23.6", + "@babel/helper-module-transforms": "^7.23.3", + "@babel/helpers": "^7.24.4", + "@babel/parser": "^7.24.4", + "@babel/template": "^7.24.0", + "@babel/traverse": "^7.24.1", + "@babel/types": "^7.24.0", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/generator": { + "version": "7.24.4", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.24.4.tgz", + "integrity": "sha512-Xd6+v6SnjWVx/nus+y0l1sxMOTOMBkyL4+BIdbALyatQnAe/SRVjANeDPSCYaX+i1iJmuGSKf3Z+E+V/va1Hvw==", + "dev": true, + "dependencies": { + "@babel/types": "^7.24.0", + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25", + "jsesc": "^2.5.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.23.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.23.6.tgz", + "integrity": "sha512-9JB548GZoQVmzrFgp8o7KxdgkTGm6xs9DW0o/Pim72UDjzr5ObUQ6ZzYPqA+g9OTS2bBQoctLJrky0RDCAWRgQ==", + "dev": true, + "dependencies": { + "@babel/compat-data": "^7.23.5", + "@babel/helper-validator-option": "^7.23.5", + "browserslist": "^4.22.2", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true + }, + "node_modules/@babel/helper-environment-visitor": { + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.20.tgz", + "integrity": "sha512-zfedSIzFhat/gFhWfHtgWvlec0nqB9YEIVrpuwjruLlXfUSnA8cJB0miHKwqDnQ7d32aKo2xt88/xZptwxbfhA==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-function-name": { + "version": "7.23.0", + "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.23.0.tgz", + "integrity": "sha512-OErEqsrxjZTJciZ4Oo+eoZqeW9UIiOcuYKRJA4ZAgV9myA+pOXhhmpfNCKjEH/auVfEYVFJ6y1Tc4r0eIApqiw==", + "dev": true, + "dependencies": { + "@babel/template": "^7.22.15", + "@babel/types": "^7.23.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-hoist-variables": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.22.5.tgz", + "integrity": "sha512-wGjk9QZVzvknA6yKIUURb8zY3grXCcOZt+/7Wcy8O2uctxhplmUPkOdlgoNhmdVee2c92JXbf1xpMtVNbfoxRw==", + "dev": true, + "dependencies": { + "@babel/types": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.24.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.24.3.tgz", + "integrity": "sha512-viKb0F9f2s0BCS22QSF308z/+1YWKV/76mwt61NBzS5izMzDPwdq1pTrzf+Li3npBWX9KdQbkeCt1jSAM7lZqg==", + "dev": true, + "dependencies": { + "@babel/types": "^7.24.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.23.3.tgz", + "integrity": "sha512-7bBs4ED9OmswdfDzpz4MpWgSrV7FXlc3zIagvLFjS5H+Mk7Snr21vQ6QwrsoCGMfNC4e4LQPdoULEt4ykz0SRQ==", + "dev": true, + "dependencies": { + "@babel/helper-environment-visitor": "^7.22.20", + "@babel/helper-module-imports": "^7.22.15", + "@babel/helper-simple-access": "^7.22.5", + "@babel/helper-split-export-declaration": "^7.22.6", + "@babel/helper-validator-identifier": "^7.22.20" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-simple-access": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.22.5.tgz", + "integrity": "sha512-n0H99E/K+Bika3++WNL17POvo4rKWZ7lZEp1Q+fStVbUi8nxPQEBOlTmCOxW/0JsS56SKKQ+ojAe2pHKJHN35w==", + "dev": true, + "dependencies": { + "@babel/types": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-split-export-declaration": { + "version": "7.22.6", + "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.22.6.tgz", + "integrity": "sha512-AsUnxuLhRYsisFiaJwvp1QF+I3KjD5FOxut14q/GzovUe6orHLesW2C7d754kRm53h5gqrz6sFl6sxc4BVtE/g==", + "dev": true, + "dependencies": { + "@babel/types": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.24.1.tgz", + "integrity": "sha512-2ofRCjnnA9y+wk8b9IAREroeUP02KHp431N2mhKniy2yKIDKpbrHv9eXwm8cBeWQYcJmzv5qKCu65P47eCF7CQ==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz", + "integrity": "sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.23.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.23.5.tgz", + "integrity": "sha512-85ttAOMLsr53VgXkTbkx8oA6YTfT4q7/HzXSLEYmjcSTJPMPQtvq1BD79Byep5xMUYbGRzEpDsjUf3dyp54IKw==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.24.4", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.24.4.tgz", + "integrity": "sha512-FewdlZbSiwaVGlgT1DPANDuCHaDMiOo+D/IDYRFYjHOuv66xMSJ7fQwwODwRNAPkADIO/z1EoF/l2BCWlWABDw==", + "dev": true, + "dependencies": { + "@babel/template": "^7.24.0", + "@babel/traverse": "^7.24.1", + "@babel/types": "^7.24.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/highlight": { + "version": "7.24.2", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.24.2.tgz", + "integrity": "sha512-Yac1ao4flkTxTteCDZLEvdxg2fZfz1v8M4QpaGypq/WPDqg3ijHYbDfs+LG5hvzSoqaSZ9/Z9lKSP3CjZjv+pA==", + "dev": true, + "dependencies": { + "@babel/helper-validator-identifier": "^7.22.20", + "chalk": "^2.4.2", + "js-tokens": "^4.0.0", + "picocolors": "^1.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/highlight/node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/highlight/node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/highlight/node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/@babel/highlight/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "dev": true + }, + "node_modules/@babel/highlight/node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@babel/highlight/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/highlight/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/parser": { + "version": "7.24.4", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.24.4.tgz", + "integrity": "sha512-zTvEBcghmeBma9QIGunWevvBAp4/Qu9Bdq+2k0Ot4fVMD6v3dsC9WOcRSKk7tRRyBM/53yKMJko9xOatGQAwSg==", + "dev": true, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/template": { + "version": "7.24.0", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.24.0.tgz", + "integrity": "sha512-Bkf2q8lMB0AFpX0NFEqSbx1OkTHf0f+0j82mkw+ZpzBnkk7e9Ql0891vlfgi+kHwOk8tQjiQHpqh4LaSa0fKEA==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.23.5", + "@babel/parser": "^7.24.0", + "@babel/types": "^7.24.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.24.1.tgz", + "integrity": "sha512-xuU6o9m68KeqZbQuDt2TcKSxUw/mrsvavlEqQ1leZ/B+C9tk6E4sRWy97WaXgvq5E+nU3cXMxv3WKOCanVMCmQ==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.24.1", + "@babel/generator": "^7.24.1", + "@babel/helper-environment-visitor": "^7.22.20", + "@babel/helper-function-name": "^7.23.0", + "@babel/helper-hoist-variables": "^7.22.5", + "@babel/helper-split-export-declaration": "^7.22.6", + "@babel/parser": "^7.24.1", + "@babel/types": "^7.24.0", + "debug": "^4.3.1", + "globals": "^11.1.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse/node_modules/globals": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", + "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/types": { + "version": "7.24.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.24.0.tgz", + "integrity": "sha512-+j7a5c253RfKh8iABBhywc8NSfP5LURe7Uh4qpsh6jc+aLJguvmIUBdjSdEMQv2bENrCR5MfRdjGo7vzS/ob7w==", + "dev": true, + "dependencies": { + "@babel/helper-string-parser": "^7.23.4", + "@babel/helper-validator-identifier": "^7.22.20", + "to-fast-properties": "^2.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@colors/colors": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", + "integrity": "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==", + "engines": { + "node": ">=0.1.90" + } + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@dabh/diagnostics": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@dabh/diagnostics/-/diagnostics-2.0.3.tgz", + "integrity": "sha512-hrlQOIi7hAfzsMqlGSFyVucrx38O+j6wiGOf//H2ecvIEqYN4ADBSS2iLMh5UFyDunCNniUIPk/q3riFv45xRA==", + "dependencies": { + "colorspace": "1.1.x", + "enabled": "2.0.x", + "kuler": "^2.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", + "integrity": "sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==", + "dev": true, + "dependencies": { + "eslint-visitor-keys": "^3.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.10.0", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.10.0.tgz", + "integrity": "sha512-Cu96Sd2By9mCNTx2iyKOmq10v22jUVQv0lQnlGNy16oE9589yE+QADPbrMGCkA51cKZSg3Pu/aTJVTGfL/qjUA==", + "dev": true, + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", + "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", + "dev": true, + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^9.6.0", + "globals": "^13.19.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, + "node_modules/@eslint/eslintrc/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", "dev": true, "dependencies": { - "@babel/highlight": "^7.24.2", - "picocolors": "^1.0.0" + "brace-expansion": "^1.1.7" }, "engines": { - "node": ">=6.9.0" + "node": "*" } }, - "node_modules/@babel/compat-data": { - "version": "7.24.4", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.24.4.tgz", - "integrity": "sha512-vg8Gih2MLK+kOkHJp4gBEIkyaIi00jgWot2D9QOmmfLC8jINSOzmCLta6Bvz/JSBCqnegV0L80jhxkol5GWNfQ==", + "node_modules/@eslint/js": { + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.56.0.tgz", + "integrity": "sha512-gMsVel9D7f2HLkBma9VbtzZRehRogVRfbr++f06nL2vnCGCNlzOD+/MUov/F4p8myyAHspEhVobgjpX64q5m6A==", "dev": true, "engines": { - "node": ">=6.9.0" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, - "node_modules/@babel/core": { - "version": "7.24.4", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.24.4.tgz", - "integrity": "sha512-MBVlMXP+kkl5394RBLSxxk/iLTeVGuXTV3cIDXavPpMMqnSnt6apKgan/U8O3USWZCWZT/TbgfEpKa4uMgN4Dg==", + "node_modules/@humanwhocodes/config-array": { + "version": "0.11.14", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz", + "integrity": "sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==", "dev": true, "dependencies": { - "@ampproject/remapping": "^2.2.0", - "@babel/code-frame": "^7.24.2", - "@babel/generator": "^7.24.4", - "@babel/helper-compilation-targets": "^7.23.6", - "@babel/helper-module-transforms": "^7.23.3", - "@babel/helpers": "^7.24.4", - "@babel/parser": "^7.24.4", - "@babel/template": "^7.24.0", - "@babel/traverse": "^7.24.1", - "@babel/types": "^7.24.0", - "convert-source-map": "^2.0.0", - "debug": "^4.1.0", - "gensync": "^1.0.0-beta.2", - "json5": "^2.2.3", - "semver": "^6.3.1" + "@humanwhocodes/object-schema": "^2.0.2", + "debug": "^4.3.1", + "minimatch": "^3.0.5" }, "engines": { - "node": ">=6.9.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/babel" + "node": ">=10.10.0" } }, - "node_modules/@babel/core/node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true - }, - "node_modules/@babel/core/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "node_modules/@humanwhocodes/config-array/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", "dev": true, - "bin": { - "semver": "bin/semver.js" + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" } }, - "node_modules/@babel/generator": { - "version": "7.24.4", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.24.4.tgz", - "integrity": "sha512-Xd6+v6SnjWVx/nus+y0l1sxMOTOMBkyL4+BIdbALyatQnAe/SRVjANeDPSCYaX+i1iJmuGSKf3Z+E+V/va1Hvw==", + "node_modules/@humanwhocodes/config-array/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", "dev": true, "dependencies": { - "@babel/types": "^7.24.0", - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.25", - "jsesc": "^2.5.1" + "brace-expansion": "^1.1.7" }, "engines": { - "node": ">=6.9.0" + "node": "*" } }, - "node_modules/@babel/helper-compilation-targets": { - "version": "7.23.6", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.23.6.tgz", - "integrity": "sha512-9JB548GZoQVmzrFgp8o7KxdgkTGm6xs9DW0o/Pim72UDjzr5ObUQ6ZzYPqA+g9OTS2bBQoctLJrky0RDCAWRgQ==", + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/object-schema": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", + "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", + "dev": true + }, + "node_modules/@istanbuljs/load-nyc-config": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", + "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", "dev": true, "dependencies": { - "@babel/compat-data": "^7.23.5", - "@babel/helper-validator-option": "^7.23.5", - "browserslist": "^4.22.2", - "lru-cache": "^5.1.1", - "semver": "^6.3.1" + "camelcase": "^5.3.1", + "find-up": "^4.1.0", + "get-package-type": "^0.1.0", + "js-yaml": "^3.13.1", + "resolve-from": "^5.0.0" }, "engines": { - "node": ">=6.9.0" + "node": ">=8" } }, - "node_modules/@babel/helper-compilation-targets/node_modules/lru-cache": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", - "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "node_modules/@istanbuljs/load-nyc-config/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", "dev": true, "dependencies": { - "yallist": "^3.0.2" + "sprintf-js": "~1.0.2" } }, - "node_modules/@babel/helper-compilation-targets/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "node_modules/@istanbuljs/load-nyc-config/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", "dev": true, - "bin": { - "semver": "bin/semver.js" + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" } }, - "node_modules/@babel/helper-compilation-targets/node_modules/yallist": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", - "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", - "dev": true - }, - "node_modules/@babel/helper-environment-visitor": { - "version": "7.22.20", - "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.20.tgz", - "integrity": "sha512-zfedSIzFhat/gFhWfHtgWvlec0nqB9YEIVrpuwjruLlXfUSnA8cJB0miHKwqDnQ7d32aKo2xt88/xZptwxbfhA==", + "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { + "version": "3.14.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", + "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", "dev": true, - "engines": { - "node": ">=6.9.0" + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" } }, - "node_modules/@babel/helper-function-name": { - "version": "7.23.0", - "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.23.0.tgz", - "integrity": "sha512-OErEqsrxjZTJciZ4Oo+eoZqeW9UIiOcuYKRJA4ZAgV9myA+pOXhhmpfNCKjEH/auVfEYVFJ6y1Tc4r0eIApqiw==", + "node_modules/@istanbuljs/load-nyc-config/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", "dev": true, "dependencies": { - "@babel/template": "^7.22.15", - "@babel/types": "^7.23.0" + "p-locate": "^4.1.0" }, "engines": { - "node": ">=6.9.0" + "node": ">=8" } }, - "node_modules/@babel/helper-hoist-variables": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.22.5.tgz", - "integrity": "sha512-wGjk9QZVzvknA6yKIUURb8zY3grXCcOZt+/7Wcy8O2uctxhplmUPkOdlgoNhmdVee2c92JXbf1xpMtVNbfoxRw==", + "node_modules/@istanbuljs/load-nyc-config/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", "dev": true, "dependencies": { - "@babel/types": "^7.22.5" + "p-try": "^2.0.0" }, "engines": { - "node": ">=6.9.0" + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@babel/helper-module-imports": { - "version": "7.24.3", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.24.3.tgz", - "integrity": "sha512-viKb0F9f2s0BCS22QSF308z/+1YWKV/76mwt61NBzS5izMzDPwdq1pTrzf+Li3npBWX9KdQbkeCt1jSAM7lZqg==", + "node_modules/@istanbuljs/load-nyc-config/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", "dev": true, "dependencies": { - "@babel/types": "^7.24.0" + "p-limit": "^2.2.0" }, "engines": { - "node": ">=6.9.0" + "node": ">=8" } }, - "node_modules/@babel/helper-module-transforms": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.23.3.tgz", - "integrity": "sha512-7bBs4ED9OmswdfDzpz4MpWgSrV7FXlc3zIagvLFjS5H+Mk7Snr21vQ6QwrsoCGMfNC4e4LQPdoULEt4ykz0SRQ==", + "node_modules/@istanbuljs/load-nyc-config/node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/nyc-config-typescript": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@istanbuljs/nyc-config-typescript/-/nyc-config-typescript-1.0.2.tgz", + "integrity": "sha512-iKGIyMoyJuFnJRSVTZ78POIRvNnwZaWIf8vG4ZS3rQq58MMDrqEX2nnzx0R28V2X8JvmKYiqY9FP2hlJsm8A0w==", "dev": true, "dependencies": { - "@babel/helper-environment-visitor": "^7.22.20", - "@babel/helper-module-imports": "^7.22.15", - "@babel/helper-simple-access": "^7.22.5", - "@babel/helper-split-export-declaration": "^7.22.6", - "@babel/helper-validator-identifier": "^7.22.20" + "@istanbuljs/schema": "^0.1.2" }, "engines": { - "node": ">=6.9.0" + "node": ">=8" }, "peerDependencies": { - "@babel/core": "^7.0.0" + "nyc": ">=15" } }, - "node_modules/@babel/helper-simple-access": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.22.5.tgz", - "integrity": "sha512-n0H99E/K+Bika3++WNL17POvo4rKWZ7lZEp1Q+fStVbUi8nxPQEBOlTmCOxW/0JsS56SKKQ+ojAe2pHKJHN35w==", + "node_modules/@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz", + "integrity": "sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==", "dev": true, "dependencies": { - "@babel/types": "^7.22.5" + "@jridgewell/set-array": "^1.2.1", + "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/trace-mapping": "^0.3.24" }, "engines": { - "node": ">=6.9.0" + "node": ">=6.0.0" } }, - "node_modules/@babel/helper-split-export-declaration": { - "version": "7.22.6", - "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.22.6.tgz", - "integrity": "sha512-AsUnxuLhRYsisFiaJwvp1QF+I3KjD5FOxut14q/GzovUe6orHLesW2C7d754kRm53h5gqrz6sFl6sxc4BVtE/g==", + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", "dev": true, - "dependencies": { - "@babel/types": "^7.22.5" - }, "engines": { - "node": ">=6.9.0" + "node": ">=6.0.0" } }, - "node_modules/@babel/helper-string-parser": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.24.1.tgz", - "integrity": "sha512-2ofRCjnnA9y+wk8b9IAREroeUP02KHp431N2mhKniy2yKIDKpbrHv9eXwm8cBeWQYcJmzv5qKCu65P47eCF7CQ==", + "node_modules/@jridgewell/set-array": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", + "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", "dev": true, "engines": { - "node": ">=6.9.0" + "node": ">=6.0.0" } }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.22.20", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz", - "integrity": "sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==", + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.4.15", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", + "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==", + "dev": true + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.25", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", + "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", + "dev": true, + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", "dev": true, + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, "engines": { - "node": ">=6.9.0" + "node": ">= 8" } }, - "node_modules/@babel/helper-validator-option": { - "version": "7.23.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.23.5.tgz", - "integrity": "sha512-85ttAOMLsr53VgXkTbkx8oA6YTfT4q7/HzXSLEYmjcSTJPMPQtvq1BD79Byep5xMUYbGRzEpDsjUf3dyp54IKw==", + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", "dev": true, "engines": { - "node": ">=6.9.0" + "node": ">= 8" } }, - "node_modules/@babel/helpers": { - "version": "7.24.4", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.24.4.tgz", - "integrity": "sha512-FewdlZbSiwaVGlgT1DPANDuCHaDMiOo+D/IDYRFYjHOuv66xMSJ7fQwwODwRNAPkADIO/z1EoF/l2BCWlWABDw==", + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", "dev": true, "dependencies": { - "@babel/template": "^7.24.0", - "@babel/traverse": "^7.24.1", - "@babel/types": "^7.24.0" + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" }, "engines": { - "node": ">=6.9.0" + "node": ">= 8" } }, - "node_modules/@babel/highlight": { - "version": "7.24.2", - "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.24.2.tgz", - "integrity": "sha512-Yac1ao4flkTxTteCDZLEvdxg2fZfz1v8M4QpaGypq/WPDqg3ijHYbDfs+LG5hvzSoqaSZ9/Z9lKSP3CjZjv+pA==", + "node_modules/@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", "dev": true, "dependencies": { - "@babel/helper-validator-identifier": "^7.22.20", - "chalk": "^2.4.2", - "js-tokens": "^4.0.0", - "picocolors": "^1.0.0" - }, - "engines": { - "node": ">=6.9.0" + "type-detect": "4.0.8" } }, - "node_modules/@babel/highlight/node_modules/ansi-styles": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", - "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "node_modules/@sinonjs/fake-timers": { + "version": "11.2.2", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-11.2.2.tgz", + "integrity": "sha512-G2piCSxQ7oWOxwGSAyFHfPIsyeJGXYtc6mFbnFA+kRXkiEnTl8c/8jul2S329iFBnDI9HGoeWWAZvuvOkZccgw==", "dev": true, "dependencies": { - "color-convert": "^1.9.0" - }, - "engines": { - "node": ">=4" + "@sinonjs/commons": "^3.0.0" } }, - "node_modules/@babel/highlight/node_modules/chalk": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", - "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "node_modules/@sinonjs/samsam": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-8.0.0.tgz", + "integrity": "sha512-Bp8KUVlLp8ibJZrnvq2foVhP0IVX2CIprMJPK0vqGqgrDa0OHVKeZyBykqskkrdxV6yKBPmGasO8LVjAKR3Gew==", "dev": true, "dependencies": { - "ansi-styles": "^3.2.1", - "escape-string-regexp": "^1.0.5", - "supports-color": "^5.3.0" - }, - "engines": { - "node": ">=4" + "@sinonjs/commons": "^2.0.0", + "lodash.get": "^4.4.2", + "type-detect": "^4.0.8" } }, - "node_modules/@babel/highlight/node_modules/color-convert": { - "version": "1.9.3", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", - "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "node_modules/@sinonjs/samsam/node_modules/@sinonjs/commons": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-2.0.0.tgz", + "integrity": "sha512-uLa0j859mMrg2slwQYdO/AkrOfmH+X6LTVmNTS9CqexuE2IvVORIkSpJLqePAbEnKJ77aMmCwr1NUZ57120Xcg==", "dev": true, "dependencies": { - "color-name": "1.1.3" + "type-detect": "4.0.8" } }, - "node_modules/@babel/highlight/node_modules/color-name": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "node_modules/@sinonjs/text-encoding": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@sinonjs/text-encoding/-/text-encoding-0.7.2.tgz", + "integrity": "sha512-sXXKG+uL9IrKqViTtao2Ws6dy0znu9sOaP1di/jKGW1M6VssO8vlpXCQcpZ+jisQ1tTFAC5Jo/EOzFbggBagFQ==", "dev": true }, - "node_modules/@babel/highlight/node_modules/escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", - "dev": true, + "node_modules/@smithy/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-p6GlFGBt9K4MYLu72YuJ523NVR4A8oHlC5M2JO6OmQqN8kAc/uh1JqLE+FizTokrSJGg0CSvC+BrsmGzKtsZKA==", + "dependencies": { + "@smithy/types": "^3.0.0", + "tslib": "^2.6.2" + }, "engines": { - "node": ">=0.8.0" + "node": ">=16.0.0" } }, - "node_modules/@babel/highlight/node_modules/has-flag": { + "node_modules/@smithy/chunked-blob-reader": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", - "dev": true, - "engines": { - "node": ">=4" + "resolved": "https://registry.npmjs.org/@smithy/chunked-blob-reader/-/chunked-blob-reader-3.0.0.tgz", + "integrity": "sha512-sbnURCwjF0gSToGlsBiAmd1lRCmSn72nu9axfJu5lIx6RUEgHu6GwTMbqCdhQSi0Pumcm5vFxsi9XWXb2mTaoA==", + "dependencies": { + "tslib": "^2.6.2" } }, - "node_modules/@babel/highlight/node_modules/supports-color": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", - "dev": true, + "node_modules/@smithy/chunked-blob-reader-native": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@smithy/chunked-blob-reader-native/-/chunked-blob-reader-native-3.0.0.tgz", + "integrity": "sha512-VDkpCYW+peSuM4zJip5WDfqvg2Mo/e8yxOv3VF1m11y7B8KKMKVFtmZWDe36Fvk8rGuWrPZHHXZ7rR7uM5yWyg==", "dependencies": { - "has-flag": "^3.0.0" - }, - "engines": { - "node": ">=4" + "@smithy/util-base64": "^3.0.0", + "tslib": "^2.6.2" } }, - "node_modules/@babel/parser": { - "version": "7.24.4", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.24.4.tgz", - "integrity": "sha512-zTvEBcghmeBma9QIGunWevvBAp4/Qu9Bdq+2k0Ot4fVMD6v3dsC9WOcRSKk7tRRyBM/53yKMJko9xOatGQAwSg==", - "dev": true, - "bin": { - "parser": "bin/babel-parser.js" + "node_modules/@smithy/config-resolver": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-3.0.1.tgz", + "integrity": "sha512-hbkYJc20SBDz2qqLzttjI/EqXemtmWk0ooRznLsiXp3066KQRTvuKHa7U4jCZCJq6Dozqvy0R1/vNESC9inPJg==", + "dependencies": { + "@smithy/node-config-provider": "^3.1.0", + "@smithy/types": "^3.0.0", + "@smithy/util-config-provider": "^3.0.0", + "@smithy/util-middleware": "^3.0.0", + "tslib": "^2.6.2" }, "engines": { - "node": ">=6.0.0" + "node": ">=16.0.0" } }, - "node_modules/@babel/template": { - "version": "7.24.0", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.24.0.tgz", - "integrity": "sha512-Bkf2q8lMB0AFpX0NFEqSbx1OkTHf0f+0j82mkw+ZpzBnkk7e9Ql0891vlfgi+kHwOk8tQjiQHpqh4LaSa0fKEA==", - "dev": true, + "node_modules/@smithy/core": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-2.2.0.tgz", + "integrity": "sha512-ygLZSSKgt9bR8HAxR9mK+U5obvAJBr6zlQuhN5soYWx/amjDoQN4dTkydTypgKe6rIbUjTILyLU+W5XFwXr4kg==", "dependencies": { - "@babel/code-frame": "^7.23.5", - "@babel/parser": "^7.24.0", - "@babel/types": "^7.24.0" + "@smithy/middleware-endpoint": "^3.0.1", + "@smithy/middleware-retry": "^3.0.3", + "@smithy/middleware-serde": "^3.0.0", + "@smithy/protocol-http": "^4.0.0", + "@smithy/smithy-client": "^3.1.1", + "@smithy/types": "^3.0.0", + "@smithy/util-middleware": "^3.0.0", + "tslib": "^2.6.2" }, "engines": { - "node": ">=6.9.0" + "node": ">=16.0.0" } }, - "node_modules/@babel/traverse": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.24.1.tgz", - "integrity": "sha512-xuU6o9m68KeqZbQuDt2TcKSxUw/mrsvavlEqQ1leZ/B+C9tk6E4sRWy97WaXgvq5E+nU3cXMxv3WKOCanVMCmQ==", - "dev": true, + "node_modules/@smithy/credential-provider-imds": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-3.1.0.tgz", + "integrity": "sha512-q4A4d38v8pYYmseu/jTS3Z5I3zXlEOe5Obi+EJreVKgSVyWUHOd7/yaVCinC60QG4MRyCs98tcxBH1IMC0bu7Q==", "dependencies": { - "@babel/code-frame": "^7.24.1", - "@babel/generator": "^7.24.1", - "@babel/helper-environment-visitor": "^7.22.20", - "@babel/helper-function-name": "^7.23.0", - "@babel/helper-hoist-variables": "^7.22.5", - "@babel/helper-split-export-declaration": "^7.22.6", - "@babel/parser": "^7.24.1", - "@babel/types": "^7.24.0", - "debug": "^4.3.1", - "globals": "^11.1.0" + "@smithy/node-config-provider": "^3.1.0", + "@smithy/property-provider": "^3.1.0", + "@smithy/types": "^3.0.0", + "@smithy/url-parser": "^3.0.0", + "tslib": "^2.6.2" }, "engines": { - "node": ">=6.9.0" + "node": ">=16.0.0" } }, - "node_modules/@babel/traverse/node_modules/globals": { - "version": "11.12.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", - "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", - "dev": true, + "node_modules/@smithy/eventstream-codec": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-codec/-/eventstream-codec-3.0.0.tgz", + "integrity": "sha512-PUtyEA0Oik50SaEFCZ0WPVtF9tz/teze2fDptW6WRXl+RrEenH8UbEjudOz8iakiMl3lE3lCVqYf2Y+znL8QFQ==", + "dependencies": { + "@aws-crypto/crc32": "3.0.0", + "@smithy/types": "^3.0.0", + "@smithy/util-hex-encoding": "^3.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@smithy/eventstream-serde-browser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-browser/-/eventstream-serde-browser-3.0.0.tgz", + "integrity": "sha512-NB7AFiPN4NxP/YCAnrvYR18z2/ZsiHiF7VtG30gshO9GbFrIb1rC8ep4NGpJSWrz6P64uhPXeo4M0UsCLnZKqw==", + "dependencies": { + "@smithy/eventstream-serde-universal": "^3.0.0", + "@smithy/types": "^3.0.0", + "tslib": "^2.6.2" + }, "engines": { - "node": ">=4" + "node": ">=16.0.0" } }, - "node_modules/@babel/types": { - "version": "7.24.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.24.0.tgz", - "integrity": "sha512-+j7a5c253RfKh8iABBhywc8NSfP5LURe7Uh4qpsh6jc+aLJguvmIUBdjSdEMQv2bENrCR5MfRdjGo7vzS/ob7w==", - "dev": true, + "node_modules/@smithy/eventstream-serde-config-resolver": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-config-resolver/-/eventstream-serde-config-resolver-3.0.0.tgz", + "integrity": "sha512-RUQG3vQ3LX7peqqHAbmayhgrF5aTilPnazinaSGF1P0+tgM3vvIRWPHmlLIz2qFqB9LqFIxditxc8O2Z6psrRw==", "dependencies": { - "@babel/helper-string-parser": "^7.23.4", - "@babel/helper-validator-identifier": "^7.22.20", - "to-fast-properties": "^2.0.0" + "@smithy/types": "^3.0.0", + "tslib": "^2.6.2" }, "engines": { - "node": ">=6.9.0" + "node": ">=16.0.0" } }, - "node_modules/@colors/colors": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", - "integrity": "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==", + "node_modules/@smithy/eventstream-serde-node": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-node/-/eventstream-serde-node-3.0.0.tgz", + "integrity": "sha512-baRPdMBDMBExZXIUAoPGm/hntixjt/VFpU6+VmCyiYJYzRHRxoaI1MN+5XE+hIS8AJ2GCHLMFEIOLzq9xx1EgQ==", + "dependencies": { + "@smithy/eventstream-serde-universal": "^3.0.0", + "@smithy/types": "^3.0.0", + "tslib": "^2.6.2" + }, "engines": { - "node": ">=0.1.90" + "node": ">=16.0.0" } }, - "node_modules/@cspotcode/source-map-support": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", - "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", - "dev": true, + "node_modules/@smithy/eventstream-serde-universal": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-universal/-/eventstream-serde-universal-3.0.0.tgz", + "integrity": "sha512-HNFfShmotWGeAoW4ujP8meV9BZavcpmerDbPIjkJbxKbN8RsUcpRQ/2OyIxWNxXNH2GWCAxuSB7ynmIGJlQ3Dw==", "dependencies": { - "@jridgewell/trace-mapping": "0.3.9" + "@smithy/eventstream-codec": "^3.0.0", + "@smithy/types": "^3.0.0", + "tslib": "^2.6.2" }, "engines": { - "node": ">=12" + "node": ">=16.0.0" } }, - "node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", - "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", - "dev": true, + "node_modules/@smithy/fetch-http-handler": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-3.0.1.tgz", + "integrity": "sha512-uaH74i5BDj+rBwoQaXioKpI0SHBJFtOVwzrCpxZxphOW0ki5jhj7dXvDMYM2IJem8TpdFvS2iC08sjOblfFGFg==", "dependencies": { - "@jridgewell/resolve-uri": "^3.0.3", - "@jridgewell/sourcemap-codec": "^1.4.10" + "@smithy/protocol-http": "^4.0.0", + "@smithy/querystring-builder": "^3.0.0", + "@smithy/types": "^3.0.0", + "@smithy/util-base64": "^3.0.0", + "tslib": "^2.6.2" } }, - "node_modules/@dabh/diagnostics": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@dabh/diagnostics/-/diagnostics-2.0.3.tgz", - "integrity": "sha512-hrlQOIi7hAfzsMqlGSFyVucrx38O+j6wiGOf//H2ecvIEqYN4ADBSS2iLMh5UFyDunCNniUIPk/q3riFv45xRA==", + "node_modules/@smithy/hash-blob-browser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@smithy/hash-blob-browser/-/hash-blob-browser-3.0.0.tgz", + "integrity": "sha512-/Wbpdg+bwJvW7lxR/zpWAc1/x/YkcqguuF2bAzkJrvXriZu1vm8r+PUdE4syiVwQg7PPR2dXpi3CLBb9qRDaVQ==", "dependencies": { - "colorspace": "1.1.x", - "enabled": "2.0.x", - "kuler": "^2.0.0" + "@smithy/chunked-blob-reader": "^3.0.0", + "@smithy/chunked-blob-reader-native": "^3.0.0", + "@smithy/types": "^3.0.0", + "tslib": "^2.6.2" } }, - "node_modules/@eslint-community/eslint-utils": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", - "integrity": "sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==", - "dev": true, + "node_modules/@smithy/hash-node": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-3.0.0.tgz", + "integrity": "sha512-84qXstNemP3XS5jcof0el6+bDfjzuvhJPQTEfro3lgtbCtKgzPm3MgiS6ehXVPjeQ5+JS0HqmTz8f/RYfzHVxw==", "dependencies": { - "eslint-visitor-keys": "^3.3.0" + "@smithy/types": "^3.0.0", + "@smithy/util-buffer-from": "^3.0.0", + "@smithy/util-utf8": "^3.0.0", + "tslib": "^2.6.2" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + "node": ">=16.0.0" } }, - "node_modules/@eslint-community/regexpp": { - "version": "4.10.0", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.10.0.tgz", - "integrity": "sha512-Cu96Sd2By9mCNTx2iyKOmq10v22jUVQv0lQnlGNy16oE9589yE+QADPbrMGCkA51cKZSg3Pu/aTJVTGfL/qjUA==", - "dev": true, + "node_modules/@smithy/hash-stream-node": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@smithy/hash-stream-node/-/hash-stream-node-3.0.0.tgz", + "integrity": "sha512-J0i7de+EgXDEGITD4fxzmMX8CyCNETTIRXlxjMiNUvvu76Xn3GJ31wQR85ynlPk2wI1lqoknAFJaD1fiNDlbIA==", + "dependencies": { + "@smithy/types": "^3.0.0", + "@smithy/util-utf8": "^3.0.0", + "tslib": "^2.6.2" + }, "engines": { - "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + "node": ">=16.0.0" } }, - "node_modules/@eslint/eslintrc": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", - "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", - "dev": true, + "node_modules/@smithy/invalid-dependency": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-3.0.0.tgz", + "integrity": "sha512-F6wBBaEFgJzj0s4KUlliIGPmqXemwP6EavgvDqYwCH40O5Xr2iMHvS8todmGVZtuJCorBkXsYLyTu4PuizVq5g==", "dependencies": { - "ajv": "^6.12.4", - "debug": "^4.3.2", - "espree": "^9.6.0", - "globals": "^13.19.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.1.0", - "minimatch": "^3.1.2", - "strip-json-comments": "^3.1.1" + "@smithy/types": "^3.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@smithy/is-array-buffer": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-3.0.0.tgz", + "integrity": "sha512-+Fsu6Q6C4RSJiy81Y8eApjEB5gVtM+oFKTffg+jSuwtvomJJrhUJBu2zS8wjXSgH/g1MKEWrzyChTBe6clb5FQ==", + "dependencies": { + "tslib": "^2.6.2" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": ">=16.0.0" + } + }, + "node_modules/@smithy/md5-js": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@smithy/md5-js/-/md5-js-3.0.0.tgz", + "integrity": "sha512-Tm0vrrVzjlD+6RCQTx7D3Ls58S3FUH1ZCtU1MIh/qQmaOo1H9lMN2as6CikcEwgattnA9SURSdoJJ27xMcEfMA==", + "dependencies": { + "@smithy/types": "^3.0.0", + "@smithy/util-utf8": "^3.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@smithy/middleware-content-length": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-3.0.0.tgz", + "integrity": "sha512-3C4s4d/iGobgCtk2tnWW6+zSTOBg1PRAm2vtWZLdriwTroFbbWNSr3lcyzHdrQHnEXYCC5K52EbpfodaIUY8sg==", + "dependencies": { + "@smithy/protocol-http": "^4.0.0", + "@smithy/types": "^3.0.0", + "tslib": "^2.6.2" }, - "funding": { - "url": "https://opencollective.com/eslint" + "engines": { + "node": ">=16.0.0" } }, - "node_modules/@eslint/eslintrc/node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "dev": true, + "node_modules/@smithy/middleware-endpoint": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-3.0.1.tgz", + "integrity": "sha512-lQ/UOdGD4KM5kLZiAl0q8Qy3dPbynvAXKAdXnYlrA1OpaUwr+neSsVokDZpY6ZVb5Yx8jnus29uv6XWpM9P4SQ==", "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" + "@smithy/middleware-serde": "^3.0.0", + "@smithy/node-config-provider": "^3.1.0", + "@smithy/shared-ini-file-loader": "^3.1.0", + "@smithy/types": "^3.0.0", + "@smithy/url-parser": "^3.0.0", + "@smithy/util-middleware": "^3.0.0", + "tslib": "^2.6.2" }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" + "engines": { + "node": ">=16.0.0" } }, - "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, + "node_modules/@smithy/middleware-retry": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-3.0.3.tgz", + "integrity": "sha512-Wve1qzJb83VEU/6q+/I0cQdAkDnuzELC6IvIBwDzUEiGpKqXgX1v10FUuZGbRS6Ov/P+HHthcAoHOJZQvZNAkA==", "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" + "@smithy/node-config-provider": "^3.1.0", + "@smithy/protocol-http": "^4.0.0", + "@smithy/service-error-classification": "^3.0.0", + "@smithy/smithy-client": "^3.1.1", + "@smithy/types": "^3.0.0", + "@smithy/util-middleware": "^3.0.0", + "@smithy/util-retry": "^3.0.0", + "tslib": "^2.6.2", + "uuid": "^9.0.1" + }, + "engines": { + "node": ">=16.0.0" } }, - "node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true + "node_modules/@smithy/middleware-retry/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "bin": { + "uuid": "dist/bin/uuid" + } }, - "node_modules/@eslint/eslintrc/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, + "node_modules/@smithy/middleware-serde": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-3.0.0.tgz", + "integrity": "sha512-I1vKG1foI+oPgG9r7IMY1S+xBnmAn1ISqployvqkwHoSb8VPsngHDTOgYGYBonuOKndaWRUGJZrKYYLB+Ane6w==", "dependencies": { - "brace-expansion": "^1.1.7" + "@smithy/types": "^3.0.0", + "tslib": "^2.6.2" }, "engines": { - "node": "*" + "node": ">=16.0.0" } }, - "node_modules/@eslint/js": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.56.0.tgz", - "integrity": "sha512-gMsVel9D7f2HLkBma9VbtzZRehRogVRfbr++f06nL2vnCGCNlzOD+/MUov/F4p8myyAHspEhVobgjpX64q5m6A==", - "dev": true, + "node_modules/@smithy/middleware-stack": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-3.0.0.tgz", + "integrity": "sha512-+H0jmyfAyHRFXm6wunskuNAqtj7yfmwFB6Fp37enytp2q047/Od9xetEaUbluyImOlGnGpaVGaVfjwawSr+i6Q==", + "dependencies": { + "@smithy/types": "^3.0.0", + "tslib": "^2.6.2" + }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": ">=16.0.0" } }, - "node_modules/@humanwhocodes/config-array": { - "version": "0.11.14", - "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz", - "integrity": "sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==", - "dev": true, + "node_modules/@smithy/node-config-provider": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-3.1.0.tgz", + "integrity": "sha512-ngfB8QItUfTFTfHMvKuc2g1W60V1urIgZHqD1JNFZC2tTWXahqf2XvKXqcBS7yZqR7GqkQQZy11y/lNOUWzq7Q==", "dependencies": { - "@humanwhocodes/object-schema": "^2.0.2", - "debug": "^4.3.1", - "minimatch": "^3.0.5" + "@smithy/property-provider": "^3.1.0", + "@smithy/shared-ini-file-loader": "^3.1.0", + "@smithy/types": "^3.0.0", + "tslib": "^2.6.2" }, "engines": { - "node": ">=10.10.0" + "node": ">=16.0.0" } }, - "node_modules/@humanwhocodes/config-array/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, + "node_modules/@smithy/node-http-handler": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-3.0.0.tgz", + "integrity": "sha512-3trD4r7NOMygwLbUJo4eodyQuypAWr7uvPnebNJ9a70dQhVn+US8j/lCnvoJS6BXfZeF7PkkkI0DemVJw+n+eQ==", "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" + "@smithy/abort-controller": "^3.0.0", + "@smithy/protocol-http": "^4.0.0", + "@smithy/querystring-builder": "^3.0.0", + "@smithy/types": "^3.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" } }, - "node_modules/@humanwhocodes/config-array/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, + "node_modules/@smithy/property-provider": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-3.1.0.tgz", + "integrity": "sha512-Tj3+oVhqdZgemjCiWjFlADfhvLF4C/uKDuKo7/tlEsRQ9+3emCreR2xndj970QSRSsiCEU8hZW3/8JQu+n5w4Q==", "dependencies": { - "brace-expansion": "^1.1.7" + "@smithy/types": "^3.0.0", + "tslib": "^2.6.2" }, "engines": { - "node": "*" + "node": ">=16.0.0" } }, - "node_modules/@humanwhocodes/module-importer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", - "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", - "dev": true, - "engines": { - "node": ">=12.22" + "node_modules/@smithy/protocol-http": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-4.0.0.tgz", + "integrity": "sha512-qOQZOEI2XLWRWBO9AgIYuHuqjZ2csyr8/IlgFDHDNuIgLAMRx2Bl8ck5U5D6Vh9DPdoaVpuzwWMa0xcdL4O/AQ==", + "dependencies": { + "@smithy/types": "^3.0.0", + "tslib": "^2.6.2" }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" + "engines": { + "node": ">=16.0.0" } }, - "node_modules/@humanwhocodes/object-schema": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", - "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", - "dev": true - }, - "node_modules/@istanbuljs/load-nyc-config": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", - "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", - "dev": true, + "node_modules/@smithy/querystring-builder": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-3.0.0.tgz", + "integrity": "sha512-bW8Fi0NzyfkE0TmQphDXr1AmBDbK01cA4C1Z7ggwMAU5RDz5AAv/KmoRwzQAS0kxXNf/D2ALTEgwK0U2c4LtRg==", "dependencies": { - "camelcase": "^5.3.1", - "find-up": "^4.1.0", - "get-package-type": "^0.1.0", - "js-yaml": "^3.13.1", - "resolve-from": "^5.0.0" + "@smithy/types": "^3.0.0", + "@smithy/util-uri-escape": "^3.0.0", + "tslib": "^2.6.2" }, "engines": { - "node": ">=8" + "node": ">=16.0.0" } }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", - "dev": true, + "node_modules/@smithy/querystring-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-3.0.0.tgz", + "integrity": "sha512-UzHwthk0UEccV4dHzPySnBy34AWw3V9lIqUTxmozQ+wPDAO9csCWMfOLe7V9A2agNYy7xE+Pb0S6K/J23JSzfQ==", "dependencies": { - "sprintf-js": "~1.0.2" + "@smithy/types": "^3.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" } }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/find-up": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", - "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", - "dev": true, + "node_modules/@smithy/service-error-classification": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-3.0.0.tgz", + "integrity": "sha512-3BsBtOUt2Gsnc3X23ew+r2M71WwtpHfEDGhHYHSDg6q1t8FrWh15jT25DLajFV1H+PpxAJ6gqe9yYeRUsmSdFA==", "dependencies": { - "locate-path": "^5.0.0", - "path-exists": "^4.0.0" + "@smithy/types": "^3.0.0" }, "engines": { - "node": ">=8" + "node": ">=16.0.0" } }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { - "version": "3.14.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", - "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", - "dev": true, + "node_modules/@smithy/shared-ini-file-loader": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-3.1.0.tgz", + "integrity": "sha512-dAM7wSX0NR3qTNyGVN/nwwpEDzfV9T/3AN2eABExWmda5VqZKSsjlINqomO5hjQWGv+IIkoXfs3u2vGSNz8+Rg==", "dependencies": { - "argparse": "^1.0.7", - "esprima": "^4.0.0" + "@smithy/types": "^3.0.0", + "tslib": "^2.6.2" }, - "bin": { - "js-yaml": "bin/js-yaml.js" + "engines": { + "node": ">=16.0.0" } }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/locate-path": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", - "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", - "dev": true, + "node_modules/@smithy/signature-v4": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-3.0.0.tgz", + "integrity": "sha512-kXFOkNX+BQHe2qnLxpMEaCRGap9J6tUGLzc3A9jdn+nD4JdMwCKTJ+zFwQ20GkY+mAXGatyTw3HcoUlR39HwmA==", "dependencies": { - "p-locate": "^4.1.0" + "@smithy/is-array-buffer": "^3.0.0", + "@smithy/types": "^3.0.0", + "@smithy/util-hex-encoding": "^3.0.0", + "@smithy/util-middleware": "^3.0.0", + "@smithy/util-uri-escape": "^3.0.0", + "@smithy/util-utf8": "^3.0.0", + "tslib": "^2.6.2" }, "engines": { - "node": ">=8" + "node": ">=16.0.0" } }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/p-limit": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", - "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", - "dev": true, + "node_modules/@smithy/smithy-client": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-3.1.1.tgz", + "integrity": "sha512-tj4Ku7MpzZR8cmVuPcSbrLFVxmptWktmJMwST/uIEq4sarabEdF8CbmQdYB7uJ/X51Qq2EYwnRsoS7hdR4B7rA==", "dependencies": { - "p-try": "^2.0.0" + "@smithy/middleware-endpoint": "^3.0.1", + "@smithy/middleware-stack": "^3.0.0", + "@smithy/protocol-http": "^4.0.0", + "@smithy/types": "^3.0.0", + "@smithy/util-stream": "^3.0.1", + "tslib": "^2.6.2" }, "engines": { - "node": ">=6" + "node": ">=16.0.0" + } + }, + "node_modules/@smithy/types": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-3.0.0.tgz", + "integrity": "sha512-VvWuQk2RKFuOr98gFhjca7fkBS+xLLURT8bUjk5XQoV0ZLm7WPwWPPY3/AwzTLuUBDeoKDCthfe1AsTUWaSEhw==", + "dependencies": { + "tslib": "^2.6.2" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "engines": { + "node": ">=16.0.0" } }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/p-locate": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", - "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", - "dev": true, + "node_modules/@smithy/url-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-3.0.0.tgz", + "integrity": "sha512-2XLazFgUu+YOGHtWihB3FSLAfCUajVfNBXGGYjOaVKjLAuAxx3pSBY3hBgLzIgB17haf59gOG3imKqTy8mcrjw==", "dependencies": { - "p-limit": "^2.2.0" + "@smithy/querystring-parser": "^3.0.0", + "@smithy/types": "^3.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@smithy/util-base64": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-base64/-/util-base64-3.0.0.tgz", + "integrity": "sha512-Kxvoh5Qtt0CDsfajiZOCpJxgtPHXOKwmM+Zy4waD43UoEMA+qPxxa98aE/7ZhdnBFZFXMOiBR5xbcaMhLtznQQ==", + "dependencies": { + "@smithy/util-buffer-from": "^3.0.0", + "@smithy/util-utf8": "^3.0.0", + "tslib": "^2.6.2" }, "engines": { - "node": ">=8" + "node": ">=16.0.0" } }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/resolve-from": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", - "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", - "dev": true, - "engines": { - "node": ">=8" + "node_modules/@smithy/util-body-length-browser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-body-length-browser/-/util-body-length-browser-3.0.0.tgz", + "integrity": "sha512-cbjJs2A1mLYmqmyVl80uoLTJhAcfzMOyPgjwAYusWKMdLeNtzmMz9YxNl3/jRLoxSS3wkqkf0jwNdtXWtyEBaQ==", + "dependencies": { + "tslib": "^2.6.2" } }, - "node_modules/@istanbuljs/nyc-config-typescript": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@istanbuljs/nyc-config-typescript/-/nyc-config-typescript-1.0.2.tgz", - "integrity": "sha512-iKGIyMoyJuFnJRSVTZ78POIRvNnwZaWIf8vG4ZS3rQq58MMDrqEX2nnzx0R28V2X8JvmKYiqY9FP2hlJsm8A0w==", - "dev": true, + "node_modules/@smithy/util-body-length-node": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-body-length-node/-/util-body-length-node-3.0.0.tgz", + "integrity": "sha512-Tj7pZ4bUloNUP6PzwhN7K386tmSmEET9QtQg0TgdNOnxhZvCssHji+oZTUIuzxECRfG8rdm2PMw2WCFs6eIYkA==", "dependencies": { - "@istanbuljs/schema": "^0.1.2" + "tslib": "^2.6.2" }, "engines": { - "node": ">=8" - }, - "peerDependencies": { - "nyc": ">=15" + "node": ">=16.0.0" } }, - "node_modules/@istanbuljs/schema": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", - "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", - "dev": true, + "node_modules/@smithy/util-buffer-from": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-3.0.0.tgz", + "integrity": "sha512-aEOHCgq5RWFbP+UDPvPot26EJHjOC+bRgse5A8V3FSShqd5E5UN4qc7zkwsvJPPAVsf73QwYcHN1/gt/rtLwQA==", + "dependencies": { + "@smithy/is-array-buffer": "^3.0.0", + "tslib": "^2.6.2" + }, "engines": { - "node": ">=8" + "node": ">=16.0.0" } }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz", - "integrity": "sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==", - "dev": true, + "node_modules/@smithy/util-config-provider": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-config-provider/-/util-config-provider-3.0.0.tgz", + "integrity": "sha512-pbjk4s0fwq3Di/ANL+rCvJMKM5bzAQdE5S/6RL5NXgMExFAi6UgQMPOm5yPaIWPpr+EOXKXRonJ3FoxKf4mCJQ==", "dependencies": { - "@jridgewell/set-array": "^1.2.1", - "@jridgewell/sourcemap-codec": "^1.4.10", - "@jridgewell/trace-mapping": "^0.3.24" + "tslib": "^2.6.2" }, "engines": { - "node": ">=6.0.0" + "node": ">=16.0.0" } }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, + "node_modules/@smithy/util-defaults-mode-browser": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-3.0.3.tgz", + "integrity": "sha512-3DFON2bvXJAukJe+qFgPV/rorG7ZD3m4gjCXHD1V5z/tgKQp5MCTCLntrd686tX6tj8Uli3lefWXJudNg5WmCA==", + "dependencies": { + "@smithy/property-provider": "^3.1.0", + "@smithy/smithy-client": "^3.1.1", + "@smithy/types": "^3.0.0", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + }, "engines": { - "node": ">=6.0.0" + "node": ">= 10.0.0" } }, - "node_modules/@jridgewell/set-array": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", - "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", - "dev": true, + "node_modules/@smithy/util-defaults-mode-node": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-3.0.3.tgz", + "integrity": "sha512-D0b8GJXecT00baoSQ3Iieu3k3mZ7GY8w1zmg8pdogYrGvWJeLcIclqk2gbkG4K0DaBGWrO6v6r20iwIFfDYrmA==", + "dependencies": { + "@smithy/config-resolver": "^3.0.1", + "@smithy/credential-provider-imds": "^3.1.0", + "@smithy/node-config-provider": "^3.1.0", + "@smithy/property-provider": "^3.1.0", + "@smithy/smithy-client": "^3.1.1", + "@smithy/types": "^3.0.0", + "tslib": "^2.6.2" + }, "engines": { - "node": ">=6.0.0" + "node": ">= 10.0.0" } }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.4.15", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", - "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==", - "dev": true - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.25", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", - "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", - "dev": true, + "node_modules/@smithy/util-endpoints": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-2.0.1.tgz", + "integrity": "sha512-ZRT0VCOnKlVohfoABMc8lWeQo/JEFuPWctfNRXgTHbyOVssMOLYFUNWukxxiHRGVAhV+n3c0kPW+zUqckjVPEA==", "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" + "@smithy/node-config-provider": "^3.1.0", + "@smithy/types": "^3.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" } }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "dev": true, + "node_modules/@smithy/util-hex-encoding": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-3.0.0.tgz", + "integrity": "sha512-eFndh1WEK5YMUYvy3lPlVmYY/fZcQE1D8oSf41Id2vCeIkKJXPcYDCZD+4+xViI6b1XSd7tE+s5AmXzz5ilabQ==", "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" + "tslib": "^2.6.2" }, "engines": { - "node": ">= 8" + "node": ">=16.0.0" } }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "dev": true, + "node_modules/@smithy/util-middleware": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-3.0.0.tgz", + "integrity": "sha512-q5ITdOnV2pXHSVDnKWrwgSNTDBAMHLptFE07ua/5Ty5WJ11bvr0vk2a7agu7qRhrCFRQlno5u3CneU5EELK+DQ==", + "dependencies": { + "@smithy/types": "^3.0.0", + "tslib": "^2.6.2" + }, "engines": { - "node": ">= 8" + "node": ">=16.0.0" } }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dev": true, + "node_modules/@smithy/util-retry": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-3.0.0.tgz", + "integrity": "sha512-nK99bvJiziGv/UOKJlDvFF45F00WgPLKVIGUfAK+mDhzVN2hb/S33uW2Tlhg5PVBoqY7tDVqL0zmu4OxAHgo9g==", "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" + "@smithy/service-error-classification": "^3.0.0", + "@smithy/types": "^3.0.0", + "tslib": "^2.6.2" }, "engines": { - "node": ">= 8" + "node": ">=16.0.0" } }, - "node_modules/@sinonjs/commons": { + "node_modules/@smithy/util-stream": { "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", - "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", - "dev": true, + "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-3.0.1.tgz", + "integrity": "sha512-7F7VNNhAsfMRA8I986YdOY5fE0/T1/ZjFF6OLsqkvQVNP3vZ/szYDfGCyphb7ioA09r32K/0qbSFfNFU68aSzA==", "dependencies": { - "type-detect": "4.0.8" + "@smithy/fetch-http-handler": "^3.0.1", + "@smithy/node-http-handler": "^3.0.0", + "@smithy/types": "^3.0.0", + "@smithy/util-base64": "^3.0.0", + "@smithy/util-buffer-from": "^3.0.0", + "@smithy/util-hex-encoding": "^3.0.0", + "@smithy/util-utf8": "^3.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" } }, - "node_modules/@sinonjs/fake-timers": { - "version": "11.2.2", - "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-11.2.2.tgz", - "integrity": "sha512-G2piCSxQ7oWOxwGSAyFHfPIsyeJGXYtc6mFbnFA+kRXkiEnTl8c/8jul2S329iFBnDI9HGoeWWAZvuvOkZccgw==", - "dev": true, + "node_modules/@smithy/util-uri-escape": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-uri-escape/-/util-uri-escape-3.0.0.tgz", + "integrity": "sha512-LqR7qYLgZTD7nWLBecUi4aqolw8Mhza9ArpNEQ881MJJIU2sE5iHCK6TdyqqzcDLy0OPe10IY4T8ctVdtynubg==", "dependencies": { - "@sinonjs/commons": "^3.0.0" + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" } }, - "node_modules/@sinonjs/samsam": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-8.0.0.tgz", - "integrity": "sha512-Bp8KUVlLp8ibJZrnvq2foVhP0IVX2CIprMJPK0vqGqgrDa0OHVKeZyBykqskkrdxV6yKBPmGasO8LVjAKR3Gew==", - "dev": true, + "node_modules/@smithy/util-utf8": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-3.0.0.tgz", + "integrity": "sha512-rUeT12bxFnplYDe815GXbq/oixEGHfRFFtcTF3YdDi/JaENIM6aSYYLJydG83UNzLXeRI5K8abYd/8Sp/QM0kA==", "dependencies": { - "@sinonjs/commons": "^2.0.0", - "lodash.get": "^4.4.2", - "type-detect": "^4.0.8" + "@smithy/util-buffer-from": "^3.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" } }, - "node_modules/@sinonjs/samsam/node_modules/@sinonjs/commons": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-2.0.0.tgz", - "integrity": "sha512-uLa0j859mMrg2slwQYdO/AkrOfmH+X6LTVmNTS9CqexuE2IvVORIkSpJLqePAbEnKJ77aMmCwr1NUZ57120Xcg==", - "dev": true, + "node_modules/@smithy/util-waiter": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-waiter/-/util-waiter-3.0.0.tgz", + "integrity": "sha512-+fEXJxGDLCoqRKVSmo0auGxaqbiCo+8oph+4auefYjaNxjOLKSY2MxVQfRzo65PaZv4fr+5lWg+au7vSuJJ/zw==", "dependencies": { - "type-detect": "4.0.8" + "@smithy/abort-controller": "^3.0.0", + "@smithy/types": "^3.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" } }, - "node_modules/@sinonjs/text-encoding": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/@sinonjs/text-encoding/-/text-encoding-0.7.2.tgz", - "integrity": "sha512-sXXKG+uL9IrKqViTtao2Ws6dy0znu9sOaP1di/jKGW1M6VssO8vlpXCQcpZ+jisQ1tTFAC5Jo/EOzFbggBagFQ==", - "dev": true - }, "node_modules/@tsconfig/node10": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz", @@ -1780,6 +3287,7 @@ "version": "1.0.7", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, "dependencies": { "possible-typed-array-names": "^1.0.0" }, @@ -1790,47 +3298,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/aws-sdk": { - "version": "2.1631.0", - "resolved": "https://registry.npmjs.org/aws-sdk/-/aws-sdk-2.1631.0.tgz", - "integrity": "sha512-QG1A1bsgy9jKyY20LVxEeB16zEbn3dPIZmVeh/7vk6ukb2+5Vjh0s+E5bVlNHFFXVu6rsviayqGmjr16fJ5I1g==", - "hasInstallScript": true, - "dependencies": { - "buffer": "4.9.2", - "events": "1.1.1", - "ieee754": "1.1.13", - "jmespath": "0.16.0", - "querystring": "0.2.0", - "sax": "1.2.1", - "url": "0.10.3", - "util": "^0.12.4", - "uuid": "8.0.0", - "xml2js": "0.6.2" - }, - "engines": { - "node": ">= 10.0.0" - } - }, - "node_modules/aws-sdk/node_modules/uuid": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.0.0.tgz", - "integrity": "sha512-jOXGuXZAWdsTH7eZLtyXMqUb9EcWMGZNbL9YcGBJl4MH4nrxHmZJhEHvyLFrkxo+28uLb/NYRcStH48fnD0Vzw==", - "bin": { - "uuid": "dist/bin/uuid" - } - }, - "node_modules/aws-sdk/node_modules/xml2js": { - "version": "0.6.2", - "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz", - "integrity": "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==", - "dependencies": { - "sax": ">=0.6.0", - "xmlbuilder": "~11.0.0" - }, - "engines": { - "node": ">=4.0.0" - } - }, "node_modules/axios": { "version": "1.6.8", "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.8.tgz", @@ -1846,25 +3313,6 @@ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" }, - "node_modules/base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] - }, "node_modules/bcrypt-pbkdf": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", @@ -1940,6 +3388,11 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/bowser": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.11.0.tgz", + "integrity": "sha512-AlcaJBi/pqqJBIQ8U9Mcpc9i8Aqxn88Skv5d+xBX006BY5u8N3mGLHa5Lgppa7L/HfwgwLgZ6NYs+Ag6uUmJRA==" + }, "node_modules/brace-expansion": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", @@ -1997,16 +3450,6 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, - "node_modules/buffer": { - "version": "4.9.2", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-4.9.2.tgz", - "integrity": "sha512-xq+q3SRMOxGivLhBNaUdC64hDTQwejJ+H0T/NB1XMtTVEwNTrfFF3gAxiyW0Bu/xWEGhjVKgUcMhCrUy2+uCWg==", - "dependencies": { - "base64-js": "^1.0.2", - "ieee754": "^1.1.4", - "isarray": "^1.0.0" - } - }, "node_modules/buffer-equal-constant-time": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", @@ -3195,14 +4638,6 @@ "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==" }, - "node_modules/events": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/events/-/events-1.1.1.tgz", - "integrity": "sha512-kEcvvCBByWXGnZy6JUlgAp2gBIUjfCAV6P6TgT1/aaQKcmuAEC4OZTV1I4EWQLz2gxZw76atuVyvHhTxvi0Flw==", - "engines": { - "node": ">=0.4.x" - } - }, "node_modules/express": { "version": "4.19.2", "resolved": "https://registry.npmjs.org/express/-/express-4.19.2.tgz", @@ -3525,6 +4960,7 @@ "version": "0.3.3", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", "integrity": "sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==", + "dev": true, "dependencies": { "is-callable": "^1.1.3" } @@ -3896,6 +5332,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, "dependencies": { "has-symbols": "^1.0.3" }, @@ -4025,11 +5462,6 @@ "node": ">=0.10.0" } }, - "node_modules/ieee754": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.1.13.tgz", - "integrity": "sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg==" - }, "node_modules/ignore": { "version": "5.3.1", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.1.tgz", @@ -4136,21 +5568,6 @@ "node": ">= 0.10" } }, - "node_modules/is-arguments": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.1.1.tgz", - "integrity": "sha512-8Q7EARjzEnKpt/PCD7e1cgUS0a6X8u5tdSiMqXhojOdoV9TsMsiO+9VLC5vAmO8N7/GmXn7yjR8qnA6bVAEzfA==", - "dependencies": { - "call-bind": "^1.0.2", - "has-tostringtag": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/is-array-buffer": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.4.tgz", @@ -4217,6 +5634,7 @@ "version": "1.2.7", "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, "engines": { "node": ">= 0.4" }, @@ -4286,20 +5704,6 @@ "node": ">=8" } }, - "node_modules/is-generator-function": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.0.10.tgz", - "integrity": "sha512-jsEjy9l3yiXEQ+PsXdmBwEPcOxaXWLspKdplFUVI9vq1iZgIekeC0L167qeu86czQaxed3q/Uzuw0swL0irL8A==", - "dependencies": { - "has-tostringtag": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -4442,6 +5846,7 @@ "version": "1.1.13", "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.13.tgz", "integrity": "sha512-uZ25/bUAlUY5fR4OKT4rZQEBrzQWYV9ZJYGGsUmEJ6thodVJ1HX64ePQ6Z0qPWP+m+Uq6e9UugrE38jeYsDSMw==", + "dev": true, "dependencies": { "which-typed-array": "^1.1.14" }, @@ -4625,14 +6030,6 @@ "node": ">=8" } }, - "node_modules/jmespath": { - "version": "0.16.0", - "resolved": "https://registry.npmjs.org/jmespath/-/jmespath-0.16.0.tgz", - "integrity": "sha512-9FzQjJ7MATs1tSpnco1K6ayiYE3figslrXA72G2HQ/n76RzvYlofyi5QM+iX4YRs/pu3yzxlVQSST23+dMDknw==", - "engines": { - "node": ">= 0.6.0" - } - }, "node_modules/jose": { "version": "4.15.5", "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.5.tgz", @@ -6510,6 +7907,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz", "integrity": "sha512-d7Uw+eZoloe0EHDIYoe+bQ5WXnGMOpmiZFTuMWCwpjzzkL2nTjcKiAk4hh8TjnGye2TwWOk3UXucZ+3rbmBa8Q==", + "dev": true, "engines": { "node": ">= 0.4" } @@ -6721,15 +8119,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/querystring": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/querystring/-/querystring-0.2.0.tgz", - "integrity": "sha512-X/xY82scca2tau62i9mDyU9K+I+djTMUsvwf7xnUX5GLvVzgJybOJf4Y6o9Zx3oJK/LSXg5tTZBjwzqVPaPO2g==", - "deprecated": "The querystring API is considered Legacy. new code should use the URLSearchParams API instead.", - "engines": { - "node": ">=0.4.x" - } - }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -7950,6 +9339,11 @@ "json5": "lib/cli.js" } }, + "node_modules/tslib": { + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz", + "integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==" + }, "node_modules/tunnel-ssh": { "version": "4.1.6", "resolved": "https://registry.npmjs.org/tunnel-ssh/-/tunnel-ssh-4.1.6.tgz", @@ -8194,32 +9588,6 @@ "punycode": "^2.1.0" } }, - "node_modules/url": { - "version": "0.10.3", - "resolved": "https://registry.npmjs.org/url/-/url-0.10.3.tgz", - "integrity": "sha512-hzSUW2q06EqL1gKM/a+obYHLIO6ct2hwPuviqTTOcfFVc61UbfJ2Q32+uGL/HCPxKqrdGB5QUwIe7UqlDgwsOQ==", - "dependencies": { - "punycode": "1.3.2", - "querystring": "0.2.0" - } - }, - "node_modules/url/node_modules/punycode": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.3.2.tgz", - "integrity": "sha512-RofWgt/7fL5wP1Y7fxE7/EmTLzQVnB0ycyibJ0OOHIlJqTNzglYFxVwETOcIoJqJmpDXJ9xImDv+Fq34F/d4Dw==" - }, - "node_modules/util": { - "version": "0.12.5", - "resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz", - "integrity": "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==", - "dependencies": { - "inherits": "^2.0.3", - "is-arguments": "^1.0.4", - "is-generator-function": "^1.0.7", - "is-typed-array": "^1.1.3", - "which-typed-array": "^1.1.2" - } - }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -8315,6 +9683,7 @@ "version": "1.1.15", "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.15.tgz", "integrity": "sha512-oV0jmFtUky6CXfkqehVvBP/LSWJ2sy4vWMioiENyJLePrBO/yKyV9OyJySfAKosh+RYkIl5zJCNZ8/4JncrpdA==", + "dev": true, "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.7", diff --git a/api/package.json b/api/package.json index 65c5d28d84..fba3cdd63d 100644 --- a/api/package.json +++ b/api/package.json @@ -26,13 +26,14 @@ "npm": ">= 10.0.0" }, "dependencies": { + "@aws-sdk/client-s3": "^3.583.0", + "@aws-sdk/s3-request-presigner": "^3.583.0", "@turf/bbox": "^6.5.0", "@turf/circle": "^6.5.0", "@turf/helpers": "^6.5.0", "@turf/meta": "^6.5.0", "adm-zip": "^0.5.5", "ajv": "^8.12.0", - "aws-sdk": "^2.1565.0", "axios": "^1.6.7", "clamscan": "^2.2.1", "dayjs": "^1.11.10", diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/delete.test.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/delete.test.ts index ac6a53b6ac..39b1509b61 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/delete.test.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/delete.test.ts @@ -1,4 +1,4 @@ -import { S3 } from 'aws-sdk'; +import { DeleteObjectCommandOutput } from '@aws-sdk/client-s3'; import chai, { expect } from 'chai'; import { describe } from 'mocha'; import sinon from 'sinon'; @@ -66,7 +66,7 @@ describe('deleteSurvey', () => { const fileUtilsStub = sinon .stub(file_utils, 'deleteFileFromS3') - .resolves(false as unknown as S3.DeleteObjectOutput); + .resolves(false as unknown as DeleteObjectCommandOutput); let actualResult: any = null; const sampleRes = { @@ -107,7 +107,9 @@ describe('deleteSurvey', () => { const deleteSurveyStub = sinon.stub(SurveyService.prototype, 'deleteSurvey').resolves(); - const fileUtilsStub = sinon.stub(file_utils, 'deleteFileFromS3').resolves(true as unknown as S3.DeleteObjectOutput); + const fileUtilsStub = sinon + .stub(file_utils, 'deleteFileFromS3') + .resolves(true as unknown as DeleteObjectCommandOutput); let actualResult: any = null; const sampleRes = { diff --git a/api/src/paths/resources/list.test.ts b/api/src/paths/resources/list.test.ts index d312c0ec90..0cec5e780b 100644 --- a/api/src/paths/resources/list.test.ts +++ b/api/src/paths/resources/list.test.ts @@ -21,6 +21,7 @@ describe('listResources', () => { it('returns an empty array if no resources are found', async () => { const listFilesStub = sinon.stub(fileUtils, 'listFilesFromS3').resolves({ + $metadata: {}, Contents: [] }); @@ -35,7 +36,7 @@ describe('listResources', () => { }); it('returns an array of resources', async () => { - const mockMetadata = { + const mockMetadata: Record> = { ['key1']: { 'template-name': 'name1', 'template-type': 'type1', @@ -55,11 +56,14 @@ describe('listResources', () => { sinon.stub(fileUtils, 'getObjectMeta').callsFake((key: string) => { return Promise.resolve({ + $metadata: {}, + Conents: [], Metadata: mockMetadata[key] }); }); const listFilesStub = sinon.stub(fileUtils, 'listFilesFromS3').resolves({ + $metadata: {}, Contents: [ { Key: 'key1', @@ -89,7 +93,7 @@ describe('listResources', () => { files: [ { fileName: 'key1', - url: 's3.host.example.com/test-bucket/key1', + url: 'https://s3.host.example.com/test-bucket/key1', lastModified: new Date('2023-01-01').toISOString(), fileSize: 5, metadata: { @@ -100,7 +104,7 @@ describe('listResources', () => { }, { fileName: 'key2', - url: 's3.host.example.com/test-bucket/key2', + url: 'https://s3.host.example.com/test-bucket/key2', lastModified: new Date('2023-01-02').toISOString(), fileSize: 10, metadata: { @@ -111,7 +115,7 @@ describe('listResources', () => { }, { fileName: 'key3', - url: 's3.host.example.com/test-bucket/key3', + url: 'https://s3.host.example.com/test-bucket/key3', lastModified: new Date('2023-01-03').toISOString(), fileSize: 15, metadata: { @@ -126,14 +130,13 @@ describe('listResources', () => { }); it('should filter out directories from the s3 list respones', async () => { - sinon.stub(fileUtils, 'getObjectMeta').resolves({}); + sinon.stub(fileUtils, 'getObjectMeta').resolves({ + $metadata: {} + }); const listFilesStub = sinon.stub(fileUtils, 'listFilesFromS3').resolves({ - Contents: [ - { - Key: 'templates/Current/' - } - ] + $metadata: {}, + Contents: [{ Key: 'templates/Current/' }] }); const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); diff --git a/api/src/paths/resources/list.ts b/api/src/paths/resources/list.ts index b3ea45fb8c..2147075208 100644 --- a/api/src/paths/resources/list.ts +++ b/api/src/paths/resources/list.ts @@ -1,4 +1,3 @@ -import { Object as S3Object } from 'aws-sdk/clients/s3'; import { RequestHandler } from 'express'; import { Operation } from 'express-openapi'; import { getObjectMeta, getS3HostUrl, listFilesFromS3 } from '../../utils/file-utils'; @@ -94,8 +93,8 @@ export function listResources(): RequestHandler { * which fetch the metadata for each object in the list. */ const filePromises = (response?.Contents || []) - .filter((file: S3Object) => !file.Key?.endsWith('/')) - .map(async (file: S3Object) => { + .filter((file) => !file.Key?.endsWith('/')) + .map(async (file) => { let metadata = {}; let fileName = ''; diff --git a/api/src/services/attachment-service.test.ts b/api/src/services/attachment-service.test.ts index 6d0013a780..7bcc38d4d6 100644 --- a/api/src/services/attachment-service.test.ts +++ b/api/src/services/attachment-service.test.ts @@ -1,4 +1,4 @@ -import AWS from 'aws-sdk'; +import { DeleteObjectCommandOutput } from '@aws-sdk/client-s3'; import chai, { expect } from 'chai'; import { describe } from 'mocha'; import { QueryResult } from 'pg'; @@ -17,6 +17,7 @@ import { } from '../repositories/attachment-repository'; import { SurveyAttachmentPublish, SurveyReportPublish } from '../repositories/history-publish-repository'; import { getMockDBConnection } from '../__mocks__/db'; +import * as fileUtils from './../utils/file-utils'; import { AttachmentService } from './attachment-service'; import { HistoryPublishService } from './history-publish-service'; @@ -289,7 +290,7 @@ describe('AttachmentService', () => { const getProjectReportStub = sinon .stub(AttachmentService.prototype, 'getProjectReportAttachmentById') .resolves({ - key: 'key', + key: 's3_key', uuid: 'uuid', project_report_attachment_id: 1 } as unknown as IProjectReportAttachment); @@ -306,21 +307,20 @@ describe('AttachmentService', () => { .stub(AttachmentService.prototype, '_deleteProjectAttachmentRecord') .resolves(); - const mockS3Client = new AWS.S3(); - sinon.stub(AWS, 'S3').returns(mockS3Client); - const deleteS3 = sinon.stub(mockS3Client, 'deleteObject').returns({ - promise: () => - Promise.resolve({ - DeleteMarker: true - }) - } as AWS.Request); + const mockDeleteResponse: DeleteObjectCommandOutput = { + $metadata: {}, + DeleteMarker: true, + RequestCharged: 'requester', + VersionId: '123456' + }; + const deleteFileFromS3Stub = sinon.stub(fileUtils, 'deleteFileFromS3').resolves(mockDeleteResponse); await service.deleteProjectAttachment(1, 1, ATTACHMENT_TYPE.REPORT); expect(getProjectReportStub).to.be.called; expect(deleteProjectReportAuthorsStub).to.be.called; expect(deleteProjectReportAttachmentStub).to.be.called; - expect(deleteS3).to.be.called; + expect(deleteFileFromS3Stub).to.be.calledOnceWith('s3_key'); expect(deleteProjectAttachmentStub).to.not.be.called; expect(getProjectAttachmentStub).to.not.be.called; @@ -335,7 +335,7 @@ describe('AttachmentService', () => { const getProjectReportStub = sinon .stub(AttachmentService.prototype, 'getProjectReportAttachmentById') .resolves({ - key: 'key', + key: 's3_key', uuid: 'uuid', project_report_attachment_id: 1 } as unknown as IProjectReportAttachment); @@ -348,7 +348,7 @@ describe('AttachmentService', () => { const getProjectAttachmentStub = sinon .stub(AttachmentService.prototype, 'getProjectAttachmentById') .resolves({ - key: 'key', + key: 's3_key', uuid: 'uuid', project_attachment_id: 1 } as unknown as IProjectAttachment); @@ -356,20 +356,19 @@ describe('AttachmentService', () => { .stub(AttachmentService.prototype, '_deleteProjectAttachmentRecord') .resolves(); - const mockS3Client = new AWS.S3(); - sinon.stub(AWS, 'S3').returns(mockS3Client); - const deleteS3 = sinon.stub(mockS3Client, 'deleteObject').returns({ - promise: () => - Promise.resolve({ - DeleteMarker: true - }) - } as AWS.Request); + const mockDeleteResponse: DeleteObjectCommandOutput = { + $metadata: {}, + DeleteMarker: true, + RequestCharged: 'requester', + VersionId: '123456' + }; + const deleteFileFromS3Stub = sinon.stub(fileUtils, 'deleteFileFromS3').resolves(mockDeleteResponse); await service.deleteProjectAttachment(1, 1, ATTACHMENT_TYPE.OTHER); expect(getProjectAttachmentStub).to.be.called; expect(deleteProjectAttachmentStub).to.be.called; - expect(deleteS3).to.be.called; + expect(deleteFileFromS3Stub).to.be.calledOnceWith('s3_key'); expect(getProjectReportStub).to.not.be.called; expect(deleteProjectReportAuthorsStub).to.not.be.called; @@ -739,7 +738,7 @@ describe('AttachmentService', () => { survey_report_attachment_id: 1, survey_attachment_id: 1, uuid: 'uuid', - key: 's3/key' + key: 's3_key' } as unknown as ISurveyAttachment); const deleteSurveyReportPublishStub = sinon .stub(HistoryPublishService.prototype, 'deleteSurveyReportAttachmentPublishRecord') @@ -764,24 +763,23 @@ describe('AttachmentService', () => { survey_report_attachment_id: 1, survey_attachment_id: 1, uuid: 'uuid', - key: 's3/key' + key: 's3_key' } as unknown as ISurveyReportAttachment); - const mockS3Client = new AWS.S3(); - sinon.stub(AWS, 'S3').returns(mockS3Client); - const deleteS3 = sinon.stub(mockS3Client, 'deleteObject').returns({ - promise: () => - Promise.resolve({ - DeleteMarker: true - }) - } as AWS.Request); + const mockDeleteResponse: DeleteObjectCommandOutput = { + $metadata: {}, + DeleteMarker: true, + RequestCharged: 'requester', + VersionId: '123456' + }; + const deleteFileFromS3Stub = sinon.stub(fileUtils, 'deleteFileFromS3').resolves(mockDeleteResponse); await service.deleteSurveyAttachment(1, 1, ATTACHMENT_TYPE.OTHER); expect(getSurveyAttachmentStub).to.be.called; expect(attachmentPublishDeleteStub).to.be.called; expect(deleteSurveyAttachmentStub).to.be.called; - expect(deleteS3).to.be.called; + expect(deleteFileFromS3Stub).to.be.calledOnceWith('s3_key'); expect(getSurveyReportStub).to.be.not.called; expect(deleteSurveyReportPublishStub).to.be.not.called; @@ -801,7 +799,7 @@ describe('AttachmentService', () => { survey_report_attachment_id: 1, survey_attachment_id: 1, uuid: 'uuid', - key: 's3/key' + key: 's3_key' } as unknown as ISurveyAttachment); const attachmentPublishStatusStub = sinon .stub(HistoryPublishService.prototype, 'getSurveyAttachmentPublishRecord') @@ -827,17 +825,16 @@ describe('AttachmentService', () => { survey_report_attachment_id: 1, survey_attachment_id: 1, uuid: 'uuid', - key: 's3/key' + key: 's3_key' } as unknown as ISurveyReportAttachment); - const mockS3Client = new AWS.S3(); - sinon.stub(AWS, 'S3').returns(mockS3Client); - const deleteS3 = sinon.stub(mockS3Client, 'deleteObject').returns({ - promise: () => - Promise.resolve({ - DeleteMarker: true - }) - } as AWS.Request); + const mockDeleteResponse: DeleteObjectCommandOutput = { + $metadata: {}, + DeleteMarker: true, + RequestCharged: 'requester', + VersionId: '123456' + }; + const deleteFileFromS3Stub = sinon.stub(fileUtils, 'deleteFileFromS3').resolves(mockDeleteResponse); await service.deleteSurveyAttachment(1, 1, ATTACHMENT_TYPE.REPORT); @@ -845,7 +842,7 @@ describe('AttachmentService', () => { expect(deleteSurveyReportPublishStub).to.be.called; expect(deleteSurveyReportAuthorsStub).to.be.called; expect(deleteSurveyReportStub).to.be.called; - expect(deleteS3).to.be.called; + expect(deleteFileFromS3Stub).to.be.calledOnceWith('s3_key'); expect(getSurveyAttachmentStub).to.be.not.called; expect(attachmentPublishStatusStub).to.be.not.called; diff --git a/api/src/services/observation-service.ts b/api/src/services/observation-service.ts index b9b889535f..eb6953b9cf 100644 --- a/api/src/services/observation-service.ts +++ b/api/src/services/observation-service.ts @@ -434,7 +434,7 @@ export class ObservationService extends DBService { const s3Object = await getFileFromS3(observationSubmissionRecord.key); // Get the csv file from the S3 object - const mediaFile = parseS3File(s3Object); + const mediaFile = await parseS3File(s3Object); // Validate the CSV file mime type if (mediaFile.mimetype !== 'text/csv') { diff --git a/api/src/services/platform-service.ts b/api/src/services/platform-service.ts index 2fd15c7084..572aa65e74 100644 --- a/api/src/services/platform-service.ts +++ b/api/src/services/platform-service.ts @@ -270,7 +270,10 @@ export class PlatformService extends DBService { const artifact = { submission_uuid: submissionUUID, artifact_upload_key: artifactUploadKey, - data: s3File.Body as Buffer, + // TODO: Cast to unknown required due to issue in aws-sdk v3 typings + // See https://stackoverflow.com/questions/76142043/getting-a-readable-from-getobject-in-aws-s3-sdk-v3 + // See https://github.com/aws/aws-sdk-js-v3/issues/4720 + data: s3File.Body as unknown as Buffer, fileName: attachment.file_name, mimeType: s3File.ContentType || mime.getType(attachment.file_name) || 'application/octet-stream' }; @@ -321,7 +324,10 @@ export class PlatformService extends DBService { const artifact = { submission_uuid: submissionUUID, artifact_upload_key: artifactUploadKey, - data: s3File.Body as Buffer, + // TODO: Cast to unknown required due to issue in aws-sdk v3 typings + // See https://stackoverflow.com/questions/76142043/getting-a-readable-from-getobject-in-aws-s3-sdk-v3 + // See https://github.com/aws/aws-sdk-js-v3/issues/4720 + data: s3File.Body as unknown as Buffer, fileName: attachment.file_name, mimeType: s3File.ContentType || mime.getType(attachment.file_name) || 'application/octet-stream' }; diff --git a/api/src/services/telemetry-service.ts b/api/src/services/telemetry-service.ts index 9247df1756..48f17e7d74 100644 --- a/api/src/services/telemetry-service.ts +++ b/api/src/services/telemetry-service.ts @@ -66,7 +66,7 @@ export class TelemetryService extends DBService { const s3Object = await getFileFromS3(submission.key); // step 3 parse the file - const mediaFile = parseS3File(s3Object); + const mediaFile = await parseS3File(s3Object); // step 4 validate csv if (mediaFile.mimetype !== 'text/csv') { diff --git a/api/src/utils/file-utils.test.ts b/api/src/utils/file-utils.test.ts index 386e7b6422..84c4cf315e 100644 --- a/api/src/utils/file-utils.test.ts +++ b/api/src/utils/file-utils.test.ts @@ -1,4 +1,4 @@ -import AWS from 'aws-sdk'; +import { S3Client } from '@aws-sdk/client-s3'; import { expect } from 'chai'; import { describe } from 'mocha'; import { @@ -97,16 +97,16 @@ describe('getS3HostUrl', () => { const result = getS3HostUrl(); - expect(result).to.equal('nrs.objectstore.gov.bc.ca'); + expect(result).to.equal('https://nrs.objectstore.gov.bc.ca'); }); it('should successfully produce an S3 host url', () => { - process.env.OBJECT_STORE_URL = 's3.host.example.com'; + process.env.OBJECT_STORE_URL = 'http://s3.host.example.com'; process.env.OBJECT_STORE_BUCKET_NAME = 'test-bucket-name'; const result = getS3HostUrl(); - expect(result).to.equal('s3.host.example.com/test-bucket-name'); + expect(result).to.equal('http://s3.host.example.com/test-bucket-name'); }); it('should successfully append a key to an S3 host url', () => { @@ -115,7 +115,7 @@ describe('getS3HostUrl', () => { const result = getS3HostUrl('my-test-file.txt'); - expect(result).to.equal('s3.host.example.com/test-bucket-name/my-test-file.txt'); + expect(result).to.equal('https://s3.host.example.com/test-bucket-name/my-test-file.txt'); }); }); @@ -131,7 +131,7 @@ describe('_getS3Client', () => { process.env.OBJECT_STORE_SECRET_KEY_ID = 'bbbb'; const result = _getS3Client(); - expect(result).to.be.instanceOf(AWS.S3); + expect(result).to.be.instanceOf(S3Client); }); }); @@ -185,18 +185,32 @@ describe('_getObjectStoreUrl', () => { process.env.OBJECT_STORE_URL = OBJECT_STORE_URL; }); - it('should return an object store bucket name', () => { - process.env.OBJECT_STORE_URL = 'test-url1'; + it('should return an object store bucket name that http protocol', () => { + process.env.OBJECT_STORE_URL = 'http://s3.host.example.com'; + + const result = _getObjectStoreUrl(); + expect(result).to.equal('http://s3.host.example.com'); + }); + + it('should return an object store bucket name that https protocol', () => { + process.env.OBJECT_STORE_URL = 'https://s3.host.example.com'; + + const result = _getObjectStoreUrl(); + expect(result).to.equal('https://s3.host.example.com'); + }); + + it('should return an object store bucket name that had no protocol', () => { + process.env.OBJECT_STORE_URL = 's3.host.example.com'; const result = _getObjectStoreUrl(); - expect(result).to.equal('test-url1'); + expect(result).to.equal('https://s3.host.example.com'); }); it('should return its default value', () => { delete process.env.OBJECT_STORE_URL; const result = _getObjectStoreUrl(); - expect(result).to.equal('nrs.objectstore.gov.bc.ca'); + expect(result).to.equal('https://nrs.objectstore.gov.bc.ca'); }); }); diff --git a/api/src/utils/file-utils.ts b/api/src/utils/file-utils.ts index 965d45e8f7..dc4e661f91 100644 --- a/api/src/utils/file-utils.ts +++ b/api/src/utils/file-utils.ts @@ -1,12 +1,17 @@ -import AWS from 'aws-sdk'; import { - DeleteObjectOutput, - GetObjectOutput, - HeadObjectOutput, - ListObjectsOutput, - ManagedUpload, - Metadata -} from 'aws-sdk/clients/s3'; + DeleteObjectCommand, + DeleteObjectCommandOutput, + GetObjectCommand, + GetObjectCommandOutput, + HeadObjectCommand, + HeadObjectCommandOutput, + ListObjectsCommand, + ListObjectsCommandOutput, + PutObjectCommand, + PutObjectCommandOutput, + S3Client +} from '@aws-sdk/client-s3'; +import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; import NodeClam from 'clamscan'; import { Readable } from 'stream'; import { getLogger } from './logger'; @@ -30,17 +35,16 @@ export const _getClamAvScanner = async (): Promise => { /** * Local getter for retrieving the S3 client. * - * @returns {*} {AWS.S3} The S3 client + * @return {*} {S3Client} The S3 client */ -export const _getS3Client = (): AWS.S3 => { - const awsEndpoint = new AWS.Endpoint(_getObjectStoreUrl()); - - return new AWS.S3({ - endpoint: awsEndpoint.href, - accessKeyId: process.env.OBJECT_STORE_ACCESS_KEY_ID, - secretAccessKey: process.env.OBJECT_STORE_SECRET_KEY_ID, - signatureVersion: 'v4', - s3ForcePathStyle: true, +export const _getS3Client = (): S3Client => { + return new S3Client({ + endpoint: _getObjectStoreUrl(), + credentials: { + accessKeyId: process.env.OBJECT_STORE_ACCESS_KEY_ID!, + secretAccessKey: process.env.OBJECT_STORE_SECRET_KEY_ID! + }, + forcePathStyle: true, region: 'ca-central-1' }); }; @@ -51,7 +55,13 @@ export const _getS3Client = (): AWS.S3 => { * @returns {*} {string} The object store URL */ export const _getObjectStoreUrl = (): string => { - return process.env.OBJECT_STORE_URL || 'nrs.objectstore.gov.bc.ca'; + const url = process.env.OBJECT_STORE_URL || 'https://nrs.objectstore.gov.bc.ca'; + + if (!['https://', 'http://'].some((protocol) => url.toLowerCase().startsWith(protocol))) { + return `https://${url}`; + } + + return url; }; /** @@ -93,15 +103,20 @@ export const _getS3KeyPrefix = (): string => { * * @export * @param {string} key the unique key assigned to the file in S3 when it was originally uploaded - * @returns {Promise} the response from S3 or null if required parameters are null + * @returns {Promise} the response from S3 or null if required parameters are null */ -export async function deleteFileFromS3(key: string): Promise { +export async function deleteFileFromS3(key: string): Promise { const s3Client = _getS3Client(); if (!key || !s3Client) { return null; } - return s3Client.deleteObject({ Bucket: _getObjectStoreBucketName(), Key: key }).promise(); + return s3Client.send( + new DeleteObjectCommand({ + Bucket: _getObjectStoreBucketName(), + Key: key + }) + ); } /** @@ -110,44 +125,54 @@ export async function deleteFileFromS3(key: string): Promise} the response from S3 or null if required parameters are null + * @param {Record} [metadata={}] A metadata object to store additional information with the file + * @returns {Promise} the response from S3 or null if required parameters are null */ export async function uploadFileToS3( file: Express.Multer.File, key: string, - metadata: Metadata = {} -): Promise { + metadata: Record = {} +): Promise { const s3Client = _getS3Client(); - return s3Client - .upload({ + return s3Client.send( + new PutObjectCommand({ Bucket: _getObjectStoreBucketName(), Body: file.buffer, ContentType: file.mimetype, Key: key, Metadata: metadata }) - .promise(); + ); } +/** + * Upload a buffer to S3. + * + * @export + * @param {Buffer} buffer the buffer to upload + * @param {string} mimetype the mimetype of the buffer + * @param {string} key the path where S3 will store the file + * @param {Record} [metadata={}] A metadata object to store additional information with the file + * @returns {Promise} the response from S3 or null if required parameters are null + */ export async function uploadBufferToS3( buffer: Buffer, mimetype: string, key: string, - metadata: Metadata = {} -): Promise { + metadata: Record = {} +): Promise { const s3Client = _getS3Client(); - return s3Client - .upload({ + return s3Client.send( + new PutObjectCommand({ Bucket: _getObjectStoreBucketName(), Body: buffer, ContentType: mimetype, Key: key, Metadata: metadata }) - .promise(); + ); } /** @@ -156,32 +181,37 @@ export async function uploadBufferToS3( * @export * @param {string} key the S3 key of the file to fetch * @param {string} [versionId] the S3 version id of the file to fetch (optional) - * @return {*} {Promise} + * @return {Promise} */ -export async function getFileFromS3(key: string, versionId?: string): Promise { +export async function getFileFromS3(key: string, versionId?: string): Promise { const s3Client = _getS3Client(); - return s3Client - .getObject({ + return s3Client.send( + new GetObjectCommand({ Bucket: _getObjectStoreBucketName(), Key: key, VersionId: versionId }) - .promise(); + ); } /** - * Fetchs a list of files in S3 at the given path + * Fetches a list of files in S3 at the given path * * @export * @param {string} path the path (Prefix) of the directory in S3 - * @return {*} {Promise} All objects at the given path, also including + * @return {Promise} All objects at the given path, also including * the directory itself. */ -export const listFilesFromS3 = async (path: string): Promise => { +export const listFilesFromS3 = async (path: string): Promise => { const s3Client = _getS3Client(); - return s3Client.listObjects({ Bucket: _getObjectStoreBucketName(), Prefix: path }).promise(); + return s3Client.send( + new ListObjectsCommand({ + Bucket: _getObjectStoreBucketName(), + Prefix: path + }) + ); }; /** @@ -189,12 +219,12 @@ export const listFilesFromS3 = async (path: string): Promise * * @export * @param {string} key the key of the object - * @returns {*} {Promise { +export async function getObjectMeta(key: string): Promise { const s3Client = _getS3Client(); - return s3Client.headObject({ Bucket: _getObjectStoreBucketName(), Key: key }).promise(); + return s3Client.send(new HeadObjectCommand({ Bucket: _getObjectStoreBucketName(), Key: key })); } /** @@ -210,11 +240,16 @@ export async function getS3SignedURL(key: string): Promise { return null; } - return s3Client.getSignedUrl('getObject', { - Bucket: _getObjectStoreBucketName(), - Key: key, - Expires: 300000 // 5 minutes - }); + return getSignedUrl( + s3Client, + new GetObjectCommand({ + Bucket: _getObjectStoreBucketName(), + Key: key + }), + { + expiresIn: 300000 // 5 minutes + } + ); } export interface IS3FileKey { diff --git a/api/src/utils/media/media-utils.test.ts b/api/src/utils/media/media-utils.test.ts index da8fbbf2f7..7300f60996 100644 --- a/api/src/utils/media/media-utils.test.ts +++ b/api/src/utils/media/media-utils.test.ts @@ -1,5 +1,5 @@ +import { GetObjectCommandOutput } from '@aws-sdk/client-s3'; import AdmZip from 'adm-zip'; -import { GetObjectOutput } from 'aws-sdk/clients/s3'; import chai, { expect } from 'chai'; import { describe } from 'mocha'; import sinon from 'sinon'; @@ -14,18 +14,18 @@ describe('parseUnknownMedia', () => { sinon.restore(); }); - it('calls parseUnknownMulterFile', () => { + it('calls parseUnknownMulterFile', async () => { const parseUnknownMulterFileStub = sinon.stub(media_utils, 'parseUnknownMulterFile'); - media_utils.parseUnknownMedia({ originalname: 'name' } as unknown as Express.Multer.File); + await media_utils.parseUnknownMedia({ originalname: 'name' } as unknown as Express.Multer.File); expect(parseUnknownMulterFileStub).to.have.been.calledOnce; }); - it('calls parseUnknownS3File', () => { + it('calls parseUnknownS3File', async () => { const parseUnknownS3FileStub = sinon.stub(media_utils, 'parseUnknownS3File'); - media_utils.parseUnknownMedia({} as unknown as GetObjectOutput); + await media_utils.parseUnknownMedia({} as unknown as GetObjectCommandOutput); expect(parseUnknownS3FileStub).to.have.been.calledOnce; }); @@ -64,18 +64,20 @@ describe('parseUnknownMulterFile', () => { }); describe('parseUnknownS3File', () => { - it('returns a MediaFile', () => { - const s3File = { + it('returns a MediaFile', async () => { + const s3File: GetObjectCommandOutput = { Metadata: { filename: 'file1.txt' }, - Body: Buffer.from('file1data') - } as unknown as GetObjectOutput; + Body: { + transformToByteArray: sinon.stub().resolves(Buffer.from('file1data')) + } + } as unknown as GetObjectCommandOutput; - const response = media_utils.parseUnknownS3File(s3File); + const response = await media_utils.parseUnknownS3File(s3File); expect(response).to.eql(new MediaFile('file1.txt', 'text/plain', Buffer.from('file1data'))); }); - it('returns an ArchiveFile, when a zip file is provided', () => { + it('returns an ArchiveFile, when a zip file is provided', async () => { const zipFile = new AdmZip(); zipFile.addFile('file1.txt', Buffer.from('file1data')); @@ -85,10 +87,12 @@ describe('parseUnknownS3File', () => { const s3File = { Metadata: { filename: 'zipFile.zip' }, ContentType: 'application/zip', - Body: zipFile.toBuffer() - } as unknown as GetObjectOutput; + Body: { + transformToByteArray: sinon.stub().resolves(zipFile.toBuffer()) + } + } as unknown as GetObjectCommandOutput; - const response = media_utils.parseUnknownS3File(s3File); + const response = await media_utils.parseUnknownS3File(s3File); expect(response).to.eql( new ArchiveFile('zipFile.zip', 'application/zip', zipFile.toBuffer(), [ @@ -165,38 +169,44 @@ describe('parseMulterFile', () => { }); describe('parseS3File', () => { - it('returns a MediaFile item', () => { + it('returns a MediaFile item', async () => { const s3File = { Metadata: { filename: 'file1.csv' }, ContentType: 'text/csv', - Body: Buffer.from('file1data') - } as unknown as GetObjectOutput; + Body: { + transformToByteArray: sinon.stub().resolves(Buffer.from('file1data')) + } + } as unknown as GetObjectCommandOutput; - const response = media_utils.parseS3File(s3File); + const response = await media_utils.parseS3File(s3File); expect(response).to.eql(new MediaFile('file1.csv', 'text/csv', Buffer.from('file1data'))); }); - it('returns a MediaFile item when the file mime type is unknown', () => { + it('returns a MediaFile item when the file mime type is unknown', async () => { const s3File = { Metadata: { filename: 'file1.notAKnownMimeTypecsv' }, ContentType: 'notAKnownMimeTypecsv', - Body: Buffer.from('file1data') - } as unknown as GetObjectOutput; + Body: { + transformToByteArray: sinon.stub().resolves(Buffer.from('file1data')) + } + } as unknown as GetObjectCommandOutput; - const response = media_utils.parseS3File(s3File); + const response = await media_utils.parseS3File(s3File); expect(response).to.eql(new MediaFile('file1.notAKnownMimeTypecsv', '', Buffer.from('file1data'))); }); - it('returns a MediaFile item when the file buffer is null', () => { + it('returns a MediaFile item when the file buffer is null', async () => { const s3File = { Metadata: { filename: 'file1.csv' }, ContentType: 'text/csv', - Body: null - } as unknown as GetObjectOutput; + Body: { + transformToByteArray: sinon.stub().resolves(null) + } + } as unknown as GetObjectCommandOutput; - const response = media_utils.parseS3File(s3File); + const response = await media_utils.parseS3File(s3File); expect(response).to.eql(new MediaFile('file1.csv', 'text/csv', null as unknown as Buffer)); }); diff --git a/api/src/utils/media/media-utils.ts b/api/src/utils/media/media-utils.ts index a7b997e087..8f6f724051 100644 --- a/api/src/utils/media/media-utils.ts +++ b/api/src/utils/media/media-utils.ts @@ -1,5 +1,5 @@ +import { GetObjectCommandOutput } from '@aws-sdk/client-s3'; import AdmZip from 'adm-zip'; -import { GetObjectOutput } from 'aws-sdk/clients/s3'; import mime from 'mime'; import { ArchiveFile, MediaFile } from './media-file'; @@ -9,17 +9,25 @@ import { ArchiveFile, MediaFile } from './media-file'; * Note: The array will always have 1 item unless the unknown file is a zip file containing multiple files, in which * case the array will have 1 item per file in the zip (folders ignored). * - * @param {(Express.Multer.File | GetObjectOutput)} rawMedia - * @return {*} {(MediaFile | ArchiveFile)} + * @param {(Express.Multer.File | GetObjectCommandOutput)} rawMedia + * @return {*} {(Promise)} */ -export const parseUnknownMedia = (rawMedia: Express.Multer.File | GetObjectOutput): null | MediaFile | ArchiveFile => { +export const parseUnknownMedia = async ( + rawMedia: Express.Multer.File | GetObjectCommandOutput +): Promise => { if ((rawMedia as Express.Multer.File).originalname) { return parseUnknownMulterFile(rawMedia as Express.Multer.File); } else { - return parseUnknownS3File(rawMedia as GetObjectOutput); + return parseUnknownS3File(rawMedia as GetObjectCommandOutput); } }; +/** + * Parses an unknown multer file into a known file type. + * + * @param {Express.Multer.File} rawMedia + * @return {*} {(null | MediaFile | ArchiveFile)} + */ export const parseUnknownMulterFile = (rawMedia: Express.Multer.File): null | MediaFile | ArchiveFile => { const mimetype = mime.getType(rawMedia.originalname); @@ -33,7 +41,13 @@ export const parseUnknownMulterFile = (rawMedia: Express.Multer.File): null | Me return parseMulterFile(rawMedia); }; -export const parseUnknownS3File = (rawMedia: GetObjectOutput): null | MediaFile | ArchiveFile => { +/** + * Parses an unknown S3 file into a known file type. + * + * @param {GetObjectCommandOutput} rawMedia + * @return {*} {(Promise)} + */ +export const parseUnknownS3File = async (rawMedia: GetObjectCommandOutput): Promise => { const mimetype = rawMedia.ContentType; if (isZipMimetype(mimetype || '')) { @@ -41,13 +55,13 @@ export const parseUnknownS3File = (rawMedia: GetObjectOutput): null | MediaFile return null; } - const archiveFile = parseS3File(rawMedia); - const mediaFiles = parseUnknownZipFile(rawMedia.Body as Buffer); + const archiveFile = await parseS3File(rawMedia); + const mediaFiles = parseUnknownZipFile((await rawMedia.Body.transformToByteArray()) as Buffer); return new ArchiveFile(archiveFile.fileName, archiveFile.mimetype, archiveFile.buffer, mediaFiles); } - return parseS3File(rawMedia); + return await parseS3File(rawMedia); }; /** @@ -90,13 +104,13 @@ export const parseMulterFile = (file: Express.Multer.File): MediaFile => { /** * Parse a single file into an array of MediaFile with 1 element. * - * @param {GetObjectOutput} file - * @return {*} {MediaFile} + * @param {GetObjectCommandOutput} file + * @return {*} {Promise} */ -export const parseS3File = (file: GetObjectOutput): MediaFile => { +export const parseS3File = async (file: GetObjectCommandOutput): Promise => { const fileName = file?.Metadata?.filename || ''; const mimetype = mime.getType(fileName) || ''; - const buffer = file?.Body as Buffer; + const buffer = (await file?.Body?.transformToByteArray()) as Buffer; return new MediaFile(fileName, mimetype, buffer); }; diff --git a/app/src/components/attachments/list/AttachmentsList.test.tsx b/app/src/components/attachments/list/AttachmentsList.test.tsx index fd1cb3ead7..b302a98846 100644 --- a/app/src/components/attachments/list/AttachmentsList.test.tsx +++ b/app/src/components/attachments/list/AttachmentsList.test.tsx @@ -77,7 +77,6 @@ describe('AttachmentsList', () => { handleDownload={jest.fn()} handleDelete={jest.fn()} handleViewDetails={jest.fn()} - handleRemoveOrResubmit={jest.fn()} /> @@ -117,7 +116,6 @@ describe('AttachmentsList', () => { handleDownload={jest.fn()} handleDelete={jest.fn()} handleViewDetails={jest.fn()} - handleRemoveOrResubmit={jest.fn()} emptyStateText="No shared files found" /> @@ -158,7 +156,6 @@ describe('AttachmentsList', () => { handleDownload={jest.fn()} handleDelete={jest.fn()} handleViewDetails={jest.fn()} - handleRemoveOrResubmit={jest.fn()} /> @@ -204,7 +201,6 @@ describe('AttachmentsList', () => { handleDownload={handleDownload} handleDelete={jest.fn()} handleViewDetails={jest.fn()} - handleRemoveOrResubmit={jest.fn()} /> @@ -254,7 +250,6 @@ describe('AttachmentsList', () => { handleDownload={handleDownload} handleDelete={jest.fn()} handleViewDetails={jest.fn()} - handleRemoveOrResubmit={jest.fn()} /> diff --git a/app/src/components/attachments/list/AttachmentsList.tsx b/app/src/components/attachments/list/AttachmentsList.tsx index 6e9a5a3645..197fe494b1 100644 --- a/app/src/components/attachments/list/AttachmentsList.tsx +++ b/app/src/components/attachments/list/AttachmentsList.tsx @@ -17,7 +17,6 @@ export interface IAttachmentsListProps void; handleDelete: (attachment: T) => void; handleViewDetails: (attachment: T) => void; - handleRemoveOrResubmit: (attachment: T) => void; emptyStateText?: string; } @@ -28,7 +27,7 @@ const validSystemRoles: SYSTEM_ROLE[] = [SYSTEM_ROLE.SYSTEM_ADMIN, SYSTEM_ROLE.D const pageSizeOptions = [5, 10, 25]; const AttachmentsList = (props: IAttachmentsListProps) => { - const { attachments, handleDownload, handleDelete, handleViewDetails, handleRemoveOrResubmit } = props; + const { attachments, handleDownload, handleDelete, handleViewDetails } = props; const projectAuthStateContext = useContext(ProjectAuthStateContext); @@ -96,7 +95,6 @@ const AttachmentsList = onDownloadFile={() => handleDownload(params.row)} onDeleteFile={() => handleDelete(params.row)} onViewDetails={() => handleViewDetails(params.row)} - onRemoveOrResubmit={() => handleRemoveOrResubmit(params.row)} /> ); } diff --git a/app/src/components/attachments/list/AttachmentsListItemMenuButton.tsx b/app/src/components/attachments/list/AttachmentsListItemMenuButton.tsx index 6282f8c1f6..45ceb028b0 100644 --- a/app/src/components/attachments/list/AttachmentsListItemMenuButton.tsx +++ b/app/src/components/attachments/list/AttachmentsListItemMenuButton.tsx @@ -10,15 +10,12 @@ import { AttachmentType, PublishStatus } from 'constants/attachments'; import { PROJECT_PERMISSION, SYSTEM_ROLE } from 'constants/roles'; import { useState } from 'react'; -//TODO: PRODUCTION_BANDAGE: Remove from `Remove or Resubmit` button. - interface IAttachmentsListItemMenuButtonProps { attachmentStatus: PublishStatus; attachmentFileType: string; onDownloadFile: () => void; onDeleteFile: () => void; onViewDetails: () => void; - onRemoveOrResubmit: () => void; } const AttachmentsListItemMenuButton = (props: IAttachmentsListItemMenuButtonProps) => { @@ -104,38 +101,19 @@ const AttachmentsListItemMenuButton = (props: IAttachmentsListItemMenuButtonProp )} {props.attachmentStatus === PublishStatus.SUBMITTED && ( - <> - - { - props.onDeleteFile(); - handleClose(); - }} - data-testid="attachment-action-menu-delete"> - - - - Delete - - - - - - { - props.onRemoveOrResubmit(); - handleClose(); - }} - data-testid="attachment-action-menu-resubmit"> - - - - Remove or Resubmit - - - - + + { + props.onDeleteFile(); + handleClose(); + }} + data-testid="attachment-action-menu-delete"> + + + + Delete + + )}
diff --git a/app/src/components/publish/components/RemoveOrResubmitDialog.tsx b/app/src/components/publish/components/RemoveOrResubmitDialog.tsx deleted file mode 100644 index 8dc8c2629a..0000000000 --- a/app/src/components/publish/components/RemoveOrResubmitDialog.tsx +++ /dev/null @@ -1,134 +0,0 @@ -import Box from '@mui/material/Box'; -import Button from '@mui/material/Button'; -import Dialog from '@mui/material/Dialog'; -import DialogActions from '@mui/material/DialogActions'; -import DialogContent from '@mui/material/DialogContent'; -import DialogContentText from '@mui/material/DialogContentText'; -import DialogTitle from '@mui/material/DialogTitle'; -import useTheme from '@mui/material/styles/useTheme'; -import Typography from '@mui/material/Typography'; -import useMediaQuery from '@mui/material/useMediaQuery'; -import FailureDialog from 'components/dialog/FailureDialog'; -import SuccessDialog from 'components/dialog/SuccessDialog'; -import { PublishStatus } from 'constants/attachments'; -import { Formik, FormikProps } from 'formik'; -import { useBiohubApi } from 'hooks/useBioHubApi'; -import React, { useRef, useState } from 'react'; -import { useHistory } from 'react-router'; -import AttachmentsFileCard from './AttachmentsFileCard'; -import RemoveOrResubmitForm, { - IRemoveOrResubmitForm, - RemoveOrResubmitFormInitialValues, - RemoveOrResubmitFormYupSchema -} from './RemoveOrResubmitForm'; - -export interface IRemoveOrResubmitDialog { - projectId: number; - fileName: string; - status: PublishStatus; - parentName: string; - submittedDate?: string; - open: boolean; - onClose: () => void; -} - -/** - * Publish button. - * - * @return {*} - */ -const RemoveOrResubmitDialog: React.FC = (props) => { - const { projectId, fileName, status, parentName, submittedDate, open, onClose } = props; - - const theme = useTheme(); - const biohubApi = useBiohubApi(); - const fullScreen = useMediaQuery(theme.breakpoints.down('sm')); - - const router = useHistory(); - - const [successSubmission, setSuccessSubmission] = useState(false); - const [failureSubmission, setFailureSubmission] = useState(false); - - const formikRef = useRef>(null); - - const handleSubmit = async (values: IRemoveOrResubmitForm) => { - try { - onClose(); - await biohubApi.publish.resubmitAttachment(projectId, fileName, parentName, values, router.location.pathname); - setSuccessSubmission(true); - } catch (error) { - onClose(); - setFailureSubmission(true); - } - }; - - return ( - <> - setSuccessSubmission(false)} - /> - - setFailureSubmission(false)} - /> - - - Remove or Resubmit File - - - Submitted files are locked and cannot be removed. - - - You can request to remove or resubmit this file by providing your contact information and a short - description of the request. - - - - - FILE DETAILS - - - - - - - - - - - - - - - - - - - ); -}; - -export default RemoveOrResubmitDialog; diff --git a/app/src/components/publish/components/RemoveOrResubmitForm.tsx b/app/src/components/publish/components/RemoveOrResubmitForm.tsx deleted file mode 100644 index 97764086cb..0000000000 --- a/app/src/components/publish/components/RemoveOrResubmitForm.tsx +++ /dev/null @@ -1,92 +0,0 @@ -import Box from '@mui/material/Box'; -import Grid from '@mui/material/Grid'; -import Typography from '@mui/material/Typography'; -import CustomTextField from 'components/fields/CustomTextField'; -import yup from 'utils/YupSchema'; - -export interface IRemoveOrResubmitForm { - full_name: string; - email_address: string; - phone_number: string; - description: string; -} - -export const RemoveOrResubmitFormInitialValues: IRemoveOrResubmitForm = { - full_name: '', - email_address: '', - phone_number: '', - description: '' -}; - -export const RemoveOrResubmitFormYupSchema = yup.object().shape({ - full_name: yup.string().max(50, 'Cannot exceed 50 characters').required('Full Name is Required'), - email_address: yup - .string() - .max(500, 'Cannot exceed 500 characters') - .email('Must be a valid email address') - .required('Email Address is Required'), - phone_number: yup.string().max(300, 'Cannot exceed 300 characters').required('Phone Number is Required'), - description: yup.string().max(3000, 'Cannot exceed 3000 characters').required('Description is Required') -}); - -/** - * Publish button. - * - * @return {*} - */ -const RemoveOrResubmitForm = () => { - return ( - <> - - CONTACT DETAILS - - - - - - - - - - - - - - - - - - - DESCRIPTION OF REQUEST - - - - - - - ); -}; - -export default RemoveOrResubmitForm; diff --git a/app/src/features/projects/view/ProjectAttachmentsList.tsx b/app/src/features/projects/view/ProjectAttachmentsList.tsx index 186dfd91f2..f2fdbeb13f 100644 --- a/app/src/features/projects/view/ProjectAttachmentsList.tsx +++ b/app/src/features/projects/view/ProjectAttachmentsList.tsx @@ -1,8 +1,7 @@ import Typography from '@mui/material/Typography'; import AttachmentsList from 'components/attachments/list/AttachmentsList'; import ProjectReportAttachmentDialog from 'components/dialog/attachments/project/ProjectReportAttachmentDialog'; -import RemoveOrResubmitDialog from 'components/publish/components/RemoveOrResubmitDialog'; -import { AttachmentType, PublishStatus } from 'constants/attachments'; +import { AttachmentType } from 'constants/attachments'; import { AttachmentsI18N } from 'constants/i18n'; import { DialogContext, ISnackbarProps } from 'contexts/dialogContext'; import { ProjectContext } from 'contexts/projectContext'; @@ -19,7 +18,6 @@ const ProjectAttachmentsList = () => { const dialogContext = useContext(DialogContext); const [currentAttachment, setCurrentAttachment] = useState(null); - const [removeOrResubmitDialogOpen, setRemoveOrResubmitDialogOpen] = useState(false); const showSnackBar = (textDialogProps?: Partial) => { dialogContext.setSnackbar({ ...textDialogProps, open: true }); @@ -60,11 +58,6 @@ const ProjectAttachmentsList = () => { setCurrentAttachment(null); }; - const handleRemoveOrResubmit = (attachment: IGetProjectAttachment) => { - setCurrentAttachment(attachment); - setRemoveOrResubmitDialogOpen(true); - }; - const handleDelete = (attachment: IGetProjectAttachment) => { dialogContext.setYesNoDialog({ open: true, @@ -121,19 +114,6 @@ const ProjectAttachmentsList = () => { return ( <> - setRemoveOrResubmitDialogOpen(false)} - /> { handleDownload={handleDownload} handleDelete={handleDelete} handleViewDetails={handleViewDetailsOpen} - handleRemoveOrResubmit={handleRemoveOrResubmit} emptyStateText="No shared files found" /> diff --git a/app/src/features/projects/view/ProjectPage.tsx b/app/src/features/projects/view/ProjectPage.tsx index f18bc67466..e26ec4fdf7 100644 --- a/app/src/features/projects/view/ProjectPage.tsx +++ b/app/src/features/projects/view/ProjectPage.tsx @@ -11,8 +11,6 @@ import { useContext, useEffect } from 'react'; import ProjectDetails from './ProjectDetails'; import ProjectHeader from './ProjectHeader'; -//TODO: PRODUCTION_BANDAGE: Remove - /** * Page to display a single Project. * diff --git a/app/src/features/surveys/view/SurveyAttachmentsList.tsx b/app/src/features/surveys/view/SurveyAttachmentsList.tsx index 029ef01d7d..b4fdde67ee 100644 --- a/app/src/features/surveys/view/SurveyAttachmentsList.tsx +++ b/app/src/features/surveys/view/SurveyAttachmentsList.tsx @@ -1,7 +1,5 @@ import AttachmentsList from 'components/attachments/list/AttachmentsList'; import SurveyReportAttachmentDialog from 'components/dialog/attachments/survey/SurveyReportAttachmentDialog'; -import RemoveOrResubmitDialog from 'components/publish/components/RemoveOrResubmitDialog'; -import { PublishStatus } from 'constants/attachments'; import { AttachmentsI18N } from 'constants/i18n'; import { DialogContext } from 'contexts/dialogContext'; import { SurveyContext } from 'contexts/surveyContext'; @@ -17,7 +15,6 @@ const SurveyAttachmentsList: React.FC = () => { const dialogContext = useContext(DialogContext); const [currentAttachment, setCurrentAttachment] = useState(null); - const [removeOrResubmitDialogOpen, setRemoveOrResubmitDialogOpen] = useState(false); const [viewReportDetailsDialogOpen, setViewReportDetailsDialogOpen] = useState(false); // Load survey attachments @@ -58,11 +55,6 @@ const SurveyAttachmentsList: React.FC = () => { setViewReportDetailsDialogOpen(true); }; - const handleRemoveOrResubmit = (attachment: IGetSurveyAttachment) => { - setCurrentAttachment(attachment); - setRemoveOrResubmitDialogOpen(true); - }; - const handleDelete = (attachment: IGetSurveyAttachment) => { dialogContext.setYesNoDialog({ open: true, @@ -106,19 +98,6 @@ const SurveyAttachmentsList: React.FC = () => { return ( <> - setRemoveOrResubmitDialogOpen(false)} - /> { handleDownload={handleDownload} handleDelete={handleDelete} handleViewDetails={handleViewDetails} - handleRemoveOrResubmit={handleRemoveOrResubmit} emptyStateText="No documents found" /> diff --git a/app/src/features/surveys/view/SurveyPage.tsx b/app/src/features/surveys/view/SurveyPage.tsx index ddc8233fb6..5d5c234a50 100644 --- a/app/src/features/surveys/view/SurveyPage.tsx +++ b/app/src/features/surveys/view/SurveyPage.tsx @@ -13,8 +13,6 @@ import SurveyAnimals from './SurveyAnimals'; import SurveyAttachments from './SurveyAttachments'; import SurveyHeader from './SurveyHeader'; -//TODO: PRODUCTION_BANDAGE: Remove - /** * Page to display a single Survey. * diff --git a/app/src/hooks/api/usePublishApi.ts b/app/src/hooks/api/usePublishApi.ts index 3e26404940..e5b038cd4b 100644 --- a/app/src/hooks/api/usePublishApi.ts +++ b/app/src/hooks/api/usePublishApi.ts @@ -1,5 +1,4 @@ import { AxiosInstance } from 'axios'; -import { IRemoveOrResubmitForm } from 'components/publish/components/RemoveOrResubmitForm'; import { ISubmitSurvey } from 'components/publish/PublishSurveyDialog'; import { IProjectSubmitForm } from 'interfaces/usePublishApi.interface'; @@ -49,41 +48,9 @@ const usePublishApi = (axios: AxiosInstance) => { return data; }; - /** - * Request Resubmit Attachment - * - * @param {number} projectId The project ID pertaining to the given artifact - * @param {string} fileName The name of the artifact, such as the observation submission filename, - * report name, summary results submission filename, etc. - * @param {string} parentName The name of the parent artifact, namely the project name or - * survey name - * @param {IRemoveOrResubmitForm} formValues The particular form values to be review by the - * administrator - * @param {string} path The path to the particular artifact, e.g. '/api/projects/1' - * @return {*} {Promise} - */ - const resubmitAttachment = async ( - projectId: number, - fileName: string, - parentName: string, - formValues: IRemoveOrResubmitForm, - path: string - ): Promise => { - const { data } = await axios.post('/api/publish/attachment/resubmit', { - projectId, - fileName, - parentName, - formValues, - path - }); - - return data; - }; - return { publishSurveyData, - publishProject, - resubmitAttachment + publishProject }; }; From 5e245cf4230059d4ef6123d97852c189275b6be8 Mon Sep 17 00:00:00 2001 From: Macgregor Aubertin-Young <108430771+mauberti-bc@users.noreply.github.com> Date: Fri, 5 Jul 2024 11:23:20 -0700 Subject: [PATCH 27/31] SIMSBIOHUB-591: Show all user's data on Project page & add search functionality (#1296) - Added new summary page and components. - Added/update project/survey/observations/animals/telemetry GET all endpoints, which support filter parameters and pagination. --- api/src/__mocks__/db.ts | 4 + api/src/models/animal-view.ts | 6 + api/src/models/biohub-create.test.ts | 2 +- api/src/models/biohub-create.ts | 2 +- api/src/models/observation-view.ts | 11 + api/src/models/project-view.ts | 14 +- api/src/models/survey-view.ts | 25 ++ api/src/models/telemetry-view.ts | 6 + api/src/openapi/schemas/observation.ts | 388 +++++++++++++++++ api/src/openapi/schemas/project.ts | 2 +- api/src/paths/animal/index.test.ts | 151 +++++++ api/src/paths/animal/index.ts | 246 +++++++++++ api/src/paths/observation/index.test.ts | 197 +++++++++ api/src/paths/observation/index.ts | 248 +++++++++++ api/src/paths/project/index.test.ts | 142 +++++++ api/src/paths/project/index.ts | 249 +++++++++++ api/src/paths/project/list.test.ts | 145 ------- api/src/paths/project/list.ts | 181 -------- .../paths/project/{projectId}/survey/index.ts | 4 +- .../survey/{surveyId}/critters/index.test.ts | 10 +- .../{surveyId}/observations/index.test.ts | 2 +- .../survey/{surveyId}/observations/index.ts | 401 +----------------- api/src/paths/survey/index.test.ts | 150 +++++++ api/src/paths/survey/index.ts | 287 +++++++++++++ api/src/paths/telemetry/deployments.test.ts | 22 +- api/src/paths/telemetry/index.test.ts | 127 ++++++ api/src/paths/telemetry/index.ts | 246 +++++++++++ .../telemetry/vendor/deployments.test.ts | 28 +- .../observation-repository.test.ts | 2 +- .../observation-repository.ts | 372 +++++----------- .../observation-repository/utils.ts | 325 ++++++++++++++ .../repositories/project-repository.test.ts | 14 +- api/src/repositories/project-repository.ts | 78 ++-- .../repositories/survey-critter-repository.ts | 105 +++++ api/src/repositories/survey-repository.ts | 176 +++++++- api/src/repositories/telemetry-repository.ts | 30 ++ api/src/services/bcgw-layer-service.test.ts | 53 ++- api/src/services/bcgw-layer-service.ts | 27 ++ api/src/services/bctw-service.ts | 46 +- api/src/services/critterbase-service.ts | 4 +- api/src/services/observation-service.test.ts | 2 +- api/src/services/observation-service.ts | 42 +- api/src/services/platform-service.test.ts | 2 +- api/src/services/project-service.test.ts | 33 +- api/src/services/project-service.ts | 32 +- api/src/services/survey-critter-service.ts | 95 ++++- api/src/services/survey-service.ts | 40 ++ api/src/services/telemetry-service.ts | 186 +++++++- api/src/utils/pagination.ts | 25 +- api/tsconfig.production.json | 30 +- app/src/AppRouter.tsx | 13 +- .../components/data-grid/StyledDataGrid.tsx | 14 +- .../AsyncAutocompleteDataGridEditCell.tsx | 8 +- .../AutocompleteDataGrid.interface.ts | 9 +- .../taxonomy/TaxonomyDataGridEditCell.tsx | 4 +- .../taxonomy/TaxonomyDataGridViewCell.tsx | 10 +- app/src/components/fields/CustomTextField.tsx | 2 +- .../MultiAutocompleteFieldVariableSize.tsx | 3 +- app/src/components/fields/SingleDateField.tsx | 16 +- .../fields/SystemUserAutocompleteField.tsx | 222 ++++++++++ app/src/components/layout/Header.tsx | 4 +- .../search-filter/ProjectAdvancedFilters.tsx | 116 ----- .../species/AncillarySpeciesComponent.tsx | 4 +- .../species/FocalSpeciesComponent.tsx | 4 +- .../species/components/SelectedSpecies.tsx | 8 +- .../components/SpeciesAutocompleteField.tsx | 204 ++++++--- .../species/components/SpeciesCard.tsx | 115 ++--- .../components/SpeciesSelectedCard.tsx | 14 +- app/src/constants/colours.ts | 104 +++++ app/src/constants/misc.ts | 11 - app/src/constants/regions.ts | 32 -- app/src/constants/taxon.ts | 12 - app/src/contexts/observationsTableContext.tsx | 2 +- app/src/contexts/projectContext.tsx | 8 +- app/src/contexts/taxonomyContext.tsx | 111 +++-- .../admin/users/UsersDetailPage.test.tsx | 11 +- .../admin/users/UsersDetailProjects.test.tsx | 26 +- .../admin/users/UsersDetailProjects.tsx | 4 +- app/src/features/projects/ProjectsRouter.tsx | 11 +- .../create/CreateProjectPage.test.tsx | 2 +- .../projects/create/CreateProjectPage.tsx | 2 +- .../projects/list/ProjectsListFilterForm.tsx | 52 --- .../projects/list/ProjectsListPage.test.tsx | 156 ------- .../projects/list/ProjectsListPage.tsx | 229 ---------- .../features/projects/view/ProjectHeader.tsx | 2 +- .../standards/SpeciesStandardsPage.tsx | 4 +- app/src/features/summary/SummaryPage.tsx | 50 +++ app/src/features/summary/SummaryRouter.tsx | 34 ++ .../components/FilterFieldsContainer.tsx | 59 +++ .../list-data/ListDataTableContainer.tsx | 110 +++++ .../project/ProjectsListContainer.tsx | 281 ++++++++++++ .../project/ProjectsListFilterForm.tsx | 92 ++++ .../list-data/survey/SurveysListContainer.tsx | 308 ++++++++++++++ .../survey/SurveysListFilterForm.tsx | 92 ++++ .../TabularDataTableContainer.tsx | 114 +++++ .../animal/AnimalsListContainer.tsx | 217 ++++++++++ .../animal/AnimalsListFilterForm.tsx | 75 ++++ .../observation/ObservationsListContainer.tsx | 312 ++++++++++++++ .../ObservationsListFilterForm.tsx | 84 ++++ .../telemetry/TelemetryListContainer.tsx | 221 ++++++++++ .../telemetry/TelemetryListFilterForm.tsx | 75 ++++ app/src/features/surveys/SurveyRouter.tsx | 2 +- .../components/AnimalFormContainer.tsx | 2 +- .../animal-form/edit/EditAnimalPage.tsx | 29 +- .../components/AnimalProfileHeader.tsx | 2 +- .../surveys/components/SurveyProgressChip.tsx | 19 +- .../features/surveys/list/SurveysListPage.tsx | 2 +- .../features/surveys/view/SurveyHeader.tsx | 2 +- .../surveys/view/survey-animals/animal.ts | 2 +- app/src/hooks/api/useAdminApi.test.ts | 2 +- app/src/hooks/api/useAnimalApi.test.ts | 46 ++ app/src/hooks/api/useAnimalApi.ts | 41 ++ app/src/hooks/api/useCodesApi.test.ts | 2 +- app/src/hooks/api/useExternalApi.test.ts | 2 +- app/src/hooks/api/useFundingSourceApi.test.ts | 2 +- app/src/hooks/api/useObservationApi.test.ts | 46 +- app/src/hooks/api/useObservationApi.ts | 28 +- app/src/hooks/api/useProjectApi.test.ts | 75 ++-- app/src/hooks/api/useProjectApi.ts | 76 ++-- .../api/useProjectParticipationApi.test.ts | 2 +- app/src/hooks/api/useResourcesApi.test.ts | 2 +- app/src/hooks/api/useSamplingSiteApi.ts | 2 +- app/src/hooks/api/useSpatialApi.test.ts | 2 +- app/src/hooks/api/useStandardsApi.test.ts | 2 +- app/src/hooks/api/useSurveyApi.test.ts | 6 +- app/src/hooks/api/useSurveyApi.ts | 29 +- app/src/hooks/api/useTaxonomyApi.test.tsx | 2 +- app/src/hooks/api/useTaxonomyApi.ts | 13 +- app/src/hooks/api/useTelemetryApi.test.ts | 48 +++ app/src/hooks/api/useTelemetryApi.ts | 39 ++ app/src/hooks/api/useUserApi.test.ts | 28 +- app/src/hooks/api/useUserApi.ts | 13 + .../cb_api/useAuthenticationApi.test.tsx | 2 +- app/src/hooks/cb_api/useFamilyApi.test.tsx | 2 +- app/src/hooks/telemetry/useDeviceApi.test.tsx | 2 +- app/src/hooks/useBioHubApi.ts | 10 +- app/src/hooks/useDataLoader.ts | 71 ++-- app/src/hooks/useSearchParams.test.tsx | 340 +++++++++++++++ app/src/hooks/useSearchParams.tsx | 143 +++++++ app/src/hooks/useTelemetryApi.ts | 4 - app/src/interfaces/useAnimalApi.interface.ts | 24 ++ app/src/interfaces/useCritterApi.interface.ts | 6 +- .../interfaces/useObservationApi.interface.ts | 2 +- app/src/interfaces/useProjectApi.interface.ts | 44 +- app/src/interfaces/useSurveyApi.interface.ts | 17 +- .../interfaces/useTaxonomyApi.interface.ts | 9 +- .../interfaces/useTelemetryApi.interface.ts | 26 ++ app/src/pages/landing/LandingActions.tsx | 2 +- app/src/pages/landing/LandingPage.test.tsx | 8 +- app/src/setupTests.ts | 8 + app/src/test-helpers/survey-helpers.ts | 14 +- app/src/types/misc.ts | 43 +- app/src/utils/Utils.tsx | 20 +- .../seeds/03_basic_project_survey_setup.ts | 4 +- 154 files changed, 7826 insertions(+), 2231 deletions(-) create mode 100644 api/src/models/animal-view.ts create mode 100644 api/src/models/observation-view.ts create mode 100644 api/src/models/telemetry-view.ts create mode 100644 api/src/openapi/schemas/observation.ts create mode 100644 api/src/paths/animal/index.test.ts create mode 100644 api/src/paths/animal/index.ts create mode 100644 api/src/paths/observation/index.test.ts create mode 100644 api/src/paths/observation/index.ts create mode 100644 api/src/paths/project/index.test.ts create mode 100644 api/src/paths/project/index.ts delete mode 100644 api/src/paths/project/list.test.ts delete mode 100644 api/src/paths/project/list.ts create mode 100644 api/src/paths/survey/index.test.ts create mode 100644 api/src/paths/survey/index.ts create mode 100644 api/src/paths/telemetry/index.test.ts create mode 100644 api/src/paths/telemetry/index.ts rename api/src/repositories/{ => observation-repository}/observation-repository.test.ts (99%) rename api/src/repositories/{ => observation-repository}/observation-repository.ts (62%) create mode 100644 api/src/repositories/observation-repository/utils.ts create mode 100644 app/src/components/fields/SystemUserAutocompleteField.tsx delete mode 100644 app/src/components/search-filter/ProjectAdvancedFilters.tsx create mode 100644 app/src/constants/colours.ts delete mode 100644 app/src/constants/taxon.ts delete mode 100644 app/src/features/projects/list/ProjectsListFilterForm.tsx delete mode 100644 app/src/features/projects/list/ProjectsListPage.test.tsx delete mode 100644 app/src/features/projects/list/ProjectsListPage.tsx create mode 100644 app/src/features/summary/SummaryPage.tsx create mode 100644 app/src/features/summary/SummaryRouter.tsx create mode 100644 app/src/features/summary/components/FilterFieldsContainer.tsx create mode 100644 app/src/features/summary/list-data/ListDataTableContainer.tsx create mode 100644 app/src/features/summary/list-data/project/ProjectsListContainer.tsx create mode 100644 app/src/features/summary/list-data/project/ProjectsListFilterForm.tsx create mode 100644 app/src/features/summary/list-data/survey/SurveysListContainer.tsx create mode 100644 app/src/features/summary/list-data/survey/SurveysListFilterForm.tsx create mode 100644 app/src/features/summary/tabular-data/TabularDataTableContainer.tsx create mode 100644 app/src/features/summary/tabular-data/animal/AnimalsListContainer.tsx create mode 100644 app/src/features/summary/tabular-data/animal/AnimalsListFilterForm.tsx create mode 100644 app/src/features/summary/tabular-data/observation/ObservationsListContainer.tsx create mode 100644 app/src/features/summary/tabular-data/observation/ObservationsListFilterForm.tsx create mode 100644 app/src/features/summary/tabular-data/telemetry/TelemetryListContainer.tsx create mode 100644 app/src/features/summary/tabular-data/telemetry/TelemetryListFilterForm.tsx create mode 100644 app/src/hooks/api/useAnimalApi.test.ts create mode 100644 app/src/hooks/api/useAnimalApi.ts create mode 100644 app/src/hooks/api/useTelemetryApi.test.ts create mode 100644 app/src/hooks/api/useTelemetryApi.ts create mode 100644 app/src/hooks/useSearchParams.test.tsx create mode 100644 app/src/hooks/useSearchParams.tsx create mode 100644 app/src/interfaces/useAnimalApi.interface.ts create mode 100644 app/src/interfaces/useTelemetryApi.interface.ts diff --git a/api/src/__mocks__/db.ts b/api/src/__mocks__/db.ts index dffb914469..771b31b503 100644 --- a/api/src/__mocks__/db.ts +++ b/api/src/__mocks__/db.ts @@ -66,6 +66,10 @@ export class MockReq { params = {}; body = {}; files: any[] = []; + // Exists on authenticated requests. @see authentication.ts and authorization.ts + keycloak_token?: Record; + // Exists on authenticated requests. @see authentication.ts and authorization.ts + system_user?: Record; } export type ExtendedMockRes = MockRes & Response; diff --git a/api/src/models/animal-view.ts b/api/src/models/animal-view.ts new file mode 100644 index 0000000000..96af516b35 --- /dev/null +++ b/api/src/models/animal-view.ts @@ -0,0 +1,6 @@ +export interface IAnimalAdvancedFilters { + keyword?: string; + itis_tsns?: number[]; + itis_tsn?: number; + system_user_id?: number; +} diff --git a/api/src/models/biohub-create.test.ts b/api/src/models/biohub-create.test.ts index c5dedaba10..3a62f3734b 100644 --- a/api/src/models/biohub-create.test.ts +++ b/api/src/models/biohub-create.test.ts @@ -1,7 +1,7 @@ import { expect } from 'chai'; import { FeatureCollection } from 'geojson'; import { describe } from 'mocha'; -import { ObservationRecord } from '../repositories/observation-repository'; +import { ObservationRecord } from '../repositories/observation-repository/observation-repository'; import { PostSurveyObservationToBiohubObject, PostSurveySubmissionToBioHubObject, diff --git a/api/src/models/biohub-create.ts b/api/src/models/biohub-create.ts index 137f0f5ff4..5adb0605a1 100644 --- a/api/src/models/biohub-create.ts +++ b/api/src/models/biohub-create.ts @@ -1,7 +1,7 @@ import { FeatureCollection } from 'geojson'; import { ATTACHMENT_TYPE } from '../constants/attachments'; import { ISurveyAttachment, ISurveyReportAttachment } from '../repositories/attachment-repository'; -import { ObservationRecord } from '../repositories/observation-repository'; +import { ObservationRecord } from '../repositories/observation-repository/observation-repository'; import { getLogger } from '../utils/logger'; import { GetSurveyData, GetSurveyPurposeAndMethodologyData } from './survey-view'; diff --git a/api/src/models/observation-view.ts b/api/src/models/observation-view.ts new file mode 100644 index 0000000000..44fac03d3b --- /dev/null +++ b/api/src/models/observation-view.ts @@ -0,0 +1,11 @@ +export interface IObservationAdvancedFilters { + keyword?: string; + itis_tsns?: number[]; + itis_tsn?: number; + start_date?: string; + end_date?: string; + start_time?: string; + end_time?: string; + min_count?: number; + system_user_id?: number; +} diff --git a/api/src/models/project-view.ts b/api/src/models/project-view.ts index e49f79afd6..a6377fec11 100644 --- a/api/src/models/project-view.ts +++ b/api/src/models/project-view.ts @@ -4,8 +4,10 @@ import { SystemUser } from '../repositories/user-repository'; export interface IProjectAdvancedFilters { keyword?: string; - project_name?: string; + itis_tsn?: number; itis_tsns?: number[]; + system_user_id?: number; + project_name?: string; } export interface IGetProject { @@ -25,13 +27,17 @@ export const ProjectData = z.object({ export type ProjectData = z.infer; -export const ProjectListData = z.object({ +export const FindProjectsResponse = z.object({ project_id: z.number(), name: z.string(), - regions: z.array(z.string()) + start_date: z.string().nullable(), + end_date: z.string().nullable(), + regions: z.array(z.string()), + focal_species: z.array(z.number()), + types: z.array(z.number()) }); -export type ProjectListData = z.infer; +export type FindProjectsResponse = z.infer; /** * Pre-processes GET /projects/{id} objectives data diff --git a/api/src/models/survey-view.ts b/api/src/models/survey-view.ts index 00911cafd0..2c11556132 100644 --- a/api/src/models/survey-view.ts +++ b/api/src/models/survey-view.ts @@ -1,4 +1,5 @@ import { Feature } from 'geojson'; +import { z } from 'zod'; import { SurveyMetadataPublish } from '../repositories/history-publish-repository'; import { IPermitModel } from '../repositories/permit-repository'; import { SiteSelectionData } from '../repositories/site-selection-strategy-repository'; @@ -8,6 +9,30 @@ import { SurveyUser } from '../repositories/survey-participation-repository'; import { SystemUser } from '../repositories/user-repository'; import { ITaxonomy } from '../services/platform-service'; +export interface ISurveyAdvancedFilters { + keyword?: string; + itis_tsn?: number; + itis_tsns?: number[]; + start_date?: string; + end_date?: string; + survey_name?: string; + system_user_id?: number; +} + +export const FindSurveysResponse = z.object({ + project_id: z.number(), + survey_id: z.number(), + name: z.string(), + progress_id: z.number(), + regions: z.array(z.string()), + start_date: z.string().nullable(), + end_date: z.string().nullable().optional().nullable(), + focal_species: z.array(z.number().nullable()), + types: z.array(z.number().nullable()) +}); + +export type FindSurveysResponse = z.infer; + export type SurveyObject = { survey_details: GetSurveyData; species: GetFocalSpeciesData & GetAncillarySpeciesData; diff --git a/api/src/models/telemetry-view.ts b/api/src/models/telemetry-view.ts new file mode 100644 index 0000000000..148579fdfa --- /dev/null +++ b/api/src/models/telemetry-view.ts @@ -0,0 +1,6 @@ +export interface ITelemetryAdvancedFilters { + keyword?: string; + itis_tsns?: number[]; + itis_tsn?: number; + system_user_id?: number; +} diff --git a/api/src/openapi/schemas/observation.ts b/api/src/openapi/schemas/observation.ts new file mode 100644 index 0000000000..10aaf153d4 --- /dev/null +++ b/api/src/openapi/schemas/observation.ts @@ -0,0 +1,388 @@ +import { OpenAPIV3 } from 'openapi-types'; +import { paginationResponseSchema } from './pagination'; + +export const observervationsWithSubcountDataSchema: OpenAPIV3.SchemaObject = { + type: 'object', + additionalProperties: false, + required: ['surveyObservations', 'supplementaryObservationData', 'pagination'], + properties: { + surveyObservations: { + type: 'array', + items: { + type: 'object', + additionalProperties: false, + required: [ + 'survey_observation_id', + 'survey_id', + 'itis_tsn', + 'itis_scientific_name', + 'survey_sample_site_id', + 'survey_sample_method_id', + 'survey_sample_period_id', + 'latitude', + 'longitude', + 'count', + 'observation_date', + 'observation_time', + 'survey_sample_site_name', + 'survey_sample_method_name', + 'survey_sample_period_start_datetime' + ], + properties: { + survey_observation_id: { + type: 'integer', + minimum: 1 + }, + survey_id: { + type: 'integer', + minimum: 1 + }, + itis_tsn: { + type: 'integer' + }, + itis_scientific_name: { + type: 'string', + nullable: true + }, + survey_sample_site_id: { + type: 'integer', + minimum: 1, + nullable: true + }, + survey_sample_method_id: { + type: 'integer', + minimum: 1, + nullable: true + }, + survey_sample_period_id: { + type: 'integer', + minimum: 1, + nullable: true + }, + latitude: { + type: 'number' + }, + longitude: { + type: 'number' + }, + count: { + type: 'integer' + }, + observation_date: { + type: 'string' + }, + observation_time: { + type: 'string' + }, + survey_sample_site_name: { + type: 'string', + nullable: true + }, + survey_sample_method_name: { + type: 'string', + nullable: true + }, + survey_sample_period_start_datetime: { + type: 'string', + nullable: true + }, + subcounts: { + type: 'array', + items: { + type: 'object', + additionalProperties: false, + required: [ + 'observation_subcount_id', + 'subcount', + 'qualitative_measurements', + 'quantitative_measurements', + 'qualitative_environments', + 'quantitative_environments' + ], + properties: { + observation_subcount_id: { + type: 'integer' + }, + subcount: { + type: 'number' + }, + qualitative_measurements: { + type: 'array', + items: { + type: 'object', + additionalProperties: false, + required: ['critterbase_taxon_measurement_id', 'critterbase_measurement_qualitative_option_id'], + properties: { + critterbase_taxon_measurement_id: { + type: 'string', + format: 'uuid' + }, + critterbase_measurement_qualitative_option_id: { + type: 'string', + format: 'uuid' + } + } + } + }, + quantitative_measurements: { + type: 'array', + items: { + type: 'object', + additionalProperties: false, + required: ['critterbase_taxon_measurement_id', 'value'], + properties: { + critterbase_taxon_measurement_id: { + type: 'string', + format: 'uuid' + }, + value: { + type: 'number' + } + } + } + }, + qualitative_environments: { + type: 'array', + items: { + type: 'object', + additionalProperties: false, + required: ['environment_qualitative_id', 'environment_qualitative_option_id'], + properties: { + observation_subcount_qualitative_environment_id: { + type: 'integer' + }, + environment_qualitative_id: { + type: 'string', + format: 'uuid' + }, + environment_qualitative_option_id: { + type: 'string', + format: 'uuid' + } + } + } + }, + quantitative_environments: { + type: 'array', + items: { + type: 'object', + additionalProperties: false, + required: ['environment_quantitative_id', 'value'], + properties: { + environment_quantitative_id: { + type: 'string', + format: 'uuid' + }, + value: { + type: 'number' + } + } + } + } + } + } + } + } + } + }, + supplementaryObservationData: { + type: 'object', + additionalProperties: false, + required: [ + 'observationCount', + 'qualitative_measurements', + 'quantitative_measurements', + 'qualitative_environments', + 'quantitative_environments' + ], + properties: { + observationCount: { + type: 'integer', + minimum: 0 + }, + qualitative_measurements: { + description: 'All qualitative measurement type definitions for the observations.', + type: 'array', + items: { + description: 'A qualitative measurement type definition, with array of valid/accepted options', + type: 'object', + additionalProperties: false, + required: ['itis_tsn', 'taxon_measurement_id', 'measurement_name', 'measurement_desc', 'options'], + properties: { + itis_tsn: { + type: 'integer', + nullable: true + }, + taxon_measurement_id: { + type: 'string' + }, + measurement_name: { + type: 'string' + }, + measurement_desc: { + type: 'string', + nullable: true + }, + options: { + description: 'Valid options for the measurement.', + type: 'array', + items: { + type: 'object', + additionalProperties: false, + required: ['qualitative_option_id', 'option_label', 'option_value', 'option_desc'], + properties: { + qualitative_option_id: { + type: 'string' + }, + option_label: { + type: 'string', + nullable: true + }, + option_value: { + type: 'number' + }, + option_desc: { + type: 'string', + nullable: true + } + } + } + } + } + } + }, + quantitative_measurements: { + description: 'All quantitative measurement type definitions for the observations.', + type: 'array', + items: { + description: 'A quantitative measurement type definition, with possible min/max constraint.', + type: 'object', + additionalProperties: false, + required: [ + 'itis_tsn', + 'taxon_measurement_id', + 'measurement_name', + 'measurement_desc', + 'min_value', + 'max_value', + 'unit' + ], + properties: { + itis_tsn: { + type: 'integer', + nullable: true + }, + taxon_measurement_id: { + type: 'string' + }, + measurement_name: { + type: 'string' + }, + measurement_desc: { + type: 'string', + nullable: true + }, + min_value: { + type: 'number', + nullable: true + }, + max_value: { + type: 'number', + nullable: true + }, + unit: { + type: 'string', + nullable: true + } + } + } + }, + qualitative_environments: { + description: 'All qualitative environment type definitions for the observations.', + type: 'array', + items: { + description: 'A qualitative environment type definition, with array of valid/accepted options', + type: 'object', + additionalProperties: false, + required: ['environment_qualitative_id', 'name', 'description', 'options'], + properties: { + environment_qualitative_id: { + type: 'string', + format: 'uuid' + }, + name: { + type: 'string' + }, + description: { + type: 'string', + nullable: true + }, + options: { + description: 'Valid options for the environment.', + type: 'array', + items: { + type: 'object', + additionalProperties: false, + required: ['environment_qualitative_option_id', 'environment_qualitative_id', 'name', 'description'], + properties: { + environment_qualitative_option_id: { + type: 'string', + format: 'uuid' + }, + environment_qualitative_id: { + type: 'string', + format: 'uuid' + }, + name: { + type: 'string' + }, + description: { + type: 'string', + nullable: true + } + } + } + } + } + } + }, + quantitative_environments: { + description: 'All quantitative environment type definitions for the observations.', + type: 'array', + items: { + description: 'A quantitative environment type definition, with possible min/max constraint.', + type: 'object', + additionalProperties: false, + required: ['environment_quantitative_id', 'name', 'description', 'min', 'max', 'unit'], + properties: { + environment_quantitative_id: { + type: 'string', + format: 'uuid' + }, + name: { + type: 'string' + }, + description: { + type: 'string', + nullable: true + }, + min: { + type: 'number', + nullable: true + }, + max: { + type: 'number', + nullable: true + }, + unit: { + type: 'string', + nullable: true + } + } + } + } + } + }, + pagination: { ...paginationResponseSchema } + } +}; diff --git a/api/src/openapi/schemas/project.ts b/api/src/openapi/schemas/project.ts index e9a9f7a94d..aea1c6ff79 100644 --- a/api/src/openapi/schemas/project.ts +++ b/api/src/openapi/schemas/project.ts @@ -23,7 +23,7 @@ export const projectCreatePostRequestObject = { }, end_date: { type: 'string', - description: 'ISO 8601 date string', + description: 'ISO 8601 datetime string', nullable: true } } diff --git a/api/src/paths/animal/index.test.ts b/api/src/paths/animal/index.test.ts new file mode 100644 index 0000000000..f7cc30846d --- /dev/null +++ b/api/src/paths/animal/index.test.ts @@ -0,0 +1,151 @@ +import chai, { expect } from 'chai'; +import { describe } from 'mocha'; +import sinon from 'sinon'; +import sinonChai from 'sinon-chai'; +import { SYSTEM_ROLE } from '../../constants/roles'; +import * as db from '../../database/db'; +import { HTTPError } from '../../errors/http-error'; +import { FindCrittersResponse, SurveyCritterService } from '../../services/survey-critter-service'; +import { getMockDBConnection, getRequestHandlerMocks } from '../../__mocks__/db'; +import { findAnimals } from './index'; + +chai.use(sinonChai); + +describe('findAnimals', () => { + afterEach(() => { + sinon.restore(); + }); + + it('finds and returns animals', async () => { + const mockFindAnimalsResponse: FindCrittersResponse[] = [ + { + wlh_id: null, + animal_id: '456-456-456', + sex: 'unknown', + itis_tsn: 123456, + itis_scientific_name: 'scientific name', + critter_comment: '', + critter_id: 2, + survey_id: 1, + critterbase_critter_id: '123-123-123' + } + ]; + + const mockDBConnection = getMockDBConnection({ + open: sinon.stub(), + commit: sinon.stub(), + release: sinon.stub(), + systemUserId: () => 20 + }); + + sinon.stub(db, 'getDBConnection').returns(mockDBConnection); + + const findCrittersStub = sinon + .stub(SurveyCritterService.prototype, 'findCritters') + .resolves(mockFindAnimalsResponse); + + const findCrittersCountStub = sinon.stub(SurveyCritterService.prototype, 'findCrittersCount').resolves(50); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + + mockReq.query = { + keyword: 'keyword', + itis_tsns: ['123456'], + start_date: '2021-01-01', + end_date: '2021-01-31', + system_user_id: '11', + page: '2', + limit: '10', + sort: undefined, + order: undefined + }; + mockReq['keycloak_token'] = {}; + mockReq['system_user'] = { + role_names: [SYSTEM_ROLE.SYSTEM_ADMIN] + }; + + const requestHandler = findAnimals(); + + await requestHandler(mockReq, mockRes, mockNext); + + expect(mockDBConnection.open).to.have.been.calledOnce; + expect(mockDBConnection.commit).to.have.been.calledOnce; + + expect(findCrittersStub).to.have.been.calledOnceWith(true, 20, sinon.match.any, sinon.match.object); + expect(findCrittersCountStub).to.have.been.calledOnceWith(true, 20, sinon.match.object); + + expect(mockRes.jsonValue.animals).to.eql(mockFindAnimalsResponse); + expect(mockRes.jsonValue.pagination).not.to.be.null; + + expect(mockDBConnection.release).to.have.been.calledOnce; + }); + + it('catches and re-throws error', async () => { + const mockFindAnimalsResponse: FindCrittersResponse[] = [ + { + wlh_id: null, + animal_id: '456-456-456', + sex: 'unknown', + itis_tsn: 123456, + itis_scientific_name: 'scientific name', + critter_comment: '', + critter_id: 2, + survey_id: 1, + critterbase_critter_id: '123-123-123' + } + ]; + + const mockDBConnection = getMockDBConnection({ + open: sinon.stub(), + commit: sinon.stub(), + rollback: sinon.stub(), + release: sinon.stub(), + systemUserId: () => 20 + }); + + sinon.stub(db, 'getDBConnection').returns(mockDBConnection); + + const findCrittersStub = sinon + .stub(SurveyCritterService.prototype, 'findCritters') + .resolves(mockFindAnimalsResponse); + + const findCrittersCountStub = sinon + .stub(SurveyCritterService.prototype, 'findCrittersCount') + .rejects(new Error('a test error')); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + + mockReq.query = { + keyword: 'keyword', + itis_tsns: ['123456'], + start_date: '2021-01-01', + end_date: '2021-01-31', + system_user_id: '11', + page: '2', + limit: '10', + sort: undefined, + order: undefined + }; + mockReq['keycloak_token'] = {}; + mockReq['system_user'] = { + role_names: [SYSTEM_ROLE.PROJECT_CREATOR] + }; + + const requestHandler = findAnimals(); + + try { + await requestHandler(mockReq, mockRes, mockNext); + expect.fail(); + } catch (actualError) { + expect(mockDBConnection.open).to.have.been.calledOnce; + + expect(findCrittersStub).to.have.been.calledOnceWith(false, 20, sinon.match.object, sinon.match.object); + expect(findCrittersCountStub).to.have.been.calledOnceWith(false, 20, sinon.match.object); + + expect(mockDBConnection.rollback).to.have.been.calledOnce; + expect(mockDBConnection.release).to.have.been.calledOnce; + + expect((actualError as HTTPError).message).to.equal('a test error'); + } + }); +}); diff --git a/api/src/paths/animal/index.ts b/api/src/paths/animal/index.ts new file mode 100644 index 0000000000..ad2d1b229b --- /dev/null +++ b/api/src/paths/animal/index.ts @@ -0,0 +1,246 @@ +import { Request, RequestHandler } from 'express'; +import { Operation } from 'express-openapi'; +import { SYSTEM_ROLE } from '../../constants/roles'; +import { getDBConnection } from '../../database/db'; +import { IAnimalAdvancedFilters } from '../../models/animal-view'; +import { paginationRequestQueryParamSchema, paginationResponseSchema } from '../../openapi/schemas/pagination'; +import { authorizeRequestHandler, userHasValidRole } from '../../request-handlers/security/authorization'; +import { SurveyCritterService } from '../../services/survey-critter-service'; +import { getLogger } from '../../utils/logger'; +import { + ensureCompletePaginationOptions, + makePaginationOptionsFromRequest, + makePaginationResponse +} from '../../utils/pagination'; + +const defaultLog = getLogger('paths/animal'); + +export const GET: Operation = [ + authorizeRequestHandler(() => { + return { + and: [ + { + discriminator: 'SystemUser' + } + ] + }; + }), + findAnimals() +]; + +GET.apiDoc = { + description: "Gets a list of animals based on the user's permissions and filter criteria.", + tags: ['animals'], + security: [ + { + Bearer: [] + } + ], + parameters: [ + { + in: 'query', + name: 'keyword', + required: false, + schema: { + type: 'string', + nullable: true + } + }, + { + in: 'query', + name: 'itis_tsns', + description: 'ITIS TSN numbers', + required: false, + schema: { + type: 'array', + items: { + type: 'integer' + }, + nullable: true + } + }, + { + in: 'query', + name: 'itis_tsn', + description: 'ITIS TSN number', + required: false, + schema: { + type: 'integer', + nullable: true + } + }, + { + in: 'query', + name: 'system_user_id', + required: false, + schema: { + type: 'number', + minimum: 1, + nullable: true + } + }, + ...paginationRequestQueryParamSchema + ], + responses: { + 200: { + description: 'Animal response object.', + content: { + 'application/json': { + schema: { + type: 'object', + required: ['animals', 'pagination'], + additionalProperties: false, + properties: { + animals: { + type: 'array', + items: { + type: 'object', + required: [ + 'wlh_id', + 'animal_id', + 'sex', + 'itis_tsn', + 'itis_scientific_name', + 'critter_comment', + 'critter_id', + 'survey_id', + 'critterbase_critter_id' + ], + additionalProperties: false, + properties: { + wlh_id: { + type: 'string', + nullable: true, + description: 'The Critterbase critter wildlife health ID.' + }, + animal_id: { + type: 'string', + description: 'The Critterbase critter animal ID.' + }, + sex: { + type: 'string', + description: 'The Critterbase critter sex.' + }, + itis_tsn: { + type: 'number', + description: 'The Critterbase critter ITIS TSN.' + }, + itis_scientific_name: { + type: 'string', + description: 'The Critterbase critter scientific name.' + }, + critter_comment: { + type: 'string', + description: 'The Critterbase critter comment.' + }, + critter_id: { + type: 'integer', + minimum: 1, + description: 'The SIMS critter ID.' + }, + survey_id: { + type: 'integer', + minimum: 1, + description: 'The SIMS survey ID.' + }, + critterbase_critter_id: { + type: 'string', + format: 'uuid', + description: 'The Critterbase critter ID.' + } + } + } + }, + pagination: { ...paginationResponseSchema } + } + } + } + } + }, + 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' + } + } +}; + +/** + * Get animals for the current user, based on their permissions and filter criteria. + * + * @returns {RequestHandler} + */ +export function findAnimals(): RequestHandler { + return async (req, res) => { + defaultLog.debug({ label: 'findAnimals' }); + + const connection = getDBConnection(req['keycloak_token']); + + try { + await connection.open(); + + const systemUserId = connection.systemUserId(); + + const isUserAdmin = userHasValidRole( + [SYSTEM_ROLE.SYSTEM_ADMIN, SYSTEM_ROLE.DATA_ADMINISTRATOR], + req['system_user']['role_names'] + ); + + const filterFields = parseQueryParams(req); + + const paginationOptions = makePaginationOptionsFromRequest(req); + + const surveyService = new SurveyCritterService(connection); + + const [critters, crittersCount] = await Promise.all([ + surveyService.findCritters( + isUserAdmin, + systemUserId, + filterFields, + ensureCompletePaginationOptions(paginationOptions) + ), + surveyService.findCrittersCount(isUserAdmin, systemUserId, filterFields) + ]); + + await connection.commit(); + + // Allow browsers to cache this response for 30 seconds + res.setHeader('Cache-Control', 'private, max-age=30'); + + return res + .status(200) + .json({ animals: critters, pagination: makePaginationResponse(crittersCount, paginationOptions) }); + } catch (error) { + defaultLog.error({ label: 'findAnimals', message: 'error', error }); + await connection.rollback(); + throw error; + } finally { + connection.release(); + } + }; +} + +/** + * Parse the query parameters from the request into the expected format. + * + * @param {Request} req + * @return {*} {IAnimalAdvancedFilters} + */ +function parseQueryParams(req: Request): IAnimalAdvancedFilters { + return { + keyword: req.query.keyword ?? undefined, + itis_tsns: req.query.itis_tsns ?? undefined, + itis_tsn: (req.query.itis_tsn && Number(req.query.itis_tsn)) ?? undefined, + system_user_id: req.query.system_user_id ?? undefined + }; +} diff --git a/api/src/paths/observation/index.test.ts b/api/src/paths/observation/index.test.ts new file mode 100644 index 0000000000..525d111514 --- /dev/null +++ b/api/src/paths/observation/index.test.ts @@ -0,0 +1,197 @@ +import chai, { expect } from 'chai'; +import { describe } from 'mocha'; +import sinon from 'sinon'; +import sinonChai from 'sinon-chai'; +import { SYSTEM_ROLE } from '../../constants/roles'; +import * as db from '../../database/db'; +import { HTTPError } from '../../errors/http-error'; +import { ObservationRecordWithSamplingAndSubcountData } from '../../repositories/observation-repository/observation-repository'; +import { ObservationService } from '../../services/observation-service'; +import { getMockDBConnection, getRequestHandlerMocks } from '../../__mocks__/db'; +import { findObservations } from './index'; + +chai.use(sinonChai); + +describe('findObservations', () => { + afterEach(() => { + sinon.restore(); + }); + + it('finds and returns observations', async () => { + const mockFindObservationsResponse: ObservationRecordWithSamplingAndSubcountData[] = [ + { + survey_observation_id: 11, + survey_id: 1, + latitude: 3, + longitude: 4, + count: 5, + itis_tsn: 6, + itis_scientific_name: 'itis_scientific_name', + observation_date: '2023-01-01', + observation_time: '12:00:00', + survey_sample_method_name: 'METHOD_NAME', + survey_sample_period_start_datetime: '2000-01-01 00:00:00', + survey_sample_site_name: 'SITE_NAME', + survey_sample_site_id: 1, + survey_sample_method_id: 1, + survey_sample_period_id: 1, + subcounts: [ + { + observation_subcount_id: 9, + subcount: 5, + qualitative_measurements: [], + quantitative_measurements: [], + qualitative_environments: [], + quantitative_environments: [] + } + ] + } + ]; + + const mockDBConnection = getMockDBConnection({ + open: sinon.stub(), + commit: sinon.stub(), + release: sinon.stub(), + systemUserId: () => 20 + }); + + sinon.stub(db, 'getDBConnection').returns(mockDBConnection); + + const findObservationsStub = sinon + .stub(ObservationService.prototype, 'findObservations') + .resolves(mockFindObservationsResponse); + + const findObservationsCountStub = sinon.stub(ObservationService.prototype, 'findObservationsCount').resolves(50); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + + mockReq.query = { + keyword: 'keyword', + itis_tsns: ['123456'], + start_date: '2021-01-01', + end_date: '2021-01-31', + start_time: '00:00:00', + end_time: '23:59:59', + min_count: '5', + system_user_id: '11', + page: '2', + limit: '10', + sort: undefined, + order: undefined + }; + mockReq['keycloak_token'] = {}; + mockReq['system_user'] = { + role_names: [SYSTEM_ROLE.SYSTEM_ADMIN] + }; + + const requestHandler = findObservations(); + + await requestHandler(mockReq, mockRes, mockNext); + + expect(mockDBConnection.open).to.have.been.calledOnce; + expect(mockDBConnection.commit).to.have.been.calledOnce; + + expect(findObservationsStub).to.have.been.calledOnceWith(true, 20, sinon.match.object, sinon.match.object); + expect(findObservationsCountStub).to.have.been.calledOnceWith(true, 20, sinon.match.object); + + expect(mockRes.jsonValue.surveyObservations).to.eql(mockFindObservationsResponse); + expect(mockRes.jsonValue.supplementaryObservationData).to.eql({ + observationCount: 50, + qualitative_measurements: [], + quantitative_measurements: [], + qualitative_environments: [], + quantitative_environments: [] + }); + expect(mockRes.jsonValue.pagination).not.to.be.null; + + expect(mockDBConnection.release).to.have.been.calledOnce; + }); + + it('catches and re-throws error', async () => { + const mockFindObservationsResponse: ObservationRecordWithSamplingAndSubcountData[] = [ + { + survey_observation_id: 11, + survey_id: 1, + latitude: 3, + longitude: 4, + count: 5, + itis_tsn: 6, + itis_scientific_name: 'itis_scientific_name', + observation_date: '2023-01-01', + observation_time: '12:00:00', + survey_sample_method_name: 'METHOD_NAME', + survey_sample_period_start_datetime: '2000-01-01 00:00:00', + survey_sample_site_name: 'SITE_NAME', + survey_sample_site_id: 1, + survey_sample_method_id: 1, + survey_sample_period_id: 1, + subcounts: [ + { + observation_subcount_id: 9, + subcount: 5, + qualitative_measurements: [], + quantitative_measurements: [], + qualitative_environments: [], + quantitative_environments: [] + } + ] + } + ]; + + const mockDBConnection = getMockDBConnection({ + open: sinon.stub(), + commit: sinon.stub(), + rollback: sinon.stub(), + release: sinon.stub(), + systemUserId: () => 20 + }); + + sinon.stub(db, 'getDBConnection').returns(mockDBConnection); + + const findObservationsStub = sinon + .stub(ObservationService.prototype, 'findObservations') + .resolves(mockFindObservationsResponse); + + const findObservationsCountStub = sinon + .stub(ObservationService.prototype, 'findObservationsCount') + .rejects(new Error('a test error')); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + + mockReq.query = { + keyword: 'keyword', + itis_tsns: ['123456'], + start_date: '2021-01-01', + end_date: '2021-01-31', + start_time: '00:00:00', + end_time: '23:59:59', + min_count: '5', + system_user_id: '11', + page: '2', + limit: '10', + sort: undefined, + order: undefined + }; + mockReq['keycloak_token'] = {}; + mockReq['system_user'] = { + role_names: [SYSTEM_ROLE.PROJECT_CREATOR] + }; + + const requestHandler = findObservations(); + + try { + await requestHandler(mockReq, mockRes, mockNext); + expect.fail(); + } catch (actualError) { + expect(mockDBConnection.open).to.have.been.calledOnce; + + expect(findObservationsStub).to.have.been.calledOnceWith(false, 20, sinon.match.object, sinon.match.object); + expect(findObservationsCountStub).to.have.been.calledOnceWith(false, 20, sinon.match.object); + + expect(mockDBConnection.rollback).to.have.been.calledOnce; + expect(mockDBConnection.release).to.have.been.calledOnce; + + expect((actualError as HTTPError).message).to.equal('a test error'); + } + }); +}); diff --git a/api/src/paths/observation/index.ts b/api/src/paths/observation/index.ts new file mode 100644 index 0000000000..de2c23a71f --- /dev/null +++ b/api/src/paths/observation/index.ts @@ -0,0 +1,248 @@ +import { Request, RequestHandler } from 'express'; +import { Operation } from 'express-openapi'; +import { SYSTEM_ROLE } from '../../constants/roles'; +import { getDBConnection } from '../../database/db'; +import { IObservationAdvancedFilters } from '../../models/observation-view'; +import { observervationsWithSubcountDataSchema } from '../../openapi/schemas/observation'; +import { paginationRequestQueryParamSchema } from '../../openapi/schemas/pagination'; +import { authorizeRequestHandler, userHasValidRole } from '../../request-handlers/security/authorization'; +import { ObservationService } from '../../services/observation-service'; +import { getLogger } from '../../utils/logger'; +import { + ensureCompletePaginationOptions, + makePaginationOptionsFromRequest, + makePaginationResponse +} from '../../utils/pagination'; + +const defaultLog = getLogger('paths/observation/index'); + +export const GET: Operation = [ + authorizeRequestHandler(() => { + return { + and: [ + { + discriminator: 'SystemUser' + } + ] + }; + }), + findObservations() +]; + +GET.apiDoc = { + description: "Gets a list of observations based on the user's permissions and filter criteria.", + tags: ['observations'], + security: [ + { + Bearer: [] + } + ], + parameters: [ + { + in: 'query', + name: 'keyword', + required: false, + schema: { + type: 'string', + nullable: true + } + }, + { + in: 'query', + name: 'itis_tsns', + description: 'ITIS TSN numbers', + required: false, + schema: { + type: 'array', + items: { + type: 'integer' + }, + nullable: true + } + }, + { + in: 'query', + name: 'itis_tsn', + description: 'ITIS TSN number', + required: false, + schema: { + type: 'integer', + nullable: true + } + }, + { + in: 'query', + name: 'start_date', + description: 'ISO 8601 date string', + required: false, + schema: { + type: 'string', + nullable: true + } + }, + { + in: 'query', + name: 'end_date', + description: 'ISO 8601 date string', + required: false, + schema: { + type: 'string', + nullable: true + } + }, + { + in: 'query', + name: 'start_time', + description: 'ISO 8601 time string', + required: false, + schema: { + type: 'string', + nullable: true + } + }, + { + in: 'query', + name: 'end_time', + description: 'ISO 8601 time string', + required: false, + schema: { + type: 'string', + nullable: true + } + }, + { + in: 'query', + name: 'min_count', + description: 'Minimum observation count (inclusive).', + required: false, + schema: { + type: 'number', + minimum: 0, + nullable: true + } + }, + { + in: 'query', + name: 'system_user_id', + required: false, + schema: { + type: 'number', + minimum: 1, + nullable: true + } + }, + ...paginationRequestQueryParamSchema + ], + responses: { + 200: { + description: 'Observation response object.', + content: { + 'application/json': { + schema: observervationsWithSubcountDataSchema + } + } + }, + 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' + } + } +}; + +/** + * Get observations for the current user, based on their permissions and filter criteria. + * + * @returns {RequestHandler} + */ +export function findObservations(): RequestHandler { + return async (req, res) => { + defaultLog.debug({ label: 'getObservations' }); + + const connection = getDBConnection(req['keycloak_token']); + + try { + await connection.open(); + + const systemUserId = connection.systemUserId(); + + const isUserAdmin = userHasValidRole( + [SYSTEM_ROLE.SYSTEM_ADMIN, SYSTEM_ROLE.DATA_ADMINISTRATOR], + req['system_user']['role_names'] + ); + + const filterFields = parseQueryParams(req); + + const paginationOptions = makePaginationOptionsFromRequest(req); + + const observationService = new ObservationService(connection); + + const [observations, observationsTotalCount] = await Promise.all([ + observationService.findObservations( + isUserAdmin, + systemUserId, + filterFields, + ensureCompletePaginationOptions(paginationOptions) + ), + observationService.findObservationsCount(isUserAdmin, systemUserId, filterFields) + ]); + + await connection.commit(); + + const response = { + surveyObservations: observations, + supplementaryObservationData: { + observationCount: observationsTotalCount, + qualitative_measurements: [], + quantitative_measurements: [], + qualitative_environments: [], + quantitative_environments: [] + }, + pagination: makePaginationResponse(observationsTotalCount, paginationOptions) + }; + + // Allow browsers to cache this response for 30 seconds + res.setHeader('Cache-Control', 'private, max-age=30'); + + return res.status(200).json(response); + } catch (error) { + defaultLog.error({ label: 'getObservations', message: 'error', error }); + await connection.rollback(); + throw error; + } finally { + connection.release(); + } + }; +} + +/** + * Parse the query parameters from the request into the expected format. + * + * @param {Request} req + * @return {*} {IObservationAdvancedFilters} + */ +function parseQueryParams( + req: Request +): IObservationAdvancedFilters { + return { + keyword: req.query.keyword ?? undefined, + itis_tsns: req.query.itis_tsns ?? undefined, + itis_tsn: (req.query.itis_tsn && Number(req.query.itis_tsn)) ?? undefined, + start_date: req.query.start_date ?? undefined, + end_date: req.query.end_date ?? undefined, + start_time: req.query.start_time ?? undefined, + end_time: req.query.end_time ?? undefined, + min_count: (req.query.min_count && Number(req.query.min_count)) ?? undefined, + system_user_id: req.query.system_user_id ?? undefined + }; +} diff --git a/api/src/paths/project/index.test.ts b/api/src/paths/project/index.test.ts new file mode 100644 index 0000000000..8a31df36b6 --- /dev/null +++ b/api/src/paths/project/index.test.ts @@ -0,0 +1,142 @@ +import chai, { expect } from 'chai'; +import { describe } from 'mocha'; +import sinon from 'sinon'; +import sinonChai from 'sinon-chai'; +import { SYSTEM_ROLE } from '../../constants/roles'; +import * as db from '../../database/db'; +import { HTTPError } from '../../errors/http-error'; +import { FindProjectsResponse } from '../../models/project-view'; +import { ProjectService } from '../../services/project-service'; +import { getMockDBConnection, getRequestHandlerMocks } from '../../__mocks__/db'; +import { findProjects } from './index'; + +chai.use(sinonChai); + +describe('findProjects', () => { + afterEach(() => { + sinon.restore(); + }); + + it('finds and returns projects', async () => { + const mockFindProjectsResponse: FindProjectsResponse[] = [ + { + project_id: 1, + name: 'project name', + start_date: '2021-01-01', + end_date: '2021-12-31', + regions: ['region1'], + focal_species: [123, 456], + types: [1, 2, 3] + } + ]; + + const mockDBConnection = getMockDBConnection({ + open: sinon.stub(), + commit: sinon.stub(), + release: sinon.stub(), + systemUserId: () => 20 + }); + + sinon.stub(db, 'getDBConnection').returns(mockDBConnection); + + const findProjectsStub = sinon.stub(ProjectService.prototype, 'findProjects').resolves(mockFindProjectsResponse); + + const findProjectsCountStub = sinon.stub(ProjectService.prototype, 'findProjectsCount').resolves(50); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + + mockReq.query = { + keyword: 'keyword', + itis_tsns: ['123456'], + system_user_id: '11', + project_name: 'project name', + page: '2', + limit: '10', + sort: undefined, + order: undefined + }; + mockReq['keycloak_token'] = {}; + mockReq['system_user'] = { + role_names: [SYSTEM_ROLE.SYSTEM_ADMIN] + }; + + const requestHandler = findProjects(); + + await requestHandler(mockReq, mockRes, mockNext); + + expect(mockDBConnection.open).to.have.been.calledOnce; + expect(mockDBConnection.commit).to.have.been.calledOnce; + + expect(findProjectsStub).to.have.been.calledOnceWith(true, 20, sinon.match.object, sinon.match.object); + expect(findProjectsCountStub).to.have.been.calledOnceWith(true, 20, sinon.match.object); + + expect(mockRes.jsonValue.projects).to.eql(mockFindProjectsResponse); + expect(mockRes.jsonValue.pagination).not.to.be.null; + + expect(mockDBConnection.release).to.have.been.calledOnce; + }); + + it('catches and re-throws error', async () => { + const mockFindProjectsResponse: FindProjectsResponse[] = [ + { + project_id: 1, + name: 'project name', + start_date: '2021-01-01', + end_date: '2021-12-31', + regions: ['region1'], + focal_species: [123, 456], + types: [1, 2, 3] + } + ]; + + const mockDBConnection = getMockDBConnection({ + open: sinon.stub(), + commit: sinon.stub(), + rollback: sinon.stub(), + release: sinon.stub(), + systemUserId: () => 20 + }); + + sinon.stub(db, 'getDBConnection').returns(mockDBConnection); + + const findProjectsStub = sinon.stub(ProjectService.prototype, 'findProjects').resolves(mockFindProjectsResponse); + + const findProjectsCountStub = sinon + .stub(ProjectService.prototype, 'findProjectsCount') + .rejects(new Error('a test error')); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + + mockReq.query = { + keyword: 'keyword', + itis_tsns: ['123456'], + system_user_id: '11', + project_name: 'project name', + page: '2', + limit: '10', + sort: undefined, + order: undefined + }; + mockReq['keycloak_token'] = {}; + mockReq['system_user'] = { + role_names: [SYSTEM_ROLE.PROJECT_CREATOR] + }; + + const requestHandler = findProjects(); + + try { + await requestHandler(mockReq, mockRes, mockNext); + expect.fail(); + } catch (actualError) { + expect(mockDBConnection.open).to.have.been.calledOnce; + + expect(findProjectsStub).to.have.been.calledOnceWith(false, 20, sinon.match.object, sinon.match.object); + expect(findProjectsCountStub).to.have.been.calledOnceWith(false, 20, sinon.match.object); + + expect(mockDBConnection.rollback).to.have.been.calledOnce; + expect(mockDBConnection.release).to.have.been.calledOnce; + + expect((actualError as HTTPError).message).to.equal('a test error'); + } + }); +}); diff --git a/api/src/paths/project/index.ts b/api/src/paths/project/index.ts new file mode 100644 index 0000000000..609ef2e4d5 --- /dev/null +++ b/api/src/paths/project/index.ts @@ -0,0 +1,249 @@ +import { Request, RequestHandler } from 'express'; +import { Operation } from 'express-openapi'; +import { SYSTEM_ROLE } from '../../constants/roles'; +import { getDBConnection } from '../../database/db'; +import { IProjectAdvancedFilters } from '../../models/project-view'; +import { paginationRequestQueryParamSchema, paginationResponseSchema } from '../../openapi/schemas/pagination'; +import { authorizeRequestHandler, userHasValidRole } from '../../request-handlers/security/authorization'; +import { ProjectService } from '../../services/project-service'; +import { getLogger } from '../../utils/logger'; +import { + ensureCompletePaginationOptions, + makePaginationOptionsFromRequest, + makePaginationResponse +} from '../../utils/pagination'; + +const defaultLog = getLogger('paths/project/index'); + +export const GET: Operation = [ + authorizeRequestHandler(() => { + return { + and: [ + { + discriminator: 'SystemUser' + } + ] + }; + }), + findProjects() +]; + +GET.apiDoc = { + description: "Gets a list of projects based on the user's permissions and filter criteria.", + tags: ['projects'], + security: [ + { + Bearer: [] + } + ], + parameters: [ + { + in: 'query', + name: 'keyword', + required: false, + schema: { + type: 'string', + nullable: true + } + }, + { + in: 'query', + name: 'itis_tsns', + description: 'ITIS TSN numbers', + required: false, + schema: { + type: 'array', + items: { + type: 'integer' + }, + nullable: true + } + }, + { + in: 'query', + name: 'itis_tsn', + description: 'ITIS TSN number', + required: false, + schema: { + type: 'integer', + nullable: true + } + }, + { + in: 'query', + name: 'project_name', + required: false, + schema: { + type: 'string', + nullable: true + } + }, + { + in: 'query', + name: 'system_user_id', + required: false, + schema: { + type: 'number', + minimum: 1, + nullable: true + } + }, + ...paginationRequestQueryParamSchema + ], + responses: { + 200: { + description: 'Project response object.', + content: { + 'application/json': { + schema: { + type: 'object', + additionalProperties: false, + required: ['projects', 'pagination'], + properties: { + projects: { + type: 'array', + items: { + type: 'object', + additionalProperties: false, + required: ['project_id', 'name', 'start_date', 'end_date', 'focal_species', 'regions', 'types'], + properties: { + project_id: { + type: 'integer', + minimum: 1, + description: 'The primary id for the project' + }, + name: { + type: 'string', + description: 'The name of the project' + }, + start_date: { + type: 'string', + description: 'The earliest start date of the surveys in the project. ISO 8601 date string.', + nullable: true + }, + end_date: { + type: 'string', + description: 'The latest end date of the surveys in the project. ISO 8601 date string.', + nullable: true + }, + regions: { + type: 'array', + description: 'The regions of the surveys in the project', + items: { + type: 'string' + } + }, + focal_species: { + type: 'array', + description: 'The focal species of the surveys in the project', + items: { + type: 'integer' + } + }, + types: { + type: 'array', + description: 'The types of the surveys in the project', + items: { + type: 'integer' + } + } + } + } + }, + pagination: { ...paginationResponseSchema } + } + } + } + } + }, + 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' + } + } +}; + +/** + * Get projects for the current user, based on their permissions and filter criteria. + * + * @returns {RequestHandler} + */ +export function findProjects(): RequestHandler { + return async (req, res) => { + defaultLog.debug({ label: 'findProjects' }); + + const connection = getDBConnection(req['keycloak_token']); + + try { + await connection.open(); + + const systemUserId = connection.systemUserId(); + + const isUserAdmin = userHasValidRole( + [SYSTEM_ROLE.SYSTEM_ADMIN, SYSTEM_ROLE.DATA_ADMINISTRATOR], + req['system_user']['role_names'] + ); + + const filterFields = parseQueryParams(req); + + const paginationOptions = makePaginationOptionsFromRequest(req); + + const projectService = new ProjectService(connection); + + const [projects, projectsTotalCount] = await Promise.all([ + projectService.findProjects( + isUserAdmin, + systemUserId, + filterFields, + ensureCompletePaginationOptions(paginationOptions) + ), + projectService.findProjectsCount(isUserAdmin, systemUserId, filterFields) + ]); + + await connection.commit(); + + const response = { + projects, + pagination: makePaginationResponse(projectsTotalCount, paginationOptions) + }; + + // Allow browsers to cache this response for 30 seconds + res.setHeader('Cache-Control', 'private, max-age=30'); + + return res.status(200).json(response); + } catch (error) { + defaultLog.error({ label: 'findProjects', message: 'error', error }); + await connection.rollback(); + throw error; + } finally { + connection.release(); + } + }; +} + +/** + * Parse the query parameters from the request into the expected format. + * + * @param {Request} req + * @return {*} {IProjectAdvancedFilters} + */ +function parseQueryParams(req: Request): IProjectAdvancedFilters { + return { + keyword: req.query.keyword ?? undefined, + itis_tsns: req.query.itis_tsns ?? undefined, + itis_tsn: (req.query.itis_tsn && Number(req.query.itis_tsn)) ?? undefined, + project_name: req.query.project_name ?? undefined, + system_user_id: req.query.system_user_id ?? undefined + }; +} diff --git a/api/src/paths/project/list.test.ts b/api/src/paths/project/list.test.ts deleted file mode 100644 index 20674b8a09..0000000000 --- a/api/src/paths/project/list.test.ts +++ /dev/null @@ -1,145 +0,0 @@ -import Ajv from 'ajv'; -import chai, { expect } from 'chai'; -import { describe } from 'mocha'; -import sinon from 'sinon'; -import sinonChai from 'sinon-chai'; -import { SYSTEM_ROLE } from '../../constants/roles'; -import * as db from '../../database/db'; -import { HTTPError } from '../../errors/http-error'; -import * as authorization from '../../request-handlers/security/authorization'; -import { ProjectService } from '../../services/project-service'; -import { getMockDBConnection } from '../../__mocks__/db'; -import * as list from './list'; - -chai.use(sinonChai); - -describe('list', () => { - describe('openapi schema', () => { - const ajv = new Ajv(); - - it('is valid openapi v3 schema', () => { - expect(ajv.validateSchema(list.GET.apiDoc as unknown as object)).to.be.true; - }); - }); - - describe('getProjectList', () => { - const dbConnectionObj = getMockDBConnection(); - - const sampleReq = { - keycloak_token: {}, - system_user: { - role_names: [SYSTEM_ROLE.SYSTEM_ADMIN] - } - } as any; - - sampleReq.query = { - page: '1', - limit: '10' - }; - - let actualResult: any = null; - - const sampleRes = { - status: () => { - return { - json: (result: any) => { - actualResult = result; - } - }; - } - }; - - afterEach(() => { - sinon.restore(); - }); - - it('returns an empty array if no project ids are found', async () => { - sinon.stub(db, 'getDBConnection').returns({ - ...dbConnectionObj, - systemUserId: () => { - return 20; - } - }); - sinon.stub(authorization, 'userHasValidRole').returns(true); - sinon.stub(ProjectService.prototype, 'getProjectList').resolves([]); - sinon.stub(ProjectService.prototype, 'getProjectCount').resolves(0); - - const result = list.getProjectList(); - - await result(sampleReq, sampleRes as any, null as unknown as any); - - expect(actualResult).to.eql({ - pagination: { - current_page: 1, - last_page: 1, - total: 0, - sort: undefined, - order: undefined, - per_page: 10 - }, - projects: [] - }); - }); - - it('returns an array of projects', async () => { - sinon.stub(db, 'getDBConnection').returns({ - ...dbConnectionObj, - systemUserId: () => { - return 20; - } - }); - sinon.stub(authorization, 'userHasValidRole').returns(true); - - const getProjectListStub = sinon.stub(ProjectService.prototype, 'getProjectList').resolves([ - { - project_id: 1, - name: 'myproject', - regions: [] - } - ]); - sinon.stub(ProjectService.prototype, 'getProjectCount').resolves(1); - - const result = list.getProjectList(); - - await result(sampleReq, sampleRes as unknown as any, null as unknown as any); - - expect(actualResult).to.eql({ - pagination: { - current_page: 1, - last_page: 1, - total: 1, - sort: undefined, - order: undefined, - per_page: 10 - }, - projects: [ - { - project_id: 1, - name: 'myproject', - regions: [] - } - ] - }); - expect(getProjectListStub).to.be.calledOnce; - }); - - it('catches error, calls rollback, and re-throws error', async () => { - const dbConnectionObj = getMockDBConnection({ rollback: sinon.stub(), release: sinon.stub() }); - - sinon.stub(db, 'getDBConnection').returns(dbConnectionObj); - - sinon.stub(ProjectService.prototype, 'getProjectList').rejects(new Error('a test error')); - - try { - const requestHandler = list.getProjectList(); - - await requestHandler(sampleReq, sampleRes as any, null as unknown as any); - expect.fail('Expected an error to be thrown'); - } catch (actualError) { - expect(dbConnectionObj.release).to.have.been.called; - - expect((actualError as HTTPError).message).to.equal('a test error'); - } - }); - }); -}); diff --git a/api/src/paths/project/list.ts b/api/src/paths/project/list.ts deleted file mode 100644 index fac26a5220..0000000000 --- a/api/src/paths/project/list.ts +++ /dev/null @@ -1,181 +0,0 @@ -import { RequestHandler } from 'express'; -import { Operation } from 'express-openapi'; -import { SYSTEM_ROLE } from '../../constants/roles'; -import { getDBConnection } from '../../database/db'; -import { IProjectAdvancedFilters } from '../../models/project-view'; -import { paginationRequestQueryParamSchema, paginationResponseSchema } from '../../openapi/schemas/pagination'; -import { authorizeRequestHandler, userHasValidRole } from '../../request-handlers/security/authorization'; -import { ProjectService } from '../../services/project-service'; -import { getLogger } from '../../utils/logger'; -import { - ensureCompletePaginationOptions, - makePaginationOptionsFromRequest, - makePaginationResponse -} from '../../utils/pagination'; - -const defaultLog = getLogger('paths/projects'); - -export const GET: Operation = [ - authorizeRequestHandler(() => { - return { - and: [ - { - discriminator: 'SystemUser' - } - ] - }; - }), - getProjectList() -]; - -GET.apiDoc = { - description: 'Gets a list of projects based on search parameters if passed in.', - tags: ['projects'], - security: [ - { - Bearer: [] - } - ], - parameters: [...paginationRequestQueryParamSchema], - requestBody: { - description: 'Project list search filter criteria object.', - content: { - 'application/json': { - schema: { - properties: { - start_date: { - type: 'string', - nullable: true - }, - end_date: { - type: 'string', - nullable: true - }, - keyword: { - type: 'string', - nullable: true - }, - project_name: { - type: 'string', - nullable: true - }, - itis_tsns: { - type: 'array', - items: { - type: 'integer' - } - } - } - } - } - } - }, - responses: { - 200: { - description: 'Project response object.', - content: { - 'application/json': { - schema: { - type: 'object', - additionalProperties: false, - required: ['projects', 'pagination'], - properties: { - projects: { - type: 'array', - items: { - type: 'object', - additionalProperties: false, - required: ['project_id', 'name', 'regions'], - properties: { - project_id: { - type: 'integer' - }, - name: { - type: 'string' - }, - regions: { - type: 'array', - items: { - type: 'string' - } - } - } - } - }, - pagination: { ...paginationResponseSchema } - } - } - } - } - }, - 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' - } - } -}; - -/** - * Get all projects (potentially based on filter criteria). - * - * @returns {RequestHandler} - */ -export function getProjectList(): RequestHandler { - return async (req, res) => { - defaultLog.debug({ label: 'getProjectList' }); - - const connection = getDBConnection(req['keycloak_token']); - - try { - await connection.open(); - - const isUserAdmin = userHasValidRole( - [SYSTEM_ROLE.SYSTEM_ADMIN, SYSTEM_ROLE.DATA_ADMINISTRATOR], - req['system_user']['role_names'] - ); - const systemUserId = connection.systemUserId(); - const filterFields: IProjectAdvancedFilters = { - keyword: req.query.keyword && String(req.query.keyword), - project_name: req.query.project_name && String(req.query.project_name), - itis_tsns: req.query.itis_tsns ? String(req.query.itis_tsns).split(',').map(Number) : undefined - }; - - const paginationOptions = makePaginationOptionsFromRequest(req); - - const projectService = new ProjectService(connection); - const projects = await projectService.getProjectList( - isUserAdmin, - systemUserId, - filterFields, - ensureCompletePaginationOptions(paginationOptions) - ); - - const projectsTotalCount = await projectService.getProjectCount(filterFields, isUserAdmin, systemUserId); - - const response = { - projects, - pagination: makePaginationResponse(projectsTotalCount, paginationOptions) - }; - - await connection.commit(); - - return res.status(200).json(response); - } catch (error) { - defaultLog.error({ label: 'getProjectList', message: 'error', error }); - throw error; - } finally { - connection.release(); - } - }; -} diff --git a/api/src/paths/project/{projectId}/survey/index.ts b/api/src/paths/project/{projectId}/survey/index.ts index 6487b48837..1f6ae88c4a 100644 --- a/api/src/paths/project/{projectId}/survey/index.ts +++ b/api/src/paths/project/{projectId}/survey/index.ts @@ -89,7 +89,7 @@ GET.apiDoc = { }, end_date: { type: 'string', - description: 'ISO 8601 date string', + description: 'ISO 8601 datetime string', nullable: true }, progress_id: { @@ -164,7 +164,7 @@ export function getSurveys(): RequestHandler { return res.status(200).json(response); } catch (error) { - defaultLog.error({ label: 'getSurveyList', message: 'error', error }); + defaultLog.error({ label: 'getSurveys', message: 'error', error }); throw error; } finally { connection.release(); diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/critters/index.test.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/critters/index.test.ts index 50ae6ed34d..9ff7fafa33 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/critters/index.test.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/critters/index.test.ts @@ -15,7 +15,15 @@ describe('getCrittersFromSurvey', () => { const mockDBConnection = getMockDBConnection({ release: sinon.stub() }); const mockSurveyCritter = { critter_id: 123, survey_id: 123, critterbase_critter_id: 'critterbase1' }; - const mockCBCritter = { critter_id: 'critterbase1' }; + const mockCBCritter = { + critter_id: 'critterbase1', + wlh_id: 'wlh1', + animal_id: 'animal1', + sex: 'unknown', + itis_tsn: 12345, + itis_scientific_name: 'species1', + critter_comment: 'comment1' + }; const mockGetDBConnection = sinon.stub(db, 'getDBConnection').returns(mockDBConnection); const mockGetCrittersInSurvey = sinon diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/observations/index.test.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/observations/index.test.ts index 9b5ed61b4c..d9fa1c43bf 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/observations/index.test.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/observations/index.test.ts @@ -4,7 +4,7 @@ import sinon from 'sinon'; import sinonChai from 'sinon-chai'; import * as db from '../../../../../../database/db'; import { HTTPError } from '../../../../../../errors/http-error'; -import { ObservationRecordWithSamplingAndSubcountData } from '../../../../../../repositories/observation-repository'; +import { ObservationRecordWithSamplingAndSubcountData } from '../../../../../../repositories/observation-repository/observation-repository'; import { CritterbaseService } from '../../../../../../services/critterbase-service'; import { ObservationService } from '../../../../../../services/observation-service'; import { getMockDBConnection, getRequestHandlerMocks } from '../../../../../../__mocks__/db'; diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/observations/index.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/observations/index.ts index cbdb17374f..d670b31308 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/observations/index.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/observations/index.ts @@ -2,10 +2,8 @@ import { RequestHandler } from 'express'; import { Operation } from 'express-openapi'; import { PROJECT_PERMISSION, SYSTEM_ROLE } from '../../../../../../constants/roles'; import { getDBConnection } from '../../../../../../database/db'; -import { - paginationRequestQueryParamSchema, - paginationResponseSchema -} from '../../../../../../openapi/schemas/pagination'; +import { observervationsWithSubcountDataSchema } from '../../../../../../openapi/schemas/observation'; +import { paginationRequestQueryParamSchema } from '../../../../../../openapi/schemas/pagination'; import { authorizeRequestHandler } from '../../../../../../request-handlers/security/authorization'; import { CritterbaseService } from '../../../../../../services/critterbase-service'; import { InsertUpdateObservations, ObservationService } from '../../../../../../services/observation-service'; @@ -91,400 +89,7 @@ GET.apiDoc = { description: 'Survey Observations get response.', content: { 'application/json': { - schema: { - description: 'Survey get response object, for view purposes', - type: 'object', - additionalProperties: false, - required: ['surveyObservations', 'supplementaryObservationData', 'pagination'], - properties: { - surveyObservations: { - type: 'array', - items: { - type: 'object', - additionalProperties: false, - required: [ - 'survey_observation_id', - 'survey_id', - 'itis_tsn', - 'itis_scientific_name', - 'survey_sample_site_id', - 'survey_sample_method_id', - 'survey_sample_period_id', - 'latitude', - 'longitude', - 'count', - 'observation_date', - 'observation_time', - 'survey_sample_site_name', - 'survey_sample_method_name', - 'survey_sample_period_start_datetime' - ], - properties: { - survey_observation_id: { - type: 'integer', - minimum: 1 - }, - survey_id: { - type: 'integer', - minimum: 1 - }, - itis_tsn: { - type: 'integer' - }, - itis_scientific_name: { - type: 'string', - nullable: true - }, - survey_sample_site_id: { - type: 'integer', - minimum: 1, - nullable: true - }, - survey_sample_method_id: { - type: 'integer', - minimum: 1, - nullable: true - }, - survey_sample_period_id: { - type: 'integer', - minimum: 1, - nullable: true - }, - latitude: { - type: 'number' - }, - longitude: { - type: 'number' - }, - count: { - type: 'integer' - }, - observation_date: { - type: 'string' - }, - observation_time: { - type: 'string' - }, - survey_sample_site_name: { - type: 'string', - nullable: true - }, - survey_sample_method_name: { - type: 'string', - nullable: true - }, - survey_sample_period_start_datetime: { - type: 'string', - nullable: true - }, - subcounts: { - type: 'array', - items: { - type: 'object', - additionalProperties: false, - required: [ - 'observation_subcount_id', - 'subcount', - 'qualitative_measurements', - 'quantitative_measurements', - 'qualitative_environments', - 'quantitative_environments' - ], - properties: { - observation_subcount_id: { - type: 'integer' - }, - subcount: { - type: 'number' - }, - qualitative_measurements: { - type: 'array', - items: { - type: 'object', - additionalProperties: false, - required: [ - 'critterbase_taxon_measurement_id', - 'critterbase_measurement_qualitative_option_id' - ], - properties: { - critterbase_taxon_measurement_id: { - type: 'string', - format: 'uuid' - }, - critterbase_measurement_qualitative_option_id: { - type: 'string', - format: 'uuid' - } - } - } - }, - quantitative_measurements: { - type: 'array', - items: { - type: 'object', - additionalProperties: false, - required: ['critterbase_taxon_measurement_id', 'value'], - properties: { - critterbase_taxon_measurement_id: { - type: 'string', - format: 'uuid' - }, - value: { - type: 'number' - } - } - } - }, - qualitative_environments: { - type: 'array', - items: { - type: 'object', - additionalProperties: false, - required: ['environment_qualitative_id', 'environment_qualitative_option_id'], - properties: { - observation_subcount_qualitative_environment_id: { - type: 'integer' - }, - environment_qualitative_id: { - type: 'string', - format: 'uuid' - }, - environment_qualitative_option_id: { - type: 'string', - format: 'uuid' - } - } - } - }, - quantitative_environments: { - type: 'array', - items: { - type: 'object', - additionalProperties: false, - required: ['environment_quantitative_id', 'value'], - properties: { - environment_quantitative_id: { - type: 'string', - format: 'uuid' - }, - value: { - type: 'number' - } - } - } - } - } - } - } - } - } - }, - supplementaryObservationData: { - type: 'object', - additionalProperties: false, - required: [ - 'observationCount', - 'qualitative_measurements', - 'quantitative_measurements', - 'qualitative_environments', - 'quantitative_environments' - ], - properties: { - observationCount: { - type: 'integer', - minimum: 0 - }, - qualitative_measurements: { - description: 'All qualitative measurement type definitions for the survey.', - type: 'array', - items: { - description: 'A qualitative measurement type definition, with array of valid/accepted options', - type: 'object', - additionalProperties: false, - required: ['itis_tsn', 'taxon_measurement_id', 'measurement_name', 'measurement_desc', 'options'], - properties: { - itis_tsn: { - type: 'integer', - nullable: true - }, - taxon_measurement_id: { - type: 'string' - }, - measurement_name: { - type: 'string' - }, - measurement_desc: { - type: 'string', - nullable: true - }, - options: { - description: 'Valid options for the measurement.', - type: 'array', - items: { - type: 'object', - additionalProperties: false, - required: ['qualitative_option_id', 'option_label', 'option_value', 'option_desc'], - properties: { - qualitative_option_id: { - type: 'string' - }, - option_label: { - type: 'string', - nullable: true - }, - option_value: { - type: 'number' - }, - option_desc: { - type: 'string', - nullable: true - } - } - } - } - } - } - }, - quantitative_measurements: { - description: 'All quantitative measurement type definitions for the survey.', - type: 'array', - items: { - description: 'A quantitative measurement type definition, with possible min/max constraint.', - type: 'object', - additionalProperties: false, - required: [ - 'itis_tsn', - 'taxon_measurement_id', - 'measurement_name', - 'measurement_desc', - 'min_value', - 'max_value', - 'unit' - ], - properties: { - itis_tsn: { - type: 'integer', - nullable: true - }, - taxon_measurement_id: { - type: 'string' - }, - measurement_name: { - type: 'string' - }, - measurement_desc: { - type: 'string', - nullable: true - }, - min_value: { - type: 'number', - nullable: true - }, - max_value: { - type: 'number', - nullable: true - }, - unit: { - type: 'string', - nullable: true - } - } - } - }, - qualitative_environments: { - description: 'All qualitative environment type definitions for the survey.', - type: 'array', - items: { - description: 'A qualitative environment type definition, with array of valid/accepted options', - type: 'object', - additionalProperties: false, - required: ['environment_qualitative_id', 'name', 'description', 'options'], - properties: { - environment_qualitative_id: { - type: 'string', - format: 'uuid' - }, - name: { - type: 'string' - }, - description: { - type: 'string', - nullable: true - }, - options: { - description: 'Valid options for the environment.', - type: 'array', - items: { - type: 'object', - additionalProperties: false, - required: [ - 'environment_qualitative_option_id', - 'environment_qualitative_id', - 'name', - 'description' - ], - properties: { - environment_qualitative_option_id: { - type: 'string', - format: 'uuid' - }, - environment_qualitative_id: { - type: 'string', - format: 'uuid' - }, - name: { - type: 'string' - }, - description: { - type: 'string', - nullable: true - } - } - } - } - } - } - }, - quantitative_environments: { - description: 'All quantitative environment type definitions for the survey.', - type: 'array', - items: { - description: 'A quantitative environment type definition, with possible min/max constraint.', - type: 'object', - additionalProperties: false, - required: ['environment_quantitative_id', 'name', 'description', 'min', 'max', 'unit'], - properties: { - environment_quantitative_id: { - type: 'string', - format: 'uuid' - }, - name: { - type: 'string' - }, - description: { - type: 'string', - nullable: true - }, - min: { - type: 'number', - nullable: true - }, - max: { - type: 'number', - nullable: true - }, - unit: { - type: 'string', - nullable: true - } - } - } - } - } - }, - pagination: { ...paginationResponseSchema } - } - } + schema: observervationsWithSubcountDataSchema } } }, diff --git a/api/src/paths/survey/index.test.ts b/api/src/paths/survey/index.test.ts new file mode 100644 index 0000000000..c9a7f502b3 --- /dev/null +++ b/api/src/paths/survey/index.test.ts @@ -0,0 +1,150 @@ +import chai, { expect } from 'chai'; +import { describe } from 'mocha'; +import sinon from 'sinon'; +import sinonChai from 'sinon-chai'; +import { SYSTEM_ROLE } from '../../constants/roles'; +import * as db from '../../database/db'; +import { HTTPError } from '../../errors/http-error'; +import { FindSurveysResponse } from '../../models/survey-view'; +import { SurveyService } from '../../services/survey-service'; +import { getMockDBConnection, getRequestHandlerMocks } from '../../__mocks__/db'; +import { findSurveys } from './index'; + +chai.use(sinonChai); + +describe('findSurveys', () => { + afterEach(() => { + sinon.restore(); + }); + + it('finds and returns surveys', async () => { + const mockFindSurveysResponse: FindSurveysResponse[] = [ + { + project_id: 1, + survey_id: 2, + name: 'survey name', + progress_id: 3, + regions: ['region1'], + start_date: '2021-01-01', + end_date: '2021-01-31', + focal_species: [123, 456], + types: [1, 2] + } + ]; + + const mockDBConnection = getMockDBConnection({ + open: sinon.stub(), + commit: sinon.stub(), + release: sinon.stub(), + systemUserId: () => 20 + }); + + sinon.stub(db, 'getDBConnection').returns(mockDBConnection); + + const findSurveysStub = sinon.stub(SurveyService.prototype, 'findSurveys').resolves(mockFindSurveysResponse); + + const findSurveysCountStub = sinon.stub(SurveyService.prototype, 'findSurveysCount').resolves(50); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + + mockReq.query = { + keyword: 'keyword', + itis_tsns: ['123456'], + start_date: '2021-01-01', + end_date: '2021-01-31', + system_user_id: '11', + survey_name: 'survey name', + page: '2', + limit: '10', + sort: undefined, + order: undefined + }; + mockReq['keycloak_token'] = {}; + mockReq['system_user'] = { + role_names: [SYSTEM_ROLE.SYSTEM_ADMIN] + }; + + const requestHandler = findSurveys(); + + await requestHandler(mockReq, mockRes, mockNext); + + expect(mockDBConnection.open).to.have.been.calledOnce; + expect(mockDBConnection.commit).to.have.been.calledOnce; + + expect(findSurveysStub).to.have.been.calledOnceWith(true, 20, sinon.match.object, sinon.match.object); + expect(findSurveysCountStub).to.have.been.calledOnceWith(true, 20, sinon.match.object); + + expect(mockRes.jsonValue.surveys).to.eql(mockFindSurveysResponse); + expect(mockRes.jsonValue.pagination).not.to.be.null; + + expect(mockDBConnection.release).to.have.been.calledOnce; + }); + + it('catches and re-throws error', async () => { + const mockFindSurveysResponse: FindSurveysResponse[] = [ + { + project_id: 1, + survey_id: 2, + name: 'survey name', + progress_id: 3, + regions: ['region1'], + start_date: '2021-01-01', + end_date: '2021-01-31', + focal_species: [123, 456], + types: [1, 2] + } + ]; + + const mockDBConnection = getMockDBConnection({ + open: sinon.stub(), + commit: sinon.stub(), + rollback: sinon.stub(), + release: sinon.stub(), + systemUserId: () => 20 + }); + + sinon.stub(db, 'getDBConnection').returns(mockDBConnection); + + const findSurveysStub = sinon.stub(SurveyService.prototype, 'findSurveys').resolves(mockFindSurveysResponse); + + const findSurveysCountStub = sinon + .stub(SurveyService.prototype, 'findSurveysCount') + .rejects(new Error('a test error')); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + + mockReq.query = { + keyword: 'keyword', + itis_tsns: ['123456'], + start_date: '2021-01-01', + end_date: '2021-01-31', + survey_name: 'survey name', + system_user_id: '11', + page: '2', + limit: '10', + sort: undefined, + order: undefined + }; + mockReq['keycloak_token'] = {}; + mockReq['system_user'] = { + role_names: [SYSTEM_ROLE.PROJECT_CREATOR] + }; + + const requestHandler = findSurveys(); + + try { + await requestHandler(mockReq, mockRes, mockNext); + expect.fail(); + } catch (actualError) { + expect(mockDBConnection.open).to.have.been.calledOnce; + + expect(findSurveysStub).to.have.been.calledOnceWith(false, 20, sinon.match.object, sinon.match.object); + expect(findSurveysCountStub).to.have.been.calledOnceWith(false, 20, sinon.match.object); + + expect(mockDBConnection.rollback).to.have.been.calledOnce; + expect(mockDBConnection.release).to.have.been.calledOnce; + + expect((actualError as HTTPError).message).to.equal('a test error'); + } + }); +}); diff --git a/api/src/paths/survey/index.ts b/api/src/paths/survey/index.ts new file mode 100644 index 0000000000..a68d30ded4 --- /dev/null +++ b/api/src/paths/survey/index.ts @@ -0,0 +1,287 @@ +import { Request, RequestHandler } from 'express'; +import { Operation } from 'express-openapi'; +import { SYSTEM_ROLE } from '../../constants/roles'; +import { getDBConnection } from '../../database/db'; +import { ISurveyAdvancedFilters } from '../../models/survey-view'; +import { paginationRequestQueryParamSchema, paginationResponseSchema } from '../../openapi/schemas/pagination'; +import { authorizeRequestHandler, userHasValidRole } from '../../request-handlers/security/authorization'; +import { SurveyService } from '../../services/survey-service'; +import { getLogger } from '../../utils/logger'; +import { + ensureCompletePaginationOptions, + makePaginationOptionsFromRequest, + makePaginationResponse +} from '../../utils/pagination'; + +const defaultLog = getLogger('paths/survey/index'); + +export const GET: Operation = [ + authorizeRequestHandler(() => { + return { + and: [ + { + discriminator: 'SystemUser' + } + ] + }; + }), + findSurveys() +]; + +GET.apiDoc = { + description: "Gets a list of surveys based on the user's permissions and filter criteria.", + tags: ['surveys'], + security: [ + { + Bearer: [] + } + ], + parameters: [ + { + in: 'query', + name: 'keyword', + required: false, + schema: { + type: 'string', + nullable: true + } + }, + { + in: 'query', + name: 'itis_tsns', + description: 'ITIS TSN numbers', + required: false, + schema: { + type: 'array', + items: { + type: 'integer' + }, + nullable: true + } + }, + { + in: 'query', + name: 'itis_tsn', + description: 'ITIS TSN number', + required: false, + schema: { + type: 'integer', + nullable: true + } + }, + { + in: 'query', + name: 'start_date', + description: 'ISO 8601 datetime string', + required: false, + schema: { + type: 'string', + nullable: true + } + }, + { + in: 'query', + name: 'end_date', + description: 'ISO 8601 datetime string', + required: false, + schema: { + type: 'string', + nullable: true + } + }, + { + in: 'query', + name: 'survey_name', + required: false, + schema: { + type: 'string', + nullable: true + } + }, + { + in: 'query', + name: 'system_user_id', + required: false, + schema: { + type: 'number', + minimum: 1, + nullable: true + } + }, + ...paginationRequestQueryParamSchema + ], + responses: { + 200: { + description: 'Survey response object.', + content: { + 'application/json': { + schema: { + type: 'object', + additionalProperties: false, + required: ['surveys', 'pagination'], + properties: { + surveys: { + type: 'array', + items: { + type: 'object', + additionalProperties: false, + required: [ + 'project_id', + 'survey_id', + 'name', + 'progress_id', + 'start_date', + 'end_date', + 'regions', + 'focal_species', + 'types' + ], + properties: { + project_id: { + type: 'integer', + minimum: 1 + }, + survey_id: { + type: 'integer', + minimum: 1 + }, + name: { + type: 'string' + }, + progress_id: { + type: 'integer', + minimum: 1 + }, + start_date: { + type: 'string', + description: 'ISO 8601 datetime string', + nullable: true + }, + end_date: { + type: 'string', + description: 'ISO 8601 datetime string', + nullable: true + }, + regions: { + type: 'array', + items: { + type: 'string' + }, + nullable: true + }, + focal_species: { + type: 'array', + items: { + type: 'integer' + }, + nullable: true + }, + types: { + type: 'array', + items: { + type: 'integer', + nullable: true + } + } + } + } + }, + pagination: { ...paginationResponseSchema } + } + } + } + } + }, + 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' + } + } +}; + +/** + * Get surveys for the current user, based on their permissions and filter criteria. + * + * @returns {RequestHandler} + */ +export function findSurveys(): RequestHandler { + return async (req, res) => { + defaultLog.debug({ label: 'findSurveys' }); + + const connection = getDBConnection(req['keycloak_token']); + + try { + await connection.open(); + + const systemUserId = connection.systemUserId(); + + const isUserAdmin = userHasValidRole( + [SYSTEM_ROLE.SYSTEM_ADMIN, SYSTEM_ROLE.DATA_ADMINISTRATOR], + req['system_user']['role_names'] + ); + + const filterFields = parseQueryParams(req); + + const paginationOptions = makePaginationOptionsFromRequest(req); + + const surveyService = new SurveyService(connection); + + const [surveys, surveysTotalCount] = await Promise.all([ + surveyService.findSurveys( + isUserAdmin, + systemUserId, + filterFields, + ensureCompletePaginationOptions(paginationOptions) + ), + surveyService.findSurveysCount(isUserAdmin, systemUserId, filterFields) + ]); + + await connection.commit(); + + const response = { + surveys, + pagination: makePaginationResponse(surveysTotalCount, paginationOptions) + }; + + // Allow browsers to cache this response for 30 seconds + res.setHeader('Cache-Control', 'private, max-age=30'); + + return res.status(200).json(response); + } catch (error) { + defaultLog.error({ label: 'findSurveys', message: 'error', error }); + await connection.rollback(); + throw error; + } finally { + connection.release(); + } + }; +} + +/** + * Parse the query parameters from the request into the expected format. + * + * @param {Request} req + * @return {*} {ISurveyAdvancedFilters} + */ +function parseQueryParams(req: Request): ISurveyAdvancedFilters { + return { + keyword: req.query.keyword ?? undefined, + itis_tsns: req.query.itis_tsns ?? undefined, + itis_tsn: (req.query.itis_tsn && Number(req.query.itis_tsn)) ?? undefined, + start_date: req.query.start_date ?? undefined, + end_date: req.query.end_date ?? undefined, + survey_name: req.query.survey_name ?? undefined, + system_user_id: req.query.system_user_id ?? undefined + }; +} diff --git a/api/src/paths/telemetry/deployments.test.ts b/api/src/paths/telemetry/deployments.test.ts index a474d65ae6..0e66886c56 100644 --- a/api/src/paths/telemetry/deployments.test.ts +++ b/api/src/paths/telemetry/deployments.test.ts @@ -1,17 +1,29 @@ import { expect } from 'chai'; import sinon from 'sinon'; -import { BctwService, IManualTelemetry } from '../../services/bctw-service'; +import { BctwService, IAllTelemetry } from '../../services/bctw-service'; import { getRequestHandlerMocks } from '../../__mocks__/db'; import { getAllTelemetryByDeploymentIds } from './deployments'; -const mockTelemetry = [ +const mockTelemetry: IAllTelemetry[] = [ { - telemetry_manual_id: 1 + telemetry_id: null, + telemetry_manual_id: '123-123-123', + deployment_id: '345-345-345', + latitude: 49.123, + longitude: -126.123, + acquisition_date: '2021-01-01', + telemetry_type: 'manual' }, { - telemetry_manual_id: 2 + telemetry_id: '567-567-567', + telemetry_manual_id: null, + deployment_id: '345-345-345', + latitude: 49.123, + longitude: -126.123, + acquisition_date: '2021-01-01', + telemetry_type: 'vendor' } -] as unknown[] as IManualTelemetry[]; +]; describe('getAllTelemetryByDeploymentIds', () => { afterEach(() => { diff --git a/api/src/paths/telemetry/index.test.ts b/api/src/paths/telemetry/index.test.ts new file mode 100644 index 0000000000..9700ce479c --- /dev/null +++ b/api/src/paths/telemetry/index.test.ts @@ -0,0 +1,127 @@ +import chai, { expect } from 'chai'; +import { describe } from 'mocha'; +import sinon from 'sinon'; +import sinonChai from 'sinon-chai'; +import { SYSTEM_ROLE } from '../../constants/roles'; +import * as db from '../../database/db'; +import { HTTPError } from '../../errors/http-error'; +import { FindTelemetryResponse, TelemetryService } from '../../services/telemetry-service'; +import { getMockDBConnection, getRequestHandlerMocks } from '../../__mocks__/db'; +import { findTelemetry } from './index'; + +chai.use(sinonChai); + +describe('findTelemetry', () => { + afterEach(() => { + sinon.restore(); + }); + + it('finds and returns telemetry', async () => { + const mockFindTelemetryResponse: FindTelemetryResponse[] = [ + { + telemetry_id: '789-789-789', + acquisition_date: '2021-01-01', + latitude: 49.123, + longitude: -126.123, + telemetry_type: 'vendor', + device_id: 123, + bctw_deployment_id: '123-123-123', + critter_id: 1, + deployment_id: 2, + critterbase_critter_id: '456-456-456', + animal_id: '678-678-678' + } + ]; + + const mockDBConnection = getMockDBConnection({ + open: sinon.stub(), + commit: sinon.stub(), + release: sinon.stub(), + systemUserId: () => 20 + }); + + sinon.stub(db, 'getDBConnection').returns(mockDBConnection); + + const findTelemetryStub = sinon + .stub(TelemetryService.prototype, 'findTelemetry') + .resolves(mockFindTelemetryResponse); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + + mockReq.query = { + keyword: 'keyword', + itis_tsns: ['123456'], + system_user_id: '11', + page: '2', + limit: '10', + sort: undefined, + order: undefined + }; + mockReq['keycloak_token'] = {}; + mockReq['system_user'] = { + role_names: [SYSTEM_ROLE.SYSTEM_ADMIN] + }; + + const requestHandler = findTelemetry(); + + await requestHandler(mockReq, mockRes, mockNext); + + expect(mockDBConnection.open).to.have.been.calledOnce; + expect(mockDBConnection.commit).to.have.been.calledOnce; + + expect(findTelemetryStub).to.have.been.calledOnceWith(true, 20, sinon.match.object, sinon.match.object); + + expect(mockRes.jsonValue.telemetry).to.eql(mockFindTelemetryResponse); + expect(mockRes.jsonValue.pagination).not.to.be.null; + + expect(mockDBConnection.release).to.have.been.calledOnce; + }); + + it('catches and re-throws error', async () => { + const mockDBConnection = getMockDBConnection({ + open: sinon.stub(), + commit: sinon.stub(), + rollback: sinon.stub(), + release: sinon.stub(), + systemUserId: () => 20 + }); + + sinon.stub(db, 'getDBConnection').returns(mockDBConnection); + + const findTelemetryStub = sinon + .stub(TelemetryService.prototype, 'findTelemetry') + .rejects(new Error('a test error')); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + + mockReq.query = { + keyword: 'keyword', + itis_tsns: ['123456'], + system_user_id: '11', + page: '2', + limit: '10', + sort: undefined, + order: undefined + }; + mockReq['keycloak_token'] = {}; + mockReq['system_user'] = { + role_names: [SYSTEM_ROLE.PROJECT_CREATOR] + }; + + const requestHandler = findTelemetry(); + + try { + await requestHandler(mockReq, mockRes, mockNext); + expect.fail(); + } catch (actualError) { + expect(mockDBConnection.open).to.have.been.calledOnce; + + expect(findTelemetryStub).to.have.been.calledOnceWith(false, 20, sinon.match.object, sinon.match.object); + + expect(mockDBConnection.rollback).to.have.been.calledOnce; + expect(mockDBConnection.release).to.have.been.calledOnce; + + expect((actualError as HTTPError).message).to.equal('a test error'); + } + }); +}); diff --git a/api/src/paths/telemetry/index.ts b/api/src/paths/telemetry/index.ts new file mode 100644 index 0000000000..a95c986d40 --- /dev/null +++ b/api/src/paths/telemetry/index.ts @@ -0,0 +1,246 @@ +import { Request, RequestHandler } from 'express'; +import { Operation } from 'express-openapi'; +import { SYSTEM_ROLE } from '../../constants/roles'; +import { getDBConnection } from '../../database/db'; +import { ITelemetryAdvancedFilters } from '../../models/telemetry-view'; +import { paginationRequestQueryParamSchema, paginationResponseSchema } from '../../openapi/schemas/pagination'; +import { authorizeRequestHandler, userHasValidRole } from '../../request-handlers/security/authorization'; +import { TelemetryService } from '../../services/telemetry-service'; +import { getLogger } from '../../utils/logger'; +import { + ensureCompletePaginationOptions, + makePaginationOptionsFromRequest, + makePaginationResponse +} from '../../utils/pagination'; + +const defaultLog = getLogger('paths/telemetry'); + +export const GET: Operation = [ + authorizeRequestHandler(() => { + return { + and: [ + { + discriminator: 'SystemUser' + } + ] + }; + }), + findTelemetry() +]; + +GET.apiDoc = { + description: "Gets a list of telemetry based on the user's permissions and filter criteria.", + tags: ['telemetry'], + security: [ + { + Bearer: [] + } + ], + parameters: [ + { + in: 'query', + name: 'keyword', + required: false, + schema: { + type: 'string', + nullable: true + } + }, + { + in: 'query', + name: 'itis_tsns', + description: 'ITIS TSN numbers', + required: false, + schema: { + type: 'array', + items: { + type: 'integer' + }, + nullable: true + } + }, + { + in: 'query', + name: 'itis_tsn', + description: 'ITIS TSN number', + required: false, + schema: { + type: 'integer', + nullable: true + } + }, + { + in: 'query', + name: 'system_user_id', + required: false, + schema: { + type: 'number', + minimum: 1, + nullable: true + } + }, + ...paginationRequestQueryParamSchema + ], + responses: { + 200: { + description: 'Telemetry response object.', + content: { + 'application/json': { + schema: { + type: 'object', + additionalProperties: false, + required: ['telemetry', 'pagination'], + properties: { + telemetry: { + type: 'array', + items: { + type: 'object', + additionalProperties: false, + properties: { + telemetry_id: { + type: 'number', + description: 'The BCTW telemetry record ID.' + }, + acquisition_date: { + type: 'string', + nullable: true, + description: 'The BCTW telemetry record acquisition date.' + }, + latitude: { + type: 'number', + nullable: true, + description: 'The BCTW telemetry record latitude.' + }, + longitude: { + type: 'number', + nullable: true, + description: 'The BCTW telemetry record longitude.' + }, + telemetry_type: { + type: 'string', + description: 'The BCTW telemetry type.' + }, + device_id: { + type: 'number', + description: 'The BCTW device ID.' + }, + bctw_deployment_id: { + type: 'string', + format: 'uuid', + description: 'The BCTW deployment ID.' + }, + critter_id: { + type: 'number', + minimum: 1, + description: 'The SIMS critter record ID.' + }, + deployment_id: { + type: 'number', + minimum: 1, + description: 'The SIMS deployment record ID.' + }, + critterbase_critter_id: { + type: 'string', + format: 'uuid', + description: 'The Critterbase critter ID.' + }, + animal_id: { + type: 'string', + nullable: true, + description: 'The Critterbase animal ID.' + } + } + } + }, + pagination: { ...paginationResponseSchema } + } + } + } + } + }, + 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' + } + } +}; + +/** + * Get telemetry for the current user, based on their permissions and filter criteria. + * + * @returns {RequestHandler} + */ +export function findTelemetry(): RequestHandler { + return async (req, res) => { + defaultLog.debug({ label: 'findTelemetry' }); + + const connection = getDBConnection(req['keycloak_token']); + + try { + await connection.open(); + + const systemUserId = connection.systemUserId(); + + const isUserAdmin = userHasValidRole( + [SYSTEM_ROLE.SYSTEM_ADMIN, SYSTEM_ROLE.DATA_ADMINISTRATOR], + req['system_user']['role_names'] + ); + + const filterFields = parseQueryParams(req); + + const paginationOptions = makePaginationOptionsFromRequest(req); + + const telemetryService = new TelemetryService(connection); + + const telemetry = await telemetryService.findTelemetry( + isUserAdmin, + systemUserId, + filterFields, + ensureCompletePaginationOptions(paginationOptions) + ); + + await connection.commit(); + + // Allow browsers to cache this response for 30 seconds + res.setHeader('Cache-Control', 'private, max-age=30'); + + return res + .status(200) + .json({ telemetry: telemetry, pagination: makePaginationResponse(telemetry.length, paginationOptions) }); + } catch (error) { + defaultLog.error({ label: 'findTelemetry', message: 'error', error }); + await connection.rollback(); + throw error; + } finally { + connection.release(); + } + }; +} + +/** + * Parse the query parameters from the request into the expected format. + * + * @param {Request} req + * @return {*} {ITelemetryAdvancedFilters} + */ +function parseQueryParams( + req: Request +): ITelemetryAdvancedFilters { + return { + keyword: req.query.keyword ?? undefined, + itis_tsns: req.query.itis_tsns ?? undefined, + itis_tsn: (req.query.itis_tsn && Number(req.query.itis_tsn)) ?? undefined, + system_user_id: req.query.system_user_id ?? undefined + }; +} diff --git a/api/src/paths/telemetry/vendor/deployments.test.ts b/api/src/paths/telemetry/vendor/deployments.test.ts index 5f3f62f84b..cb9cf157f4 100644 --- a/api/src/paths/telemetry/vendor/deployments.test.ts +++ b/api/src/paths/telemetry/vendor/deployments.test.ts @@ -1,17 +1,35 @@ import { expect } from 'chai'; import sinon from 'sinon'; -import { BctwService, IManualTelemetry } from '../../../services/bctw-service'; +import { BctwService, IVendorTelemetry } from '../../../services/bctw-service'; import { getRequestHandlerMocks } from '../../../__mocks__/db'; import { getVendorTelemetryByDeploymentIds } from './deployments'; -const mockTelemetry = [ +const mockTelemetry: IVendorTelemetry[] = [ { - telemetry_manual_id: 1 + telemetry_id: '123-123-123', + deployment_id: '345-345-345', + latitude: 49.123, + longitude: -126.123, + acquisition_date: '2021-01-01', + collar_transaction_id: '45-45-45', + critter_id: '78-78-78', + deviceid: 123456, + elevation: 200, + vendor: 'vendor1' }, { - telemetry_manual_id: 2 + telemetry_id: '456-456-456', + deployment_id: '789-789-789', + latitude: 49.123, + longitude: -126.123, + acquisition_date: '2021-01-01', + collar_transaction_id: '54-54-54', + critter_id: '87-87-87', + deviceid: 654321, + elevation: 10, + vendor: 'vendor2' } -] as unknown[] as IManualTelemetry[]; +]; describe('getVendorTelemetryByDeploymentIds', () => { afterEach(() => { diff --git a/api/src/repositories/observation-repository.test.ts b/api/src/repositories/observation-repository/observation-repository.test.ts similarity index 99% rename from api/src/repositories/observation-repository.test.ts rename to api/src/repositories/observation-repository/observation-repository.test.ts index d28877ce69..4b3f953c5b 100644 --- a/api/src/repositories/observation-repository.test.ts +++ b/api/src/repositories/observation-repository/observation-repository.test.ts @@ -4,7 +4,7 @@ import { QueryResult } from 'pg'; import sinon from 'sinon'; import sinonChai from 'sinon-chai'; import { SQLStatement } from 'sql-template-strings'; -import { getMockDBConnection } from '../__mocks__/db'; +import { getMockDBConnection } from '../../__mocks__/db'; import { InsertObservation, ObservationRepository, UpdateObservation } from './observation-repository'; chai.use(sinonChai); diff --git a/api/src/repositories/observation-repository.ts b/api/src/repositories/observation-repository/observation-repository.ts similarity index 62% rename from api/src/repositories/observation-repository.ts rename to api/src/repositories/observation-repository/observation-repository.ts index 3ed31fc931..85360f1354 100644 --- a/api/src/repositories/observation-repository.ts +++ b/api/src/repositories/observation-repository/observation-repository.ts @@ -1,20 +1,22 @@ import SQL from 'sql-template-strings'; import { z } from 'zod'; -import { getKnex } from '../database/db'; -import { ApiExecuteSQLError } from '../errors/api-error'; -import { getLogger } from '../utils/logger'; -import { GeoJSONPointZodSchema } from '../zod-schema/geoJsonZodSchema'; -import { ApiPaginationOptions } from '../zod-schema/pagination'; -import { BaseRepository } from './base-repository'; +import { getKnex } from '../../database/db'; +import { ApiExecuteSQLError } from '../../errors/api-error'; +import { IObservationAdvancedFilters } from '../../models/observation-view'; +import { getLogger } from '../../utils/logger'; +import { GeoJSONPointZodSchema } from '../../zod-schema/geoJsonZodSchema'; +import { ApiPaginationOptions } from '../../zod-schema/pagination'; +import { BaseRepository } from '../base-repository'; import { ObservationSubCountQualitativeEnvironmentRecord, ObservationSubCountQuantitativeEnvironmentRecord -} from './observation-subcount-environment-repository'; +} from '../observation-subcount-environment-repository'; import { ObservationSubCountQualitativeMeasurementRecord, ObservationSubCountQuantitativeMeasurementRecord -} from './observation-subcount-measurement-repository'; -import { ObservationSubCountRecord } from './subcount-repository'; +} from '../observation-subcount-measurement-repository'; +import { ObservationSubCountRecord } from '../subcount-repository'; +import { getSurveyObservationsBaseQuery, makeFindObservationsQuery } from './utils'; const defaultLog = getLogger('repositories/observation-repository'); @@ -168,6 +170,68 @@ export const ObservationSubmissionRecord = z.object({ export type ObservationSubmissionRecord = z.infer; export class ObservationRepository extends BaseRepository { + /** Retrieve the list of observations that the user has access to, based on filters and pagination options. + * + * @param {boolean} isUserAdmin Whether the user is an admin. + * @param {number | null} systemUserId The user's ID. + * @param {IObservationAdvancedFilters} filterFields The filter fields to apply. + * @param {ApiPaginationOptions} [pagination] The pagination options. + * @return {Promise} A promise resolving to the list of observations. + */ + async findObservations( + isUserAdmin: boolean, + systemUserId: number | null, + filterFields: IObservationAdvancedFilters, + pagination?: ApiPaginationOptions + ): Promise { + const query = makeFindObservationsQuery(isUserAdmin, systemUserId, filterFields); + + if (pagination) { + query.limit(pagination.limit).offset((pagination.page - 1) * pagination.limit); + + if (pagination.sort && pagination.order) { + query.orderBy(pagination.sort, pagination.order); + } + } + + const response = await this.connection.knex(query, ObservationRecordWithSamplingAndSubcountData); + + return response.rows; + } + + /** + * Retrieves a paginated set of observation records for the given survey, including data for + * associated sampling records. + * + * @param {number} surveyId The ID of the survey. + * @param {ApiPaginationOptions} [pagination] The pagination options. + * @return {Promise} A promise resolving to the list of observations. + * @memberof ObservationRepository + */ + async getSurveyObservationsWithSamplingDataWithAttributesData( + surveyId: number, + pagination?: ApiPaginationOptions + ): Promise { + const knex = getKnex(); + + const query = getSurveyObservationsBaseQuery( + knex, + knex.select('survey_id').from('survey').where('survey_id', surveyId) + ); + + if (pagination) { + query.limit(pagination.limit).offset((pagination.page - 1) * pagination.limit); + + if (pagination.sort && pagination.order) { + query.orderBy(pagination.sort, pagination.order); + } + } + + const response = await this.connection.knex(query); + + return response.rows; + } + /** * Deletes all survey observation records associated with the given survey, except * for records whose ID belongs to the given array, then returns the count of @@ -240,7 +304,7 @@ export class ObservationRepository extends BaseRepository { observations .map((observation) => { return `(${[ - observation['survey_observation_id'] || 'DEFAULT', + 'survey_observation_id' in observation ? observation.survey_observation_id : 'DEFAULT', surveyId, observation.survey_sample_site_id ?? 'NULL', observation.survey_sample_method_id ?? 'NULL', @@ -282,262 +346,6 @@ export class ObservationRepository extends BaseRepository { return response.rows; } - /** - * Retrieves a paginated set of observation records for the given survey, including data for - * associated sampling records. - * - * @param {number} surveyId - * @param {ApiPaginationOptions} [pagination] - * @return {*} {Promise} - * @memberof ObservationRepository - */ - async getSurveyObservationsWithSamplingDataWithAttributesData( - surveyId: number, - pagination?: ApiPaginationOptions - ): Promise { - defaultLog.debug({ label: 'getSurveyObservationsWithSamplingDataWithAttributesData', surveyId, pagination }); - - const knex = getKnex(); - - const queryBuilder = knex - // Get all sample sites for the survey - .with( - 'w_survey_sample_site', - knex - .select('survey_sample_site_id', 'name as survey_sample_site_name') - .from('survey_sample_site') - .where('survey_id', surveyId) - ) - // Get all sample methods for the sample sites, and additionally fetch the method name - .with( - 'w_survey_sample_method', - knex - .select( - 'survey_sample_method.survey_sample_site_id', - 'survey_sample_method.survey_sample_method_id', - 'method_lookup.name as survey_sample_method_name' - ) - .from('survey_sample_method') - .innerJoin('method_lookup', 'survey_sample_method.method_lookup_id', 'method_lookup.method_lookup_id') - .innerJoin( - 'w_survey_sample_site', - 'survey_sample_method.survey_sample_site_id', - 'w_survey_sample_site.survey_sample_site_id' - ) - ) - // Get all sample periods for the sample methods, and additionally create a datetime field from the start date and time - .with( - 'w_survey_sample_period', - knex - .select( - 'w_survey_sample_method.survey_sample_site_id', - 'survey_sample_period.survey_sample_method_id', - 'survey_sample_period.survey_sample_period_id', - knex.raw( - `(survey_sample_period.start_date::date + COALESCE(survey_sample_period.start_time, '00:00:00')::time)::timestamp as survey_sample_period_start_datetime` - ) - ) - .from('survey_sample_period') - .innerJoin( - 'w_survey_sample_method', - 'survey_sample_period.survey_sample_method_id', - 'w_survey_sample_method.survey_sample_method_id' - ) - ) - // Get all qualitative measurements for all subcounts associated to all observations for the survey - .with( - 'w_qualitative_measurements', - knex - .select( - 'observation_subcount_id', - knex.raw(` - json_agg(json_build_object( - 'critterbase_taxon_measurement_id', critterbase_taxon_measurement_id, - 'critterbase_measurement_qualitative_option_id', critterbase_measurement_qualitative_option_id - )) as qualitative_measurements - `) - ) - .from('observation_subcount_qualitative_measurement') - .whereIn('observation_subcount_id', (qb1) => { - qb1 - .select('observation_subcount_id') - .from('observation_subcount') - .whereIn('survey_observation_id', (qb2) => { - qb2.select('survey_observation_id').from('survey_observation').where('survey_id', surveyId); - }); - }) - .groupBy('observation_subcount_id') - ) - // Get all quantitative measurements for all subcounts associated to all observations for the survey - .with( - 'w_quantitative_measurements', - knex - .select( - 'observation_subcount_id', - knex.raw(` - json_agg(json_build_object( - 'critterbase_taxon_measurement_id', critterbase_taxon_measurement_id, - 'value', value - )) as quantitative_measurements - `) - ) - .from('observation_subcount_quantitative_measurement') - .whereIn('observation_subcount_id', (qb1) => { - qb1 - .select('observation_subcount_id') - .from('observation_subcount') - .whereIn('survey_observation_id', (qb2) => { - qb2.select('survey_observation_id').from('survey_observation').where('survey_id', surveyId); - }); - }) - .groupBy('observation_subcount_id') - ) - // Get all qualitative environments for all subcounts associated to all observations for the survey - .with( - 'w_qualitative_environments', - knex - .select( - 'observation_subcount_id', - knex.raw(` - json_agg(json_build_object( - 'observation_subcount_qualitative_environment_id', observation_subcount_qualitative_environment_id, - 'environment_qualitative_id', environment_qualitative_id, - 'environment_qualitative_option_id', environment_qualitative_option_id - )) as qualitative_environments - `) - ) - .from('observation_subcount_qualitative_environment') - .whereIn('observation_subcount_id', (qb1) => { - qb1 - .select('observation_subcount_id') - .from('observation_subcount') - .whereIn('survey_observation_id', (qb2) => { - qb2.select('survey_observation_id').from('survey_observation').where('survey_id', surveyId); - }); - }) - .groupBy('observation_subcount_id') - ) - // Get all quantitative environments for all subcounts associated to all observations for the survey - .with( - 'w_quantitative_environments', - knex - .select( - 'observation_subcount_id', - knex.raw(` - json_agg(json_build_object( - 'observation_subcount_quantitative_environment_id', observation_subcount_quantitative_environment_id, - 'environment_quantitative_id', environment_quantitative_id, - 'value', value - )) as quantitative_environments - `) - ) - .from('observation_subcount_quantitative_environment') - .whereIn('observation_subcount_id', (qb1) => { - qb1 - .select('observation_subcount_id') - .from('observation_subcount') - .whereIn('survey_observation_id', (qb2) => { - qb2.select('survey_observation_id').from('survey_observation').where('survey_id', surveyId); - }); - }) - .groupBy('observation_subcount_id') - ) - // Rollup the subcount records into an array of objects for each observation - .with( - 'w_subcounts', - knex - .select( - 'survey_observation_id', - knex.raw(` - json_agg(json_build_object( - 'observation_subcount_id', observation_subcount.observation_subcount_id, - 'subcount', subcount, - 'qualitative_measurements', COALESCE(w_qualitative_measurements.qualitative_measurements, '[]'::json), - 'quantitative_measurements', COALESCE(w_quantitative_measurements.quantitative_measurements, '[]'::json), - 'qualitative_environments', COALESCE(w_qualitative_environments.qualitative_environments, '[]'::json), - 'quantitative_environments', COALESCE(w_quantitative_environments.quantitative_environments, '[]'::json) - )) as subcounts - `) - ) - .from('observation_subcount') - .leftJoin( - 'w_qualitative_measurements', - 'observation_subcount.observation_subcount_id', - 'w_qualitative_measurements.observation_subcount_id' - ) - .leftJoin( - 'w_quantitative_measurements', - 'observation_subcount.observation_subcount_id', - 'w_quantitative_measurements.observation_subcount_id' - ) - .leftJoin( - 'w_qualitative_environments', - 'observation_subcount.observation_subcount_id', - 'w_qualitative_environments.observation_subcount_id' - ) - .leftJoin( - 'w_quantitative_environments', - 'observation_subcount.observation_subcount_id', - 'w_quantitative_environments.observation_subcount_id' - ) - .whereIn( - 'survey_observation_id', - knex('survey_observation').select('survey_observation_id').where('survey_id', surveyId) - ) - .groupBy('survey_observation_id') - ) - // Return all observations for the surveys, including the additional sampling data, and rolled up subcount data - .select( - 'survey_observation.survey_observation_id', - 'survey_observation.survey_id', - 'survey_observation.itis_tsn', - 'survey_observation.itis_scientific_name', - 'survey_observation.survey_sample_site_id', - 'survey_observation.survey_sample_method_id', - 'survey_observation.survey_sample_period_id', - 'survey_observation.latitude', - 'survey_observation.longitude', - 'survey_observation.count', - 'survey_observation.observation_date', - 'survey_observation.observation_time', - 'w_survey_sample_site.survey_sample_site_name', - 'w_survey_sample_method.survey_sample_method_name', - 'w_survey_sample_period.survey_sample_period_start_datetime', - knex.raw(`COALESCE(w_subcounts.subcounts, '[]'::json) as subcounts`) - ) - .from('survey_observation') - .leftJoin( - 'w_survey_sample_site', - 'survey_observation.survey_sample_site_id', - 'w_survey_sample_site.survey_sample_site_id' - ) - .leftJoin( - 'w_survey_sample_method', - 'survey_observation.survey_sample_method_id', - 'w_survey_sample_method.survey_sample_method_id' - ) - .leftJoin( - 'w_survey_sample_period', - 'survey_observation.survey_sample_period_id', - 'w_survey_sample_period.survey_sample_period_id' - ) - // Note: inner join requires every observation record to have at least one subcount record, otherwise use left join - .innerJoin('w_subcounts', 'w_subcounts.survey_observation_id', 'survey_observation.survey_observation_id') - .where('survey_observation.survey_id', surveyId); - - if (pagination) { - queryBuilder.limit(pagination.limit).offset((pagination.page - 1) * pagination.limit); - - if (pagination.sort && pagination.order) { - queryBuilder.orderBy(pagination.sort, pagination.order); - } - } - - const response = await this.connection.knex(queryBuilder, ObservationRecordWithSamplingAndSubcountData); - - return response.rows; - } - /** * Gets a set of GeoJson geometries representing the set of all lat/long points for the * given survey's observations. @@ -626,6 +434,38 @@ export class ObservationRepository extends BaseRepository { return response.rows[0].count; } + /** + * Retrieves the count of survey observations for the given survey + * + * @param {boolean} isUserAdmin + * @param {(number | null)} systemUserId + * @param {IObservationAdvancedFilters} filterFields + * @return {*} {Promise} + * @memberof ObservationRepository + */ + async findObservationsCount( + isUserAdmin: boolean, + systemUserId: number | null, + filterFields: IObservationAdvancedFilters + ): Promise { + const observationListQuery = makeFindObservationsQuery(isUserAdmin, systemUserId, filterFields); + + const knex = getKnex(); + + const queryBuilder = knex.from(observationListQuery.as('olq')).select(knex.raw('count(*)::integer as count')); + + const response = await this.connection.knex(queryBuilder, z.object({ count: z.number() })); + + if (!response.rowCount) { + throw new ApiExecuteSQLError('Failed to get survey count', [ + 'findObservationsCount->findObservationsCount', + 'rows was null or undefined, expected rows != null' + ]); + } + + return response.rows[0].count; + } + /** * Inserts a survey observation submission record into the database and returns the record * diff --git a/api/src/repositories/observation-repository/utils.ts b/api/src/repositories/observation-repository/utils.ts new file mode 100644 index 0000000000..4ac32f7614 --- /dev/null +++ b/api/src/repositories/observation-repository/utils.ts @@ -0,0 +1,325 @@ +import { Knex } from 'knex'; +import { getKnex } from '../../database/db'; +import { IObservationAdvancedFilters } from '../../models/observation-view'; + +/** + * Generate the observation list query based on user access and filters. + * + * @param {boolean} isUserAdmin + * @param {number | null} systemUserId The system user id of the user making the request + * @param {IObservationAdvancedFilters} filterFields + * @return {*} {Knex.QueryBuilder} + */ +export function makeFindObservationsQuery( + isUserAdmin: boolean, + systemUserId: number | null, + filterFields: IObservationAdvancedFilters +): Knex.QueryBuilder { + const knex = getKnex(); + + const getSurveyIdsQuery = knex.select(['survey_id']).from('survey'); + + // Ensure that users can only see observations that they are participating in, unless they are an administrator. + if (!isUserAdmin) { + getSurveyIdsQuery.whereIn('survey.project_id', (subqueryBuilder) => + subqueryBuilder + .select('project.project_id') + .from('project') + .leftJoin('project_participation', 'project_participation.project_id', 'project.project_id') + .where('project_participation.system_user_id', systemUserId) + ); + } + + if (filterFields.system_user_id) { + getSurveyIdsQuery.whereIn('p.project_id', (subQueryBuilder) => { + subQueryBuilder + .select('project_id') + .from('project_participation') + .where('system_user_id', filterFields.system_user_id); + }); + } + + const getObservationsQuery = getSurveyObservationsBaseQuery(knex, getSurveyIdsQuery); + + if (filterFields.min_count) { + getObservationsQuery.andWhere('subcount', '>=', filterFields.min_count); + } + + if (filterFields.start_date) { + getObservationsQuery.andWhere('observation_date', '>=', filterFields.start_date); + } + + if (filterFields.end_date) { + getObservationsQuery.andWhere('observation_date', '<=', filterFields.end_date); + } + + if (filterFields.keyword) { + getObservationsQuery.where((subqueryBuilder) => { + subqueryBuilder.where('itis_scientific_name', 'ilike', `%${filterFields.keyword}%`); + if (!isNaN(Number(filterFields.keyword))) { + subqueryBuilder.orWhere('survey_observation.survey_observation_id', Number(filterFields.keyword)); + } + }); + } + + if (filterFields.start_time) { + getObservationsQuery.andWhere('time', '>=', filterFields.start_time); + } + + if (filterFields.end_time) { + getObservationsQuery.andWhere('time', '<=', filterFields.end_time); + } + + // Focal Species filter + if (filterFields.itis_tsns?.length) { + // multiple + getObservationsQuery.whereIn('itis_tsn', filterFields.itis_tsns); + } else if (filterFields.itis_tsn) { + // single + getObservationsQuery.where('itis_tsn', filterFields.itis_tsn); + } + + return getObservationsQuery; +} + +/** + * Get the base query for retrieving survey observations with sampling data. + * + * @param {Knex} knex The Knex instance. + * @param {Knex.QueryBuilder} getSurveyIdsQuery A knex query builder that returns a list of survey IDs, which will be + * used to filter the observations. + * @return {*} {Knex.QueryBuilder} The base query for retrieving survey observations, filtered by survey IDs returned by + * the getSurveyIdsQuery. + */ +export function getSurveyObservationsBaseQuery( + knex: Knex, + getSurveyIdsQuery: Knex.QueryBuilder +): Knex.QueryBuilder { + return ( + knex + // Get all sample sites for the survey + .with( + 'w_survey_sample_site', + knex + .select('survey_sample_site_id', 'name as survey_sample_site_name') + .from('survey_sample_site') + .whereIn('survey_id', getSurveyIdsQuery) + ) + // Get all sample methods for the sample sites, and additionally fetch the method name + .with( + 'w_survey_sample_method', + knex + .select( + 'survey_sample_method.survey_sample_site_id', + 'survey_sample_method.survey_sample_method_id', + 'method_lookup.name as survey_sample_method_name' + ) + .from('survey_sample_method') + .innerJoin('method_lookup', 'survey_sample_method.method_lookup_id', 'method_lookup.method_lookup_id') + .innerJoin( + 'w_survey_sample_site', + 'survey_sample_method.survey_sample_site_id', + 'w_survey_sample_site.survey_sample_site_id' + ) + ) + // Get all sample periods for the sample methods, and additionally create a datetime field from the start date and time + .with( + 'w_survey_sample_period', + knex + .select( + 'w_survey_sample_method.survey_sample_site_id', + 'survey_sample_period.survey_sample_method_id', + 'survey_sample_period.survey_sample_period_id', + knex.raw( + `(survey_sample_period.start_date::date + COALESCE(survey_sample_period.start_time, '00:00:00')::time)::timestamp as survey_sample_period_start_datetime` + ) + ) + .from('survey_sample_period') + .innerJoin( + 'w_survey_sample_method', + 'survey_sample_period.survey_sample_method_id', + 'w_survey_sample_method.survey_sample_method_id' + ) + ) + // Get all qualitative measurements for all subcounts associated to all observations for the survey + .with( + 'w_qualitative_measurements', + knex + .select( + 'observation_subcount_id', + knex.raw(` + json_agg(json_build_object( + 'critterbase_taxon_measurement_id', critterbase_taxon_measurement_id, + 'critterbase_measurement_qualitative_option_id', critterbase_measurement_qualitative_option_id + )) as qualitative_measurements + `) + ) + .from('observation_subcount_qualitative_measurement') + .whereIn('observation_subcount_id', (qb1) => { + qb1 + .select('observation_subcount_id') + .from('observation_subcount') + .whereIn('survey_observation_id', (qb2) => { + qb2.select('survey_observation_id').from('survey_observation').whereIn('survey_id', getSurveyIdsQuery); + }); + }) + .groupBy('observation_subcount_id') + ) + // Get all quantitative measurements for all subcounts associated to all observations for the survey + .with( + 'w_quantitative_measurements', + knex + .select( + 'observation_subcount_id', + knex.raw(` + json_agg(json_build_object( + 'critterbase_taxon_measurement_id', critterbase_taxon_measurement_id, + 'value', value + )) as quantitative_measurements + `) + ) + .from('observation_subcount_quantitative_measurement') + .whereIn('observation_subcount_id', (qb1) => { + qb1 + .select('observation_subcount_id') + .from('observation_subcount') + .whereIn('survey_observation_id', (qb2) => { + qb2.select('survey_observation_id').from('survey_observation').whereIn('survey_id', getSurveyIdsQuery); + }); + }) + .groupBy('observation_subcount_id') + ) + // Get all qualitative environments for all subcounts associated to all observations for the survey + .with( + 'w_qualitative_environments', + knex + .select( + 'observation_subcount_id', + knex.raw(` + json_agg(json_build_object( + 'observation_subcount_qualitative_environment_id', observation_subcount_qualitative_environment_id, + 'environment_qualitative_id', environment_qualitative_id, + 'environment_qualitative_option_id', environment_qualitative_option_id + )) as qualitative_environments + `) + ) + .from('observation_subcount_qualitative_environment') + .whereIn('observation_subcount_id', (qb1) => { + qb1 + .select('observation_subcount_id') + .from('observation_subcount') + .whereIn('survey_observation_id', (qb2) => { + qb2.select('survey_observation_id').from('survey_observation').whereIn('survey_id', getSurveyIdsQuery); + }); + }) + .groupBy('observation_subcount_id') + ) + // Get all quantitative environments for all subcounts associated to all observations for the survey + .with( + 'w_quantitative_environments', + knex + .select( + 'observation_subcount_id', + knex.raw(` + json_agg(json_build_object( + 'observation_subcount_quantitative_environment_id', observation_subcount_quantitative_environment_id, + 'environment_quantitative_id', environment_quantitative_id, + 'value', value + )) as quantitative_environments + `) + ) + .from('observation_subcount_quantitative_environment') + .whereIn('observation_subcount_id', (qb1) => { + qb1 + .select('observation_subcount_id') + .from('observation_subcount') + .whereIn('survey_observation_id', (qb2) => { + qb2.select('survey_observation_id').from('survey_observation').whereIn('survey_id', getSurveyIdsQuery); + }); + }) + .groupBy('observation_subcount_id') + ) + // Rollup the subcount records into an array of objects for each observation + .with( + 'w_subcounts', + knex + .select( + 'survey_observation_id', + knex.raw(` + json_agg(json_build_object( + 'observation_subcount_id', observation_subcount.observation_subcount_id, + 'subcount', subcount, + 'qualitative_measurements', COALESCE(w_qualitative_measurements.qualitative_measurements, '[]'::json), + 'quantitative_measurements', COALESCE(w_quantitative_measurements.quantitative_measurements, '[]'::json), + 'qualitative_environments', COALESCE(w_qualitative_environments.qualitative_environments, '[]'::json), + 'quantitative_environments', COALESCE(w_quantitative_environments.quantitative_environments, '[]'::json) + )) as subcounts + `) + ) + .from('observation_subcount') + .leftJoin( + 'w_qualitative_measurements', + 'observation_subcount.observation_subcount_id', + 'w_qualitative_measurements.observation_subcount_id' + ) + .leftJoin( + 'w_quantitative_measurements', + 'observation_subcount.observation_subcount_id', + 'w_quantitative_measurements.observation_subcount_id' + ) + .leftJoin( + 'w_qualitative_environments', + 'observation_subcount.observation_subcount_id', + 'w_qualitative_environments.observation_subcount_id' + ) + .leftJoin( + 'w_quantitative_environments', + 'observation_subcount.observation_subcount_id', + 'w_quantitative_environments.observation_subcount_id' + ) + .whereIn( + 'survey_observation_id', + knex('survey_observation').select('survey_observation_id').whereIn('survey_id', getSurveyIdsQuery) + ) + .groupBy('survey_observation_id') + ) + // Return all observations for the surveys, including the additional sampling data, and rolled up subcount data + .select( + 'survey_observation.survey_observation_id', + 'survey_observation.survey_id', + 'survey_observation.itis_tsn', + 'survey_observation.itis_scientific_name', + 'survey_observation.survey_sample_site_id', + 'survey_observation.survey_sample_method_id', + 'survey_observation.survey_sample_period_id', + 'survey_observation.latitude', + 'survey_observation.longitude', + 'survey_observation.count', + 'survey_observation.observation_date', + 'survey_observation.observation_time', + 'w_survey_sample_site.survey_sample_site_name', + 'w_survey_sample_method.survey_sample_method_name', + 'w_survey_sample_period.survey_sample_period_start_datetime', + knex.raw(`COALESCE(w_subcounts.subcounts, '[]'::json) as subcounts`) + ) + .from('survey_observation') + .leftJoin( + 'w_survey_sample_site', + 'survey_observation.survey_sample_site_id', + 'w_survey_sample_site.survey_sample_site_id' + ) + .leftJoin( + 'w_survey_sample_method', + 'survey_observation.survey_sample_method_id', + 'w_survey_sample_method.survey_sample_method_id' + ) + .leftJoin( + 'w_survey_sample_period', + 'survey_observation.survey_sample_period_id', + 'w_survey_sample_period.survey_sample_period_id' + ) + // Note: inner join requires every observation record to have at least one subcount record, otherwise use left join + .innerJoin('w_subcounts', 'w_subcounts.survey_observation_id', 'survey_observation.survey_observation_id') + .whereIn('survey_observation.survey_id', getSurveyIdsQuery) + ); +} diff --git a/api/src/repositories/project-repository.test.ts b/api/src/repositories/project-repository.test.ts index 3c92c404fb..d2ad1f8f3c 100644 --- a/api/src/repositories/project-repository.test.ts +++ b/api/src/repositories/project-repository.test.ts @@ -15,7 +15,7 @@ import { ProjectRepository } from './project-repository'; chai.use(sinonChai); describe('ProjectRepository', () => { - describe('getProjectList', () => { + describe('findProjects', () => { it('should return a list of projects', async () => { const mockResponse = { rows: [{ id: 1 }], rowCount: 1 } as any as Promise>; const dbConnection = getMockDBConnection({ knex: () => mockResponse }); @@ -30,7 +30,7 @@ describe('ProjectRepository', () => { keyword: 'string' }; - const response = await repository.getProjectList(false, 1, input); + const response = await repository.findProjects(false, 1, input); expect(response).to.eql([{ id: 1 }]); }); @@ -49,7 +49,7 @@ describe('ProjectRepository', () => { keyword: 'string' }; - const response = await repository.getProjectList(true, 1, input); + const response = await repository.findProjects(true, 1, input); expect(response).to.eql([{ id: 1 }]); }); @@ -64,20 +64,20 @@ describe('ProjectRepository', () => { keyword: 'a' }; - const response = await repository.getProjectList(true, 1, input); + const response = await repository.findProjects(true, 1, input); expect(response).to.eql([]); }); }); - describe('getProjectCount', () => { + describe('findProjectsCount', () => { it('should return a project count', async () => { const mockResponse = { rows: [{ count: 69 }], rowCount: 1 } as any as Promise>; const dbConnection = getMockDBConnection({ knex: () => mockResponse }); const repository = new ProjectRepository(dbConnection); - const response = await repository.getProjectCount({}, false, 1001); + const response = await repository.findProjectsCount(false, 1001, {}); expect(response).to.eql(69); }); @@ -89,7 +89,7 @@ describe('ProjectRepository', () => { const repository = new ProjectRepository(dbConnection); try { - await repository.getProjectCount({}, true, 1001); + await repository.findProjectsCount(true, 1001, {}); expect.fail(); } catch (error) { expect((error as Error).message).to.equal('Failed to get project count'); diff --git a/api/src/repositories/project-repository.ts b/api/src/repositories/project-repository.ts index 6066302fed..ab587d3b43 100644 --- a/api/src/repositories/project-repository.ts +++ b/api/src/repositories/project-repository.ts @@ -6,20 +6,17 @@ import { ApiExecuteSQLError } from '../errors/api-error'; import { PostProjectObject } from '../models/project-create'; import { PutObjectivesData, PutProjectData } from '../models/project-update'; import { + FindProjectsResponse, GetAttachmentsData, GetIUCNClassificationData, GetObjectivesData, GetReportAttachmentsData, IProjectAdvancedFilters, - ProjectData, - ProjectListData + ProjectData } from '../models/project-view'; -import { getLogger } from '../utils/logger'; import { ApiPaginationOptions } from '../zod-schema/pagination'; import { BaseRepository } from './base-repository'; -const defaultLog = getLogger('repositories/project-repository'); - /** * A repository class for accessing project data. * @@ -29,15 +26,15 @@ const defaultLog = getLogger('repositories/project-repository'); */ export class ProjectRepository extends BaseRepository { /** - * Constructs a non-paginated query used to get a project list for users. + * Constructs a non-paginated query used to get a list of projects based on the user's permissions and filter criteria. * * @param {boolean} isUserAdmin - * @param {(number | null)} systemUserId + * @param {(number | null)} systemUserId The system user id of the user making the request * @param {IProjectAdvancedFilters} filterFields - * @return {*} Promise + * @return {*} {Knex.QueryBuilder} * @memberof ProjectRepository */ - _makeProjectListQuery( + _makeFindProjectsQuery( isUserAdmin: boolean, systemUserId: number | null, filterFields: IProjectAdvancedFilters @@ -48,34 +45,49 @@ export class ProjectRepository extends BaseRepository { .select([ 'p.project_id', 'p.name', - knex.raw(`COALESCE(array_remove(array_agg(DISTINCT rl.region_name), null), '{}') as regions`) + knex.raw(`MIN(s.start_date) as start_date`), + knex.raw('MAX(s.end_date) as end_date'), + knex.raw(`COALESCE(array_remove(array_agg(DISTINCT rl.region_name), null), '{}') as regions`), + knex.raw('array_remove(array_agg(distinct sp.itis_tsn), null) as focal_species'), + knex.raw('array_remove(array_agg(distinct st.type_id), null) as types') ]) .from('project as p') .leftJoin('survey as s', 's.project_id', 'p.project_id') .leftJoin('study_species as sp', 'sp.survey_id', 's.survey_id') + .leftJoin('survey_type as st', 'sp.survey_id', 'st.survey_id') .leftJoin('survey_region as sr', 'sr.survey_id', 's.survey_id') .leftJoin('region_lookup as rl', 'sr.region_id', 'rl.region_id') - + .leftJoin('project_participation as ppa', 'p.project_id', 'ppa.project_id') .groupBy(['p.project_id', 'p.name', 'p.objectives']); - /* - * Ensure that users can only see project that they are participating in, unless - * they are an administrator. - */ + // Ensure that users can only see projects that they are participating in, unless they are an administrator. if (!isUserAdmin) { query.whereIn('p.project_id', (subQueryBuilder) => { subQueryBuilder.select('project_id').from('project_participation').where('system_user_id', systemUserId); }); } - // Project Name filter (exact match) + if (filterFields.system_user_id) { + query.whereIn('p.project_id', (subQueryBuilder) => { + subQueryBuilder + .select('project_id') + .from('project_participation') + .where('system_user_id', filterFields.system_user_id); + }); + } + + // Project Name filter (like match) if (filterFields.project_name) { - query.andWhere('p.name', filterFields.project_name); + query.andWhere('p.name', 'ilike', `%${filterFields.project_name}%`); } // Focal Species filter if (filterFields.itis_tsns?.length) { + // multiple query.whereIn('sp.itis_tsn', filterFields.itis_tsns); + } else if (filterFields.itis_tsn) { + // single + query.where('sp.itis_tsn', filterFields.itis_tsn); } // Keyword Search filter @@ -86,6 +98,11 @@ export class ProjectRepository extends BaseRepository { .where('p.name', 'ilike', keywordMatch) .orWhere('p.objectives', 'ilike', keywordMatch) .orWhere('s.name', 'ilike', keywordMatch); + + // If the keyword is a number, also match on project Id + if (!isNaN(Number(filterFields.keyword))) { + subQueryBuilder.orWhere('p.project_id', Number(filterFields.keyword)); + } }); } @@ -99,20 +116,17 @@ export class ProjectRepository extends BaseRepository { * @param {(number | null)} systemUserId * @param {IProjectAdvancedFilters} filterFields * @param {ApiPaginationOptions} [pagination] - * @return {*} {Promise} + * @return {*} {Promise} * @memberof ProjectRepository */ - async getProjectList( + async findProjects( isUserAdmin: boolean, systemUserId: number | null, filterFields: IProjectAdvancedFilters, pagination?: ApiPaginationOptions - ): Promise { - defaultLog.debug({ label: 'getProjectList', pagination }); - - const query = this._makeProjectListQuery(isUserAdmin, systemUserId, filterFields); + ): Promise { + const query = this._makeFindProjectsQuery(isUserAdmin, systemUserId, filterFields); - // Pagination if (pagination) { query.limit(pagination.limit).offset((pagination.page - 1) * pagination.limit); @@ -121,7 +135,7 @@ export class ProjectRepository extends BaseRepository { } } - const response = await this.connection.knex(query, ProjectListData); + const response = await this.connection.knex(query, FindProjectsResponse); return response.rows; } @@ -129,18 +143,18 @@ export class ProjectRepository extends BaseRepository { /** * Returns the total count of projects that are visible to the given user. * - * @param {IProjectAdvancedFilters} filterFields * @param {boolean} isUserAdmin - * @param {(number | null)} systemUserId + * @param {(number | null)} systemUserId The system user id of the user making the request + * @param {IProjectAdvancedFilters} filterFields * @return {*} {Promise} * @memberof ProjectRepository */ - async getProjectCount( - filterFields: IProjectAdvancedFilters, + async findProjectsCount( isUserAdmin: boolean, - systemUserId: number | null + systemUserId: number | null, + filterFields: IProjectAdvancedFilters ): Promise { - const projectsListQuery = this._makeProjectListQuery(isUserAdmin, systemUserId, filterFields); + const projectsListQuery = this._makeFindProjectsQuery(isUserAdmin, systemUserId, filterFields); const knex = getKnex(); @@ -151,7 +165,7 @@ export class ProjectRepository extends BaseRepository { if (!response.rowCount) { throw new ApiExecuteSQLError('Failed to get project count', [ - 'ProjectRepository->getProjectCount', + 'ProjectRepository->findProjectsCount', 'rows was null or undefined, expected rows != null' ]); } diff --git a/api/src/repositories/survey-critter-repository.ts b/api/src/repositories/survey-critter-repository.ts index fb1fb7991e..9f8b630583 100644 --- a/api/src/repositories/survey-critter-repository.ts +++ b/api/src/repositories/survey-critter-repository.ts @@ -1,6 +1,10 @@ import { z } from 'zod'; import { getKnex } from '../database/db'; +import { ApiExecuteSQLError } from '../errors/api-error'; +import { IAnimalAdvancedFilters } from '../models/animal-view'; +import { ITelemetryAdvancedFilters } from '../models/telemetry-view'; import { getLogger } from '../utils/logger'; +import { ApiPaginationOptions } from '../zod-schema/pagination'; import { BaseRepository } from './base-repository'; const defaultLog = getLogger('repositories/survey-repository'); @@ -38,6 +42,107 @@ export class SurveyCritterRepository extends BaseRepository { return response.rows; } + /** + * Constructs a non-paginated query to retrieve critters that are available to the user based on the user's + * permissions and filter criteria. + * + * @param {boolean} isUserAdmin + * @param {(number | null)} systemUserId + * @param {IAnimalAdvancedFilters} [filterFields] + * @return {*} + * @memberof SurveyCritterRepository + */ + _makeFindCrittersQuery(isUserAdmin: boolean, systemUserId: number | null, filterFields?: IAnimalAdvancedFilters) { + const query = getKnex().select(['critter_id', 'survey_id', 'critterbase_critter_id']).from('critter'); + + if (!isUserAdmin) { + query + .leftJoin('survey', 'survey.survey_id', 'critter.survey_id') + .leftJoin('project', 'project.project_id', 'survey.project_id') + .leftJoin('project_participation', 'project_participation.project_id', 'project.project_id') + .where('project_participation.system_user_id', systemUserId); + } + + if (filterFields?.system_user_id) { + query.whereIn('p.project_id', (subQueryBuilder) => { + subQueryBuilder + .select('project_id') + .from('project_participation') + .where('system_user_id', filterFields.system_user_id); + }); + } + + return query; + } + + /** + * Retrieves all critters that are available to the user based on the user's permissions and filter criteria. + * + * Note: SIMS does not store critter information, beyond an ID. Critter details must be fetched from the external + * Critterbase API. + * + * @param {boolean} isUserAdmin + * @param {(number | null)} systemUserId The system user id of the user making the request + * @param {ITelemetryAdvancedFilters} [filterFields] + * @param {ApiPaginationOptions} [pagination] + * @return {*} {Promise} + * @memberof SurveyCritterRepository + */ + async findCritters( + isUserAdmin: boolean, + systemUserId: number | null, + filterFields?: ITelemetryAdvancedFilters, + pagination?: ApiPaginationOptions + ): Promise { + const query = this._makeFindCrittersQuery(isUserAdmin, systemUserId, filterFields); + + if (pagination) { + query.limit(pagination.limit).offset((pagination.page - 1) * pagination.limit); + + if (pagination.sort && pagination.order) { + query.orderBy(pagination.sort, pagination.order); + } + } + + const response = await this.connection.knex(query); + + return response.rows; + } + + /** + * Retrieves the total count of all critters that are available to the user based on the user's permissions and + * filter criteria. + * + * @param {boolean} isUserAdmin + * @param {(number | null)} systemUserId The system user id of the user making the request + * @param {ITelemetryAdvancedFilters} [filterFields] + * @return {*} {Promise} + * @memberof SurveyCritterRepository + */ + async findCrittersCount( + isUserAdmin: boolean, + systemUserId: number | null, + filterFields?: ITelemetryAdvancedFilters + ): Promise { + const findCrittersQuery = this._makeFindCrittersQuery(isUserAdmin, systemUserId, filterFields); + + const knex = getKnex(); + + // See https://knexjs.org/guide/query-builder.html#usage-with-typescript-3 for details on count() usage + const query = knex.from(findCrittersQuery.as('fcq')).select(knex.raw('count(*)::integer as count')); + + const response = await this.connection.knex(query, z.object({ count: z.number() })); + + if (!response.rowCount) { + throw new ApiExecuteSQLError('Failed to get critter count', [ + 'SurveyCritterRepository->findCrittersCount', + 'rows was null or undefined, expected rows != null' + ]); + } + + return response.rows[0].count; + } + /** * Add critter to survey * diff --git a/api/src/repositories/survey-repository.ts b/api/src/repositories/survey-repository.ts index 57b223394e..42e30e7dd2 100644 --- a/api/src/repositories/survey-repository.ts +++ b/api/src/repositories/survey-repository.ts @@ -1,3 +1,4 @@ +import { Knex } from 'knex'; import SQL from 'sql-template-strings'; import { z } from 'zod'; import { getKnex } from '../database/db'; @@ -5,12 +6,13 @@ import { ApiExecuteSQLError } from '../errors/api-error'; import { PostProprietorData, PostSurveyObject } from '../models/survey-create'; import { PutSurveyObject } from '../models/survey-update'; import { + FindSurveysResponse, GetAttachmentsData, GetReportAttachmentsData, GetSurveyProprietorData, - GetSurveyPurposeAndMethodologyData + GetSurveyPurposeAndMethodologyData, + ISurveyAdvancedFilters } from '../models/survey-view'; -import { getLogger } from '../utils/logger'; import { ApiPaginationOptions } from '../zod-schema/pagination'; import { BaseRepository } from './base-repository'; @@ -116,8 +118,6 @@ export const SurveyBasicFields = z.object({ export type SurveyBasicFields = z.infer; -const defaultLog = getLogger('repositories/survey-repository'); - export class SurveyRepository extends BaseRepository { /** * Deletes a survey and any associations for a given survey @@ -131,6 +131,134 @@ export class SurveyRepository extends BaseRepository { await this.connection.sql(sqlStatement); } + /** + * Constructs a non-paginated query used to get a list of surveys based on the user's permissions and filter criteria. + * + * @param {boolean} isUserAdmin + * @param {(number | null)} systemUserId The system user id of the user making the request + * @param {ISurveyAdvancedFilters} filterFields + * @return {*} Promise + * @memberof SurveyRepository + */ + _makeFindSurveysQuery( + isUserAdmin: boolean, + systemUserId: number | null, + filterFields: ISurveyAdvancedFilters + ): Knex.QueryBuilder { + const knex = getKnex(); + + const query = knex + .select([ + 's.survey_id', + 's.project_id', + 's.name', + 's.progress_id', + 's.start_date', + 's.end_date', + knex.raw(`COALESCE(array_remove(array_agg(DISTINCT rl.region_name), null), '{}') as regions`), + knex.raw('array_remove(array_agg(distinct sp.itis_tsn), null) as focal_species'), + knex.raw('array_remove(array_agg(distinct st.type_id), null) as types') + ]) + .from('survey as s') + .leftJoin('project as p', 'p.project_id', 's.project_id') + .leftJoin('study_species as sp', 'sp.survey_id', 's.survey_id') + .leftJoin('survey_type as st', 'st.survey_id', 's.survey_id') + .leftJoin('survey_region as sr', 'sr.survey_id', 's.survey_id') + .leftJoin('region_lookup as rl', 'rl.region_id', 'sr.region_id') + .leftJoin('project_participation as ppa', 'ppa.project_id', 's.project_id') + .groupBy('s.survey_id', 's.project_id', 's.name', 's.progress_id', 's.start_date', 's.end_date'); + + // Ensure that users can only see surveys that they are participating in, unless they are an administrator. + if (!isUserAdmin) { + query.whereIn('p.project_id', (subQueryBuilder) => { + subQueryBuilder.select('project_id').from('project_participation').where('system_user_id', systemUserId); + }); + } + + if (filterFields.system_user_id) { + query.whereIn('p.project_id', (subQueryBuilder) => { + subQueryBuilder + .select('project_id') + .from('project_participation') + .where('system_user_id', filterFields.system_user_id); + }); + } + + // Start Date filter + if (filterFields.start_date) { + query.andWhere('s.start_date', '>=', filterFields.start_date); + } + + // End Date filter + if (filterFields.end_date) { + query.andWhere('s.end_date', '<=', filterFields.end_date); + } + + // Project Name filter (like match) + if (filterFields.survey_name) { + query.andWhere('s.name', 'ilike', `%${filterFields.survey_name}%`); + } + + // Focal Species filter + if (filterFields.itis_tsns?.length) { + // multiple + query.whereIn('sp.itis_tsn', filterFields.itis_tsns); + } else if (filterFields.itis_tsn) { + // single + query.where('sp.itis_tsn', filterFields.itis_tsn); + } + + // Keyword Search filter + if (filterFields.keyword) { + const keywordMatch = `%${filterFields.keyword}%`; + query.where((subQueryBuilder) => { + subQueryBuilder + .where('s.name', 'ilike', keywordMatch) + .orWhere('s.additional_details', 'ilike', keywordMatch) + .orWhere('s.comments', 'ilike', keywordMatch); + + // If the keyword is a number, also match on survey Id + if (!isNaN(Number(filterFields.keyword))) { + subQueryBuilder.orWhere('s.survey_id', Number(filterFields.keyword)); + } + }); + } + + return query; + } + + /** + * Retrieves the paginated list of all surveys that are available to the user. + * + * @param {boolean} isUserAdmin + * @param {(number | null)} systemUserId The system user id of the user making the request + * @param {ISurveyAdvancedFilters} filterFields + * @param {ApiPaginationOptions} [pagination] + * @return {*} {Promise} + * @memberof SurveyRepository + */ + async findSurveys( + isUserAdmin: boolean, + systemUserId: number | null, + filterFields: ISurveyAdvancedFilters, + pagination?: ApiPaginationOptions + ): Promise { + const query = this._makeFindSurveysQuery(isUserAdmin, systemUserId, filterFields); + + // Pagination + if (pagination) { + query.limit(pagination.limit).offset((pagination.page - 1) * pagination.limit); + + if (pagination.sort && pagination.order) { + query.orderBy(pagination.sort, pagination.order); + } + } + + const response = await this.connection.knex(query, FindSurveysResponse); + + return response.rows; + } + /** * Get survey(s) for a given project id * @@ -474,6 +602,38 @@ export class SurveyRepository extends BaseRepository { return response.rows; } + /** + * Returns the total number of surveys that the user has access to + * + * @param {boolean} isUserAdmin + * @param {(number | null)} systemUserId + * @param {ISurveyAdvancedFilters} filterFields + * @return {*} {Promise} + * @memberof SurveyService + */ + async findSurveysCount( + isUserAdmin: boolean, + systemUserId: number | null, + filterFields: ISurveyAdvancedFilters + ): Promise { + const surveyListQuery = this._makeFindSurveysQuery(isUserAdmin, systemUserId, filterFields); + + const knex = getKnex(); + + const queryBuilder = knex.from(surveyListQuery.as('slq')).select(knex.raw('count(*)::integer as count')); + + const response = await this.connection.knex(queryBuilder, z.object({ count: z.number() })); + + if (!response.rowCount) { + throw new ApiExecuteSQLError('Failed to get survey count', [ + 'SurveyRepository->findSurveysCount', + 'rows was null or undefined, expected rows != null' + ]); + } + + return response.rows[0].count; + } + /** * Returns the total number of surveys belonging to the given project. * @@ -979,8 +1139,6 @@ export class SurveyRepository extends BaseRepository { * @memberof SurveyRepository */ async getIndigenousPartnershipsBySurveyId(surveyId: number): Promise { - defaultLog.debug({ label: 'getIndigenousPartnershipsBySurveyId', surveyId }); - const sqlStatement = SQL` SELECT * @@ -1011,8 +1169,6 @@ export class SurveyRepository extends BaseRepository { * @memberof SurveyRepository */ async getStakeholderPartnershipsBySurveyId(surveyId: number): Promise { - defaultLog.debug({ label: 'getStakeholderPartnershipsBySurveyId', surveyId }); - const sqlStatement = SQL` SELECT * @@ -1048,7 +1204,6 @@ export class SurveyRepository extends BaseRepository { firstNationsIds: number[], surveyId: number ): Promise { - defaultLog.debug({ label: 'insertIndigenousPartnerships', firstNationsIds, surveyId }); const queryBuilder = getKnex() .table('survey_first_nation_partnership') .insert( @@ -1083,7 +1238,6 @@ export class SurveyRepository extends BaseRepository { stakeholderPartners: string[], surveyId: number ): Promise { - defaultLog.debug({ label: 'insertStakeholderPartnerships', stakeholderPartners, surveyId }); const queryBuilder = getKnex() .table('survey_stakeholder_partnership') .insert( @@ -1114,7 +1268,6 @@ export class SurveyRepository extends BaseRepository { * @memberof SurveyRepository */ async deleteIndigenousPartnershipsData(surveyId: number): Promise { - defaultLog.debug({ label: 'deleteIndigenousPartnershipsData', surveyId }); const queryBuilder = getKnex().table('survey_first_nation_partnership').delete().where('survey_id', surveyId); const response = await this.connection.knex(queryBuilder); @@ -1130,7 +1283,6 @@ export class SurveyRepository extends BaseRepository { * @memberof SurveyRepository */ async deleteStakeholderPartnershipsData(surveyId: number): Promise { - defaultLog.debug({ label: 'deleteStakeholderPartnershipsData', surveyId }); const queryBuilder = getKnex().table('survey_stakeholder_partnership').delete().where('survey_id', surveyId); const response = await this.connection.knex(queryBuilder); diff --git a/api/src/repositories/telemetry-repository.ts b/api/src/repositories/telemetry-repository.ts index 012f3f167e..501ebae74e 100644 --- a/api/src/repositories/telemetry-repository.ts +++ b/api/src/repositories/telemetry-repository.ts @@ -7,6 +7,14 @@ import { BaseRepository } from './base-repository'; const defaultLog = getLogger('repositories/telemetry-repository'); +export const Deployment = z.object({ + deployment_id: z.number(), + critter_id: z.number(), + bctw_deployment_id: z.string().uuid() +}); + +export type Deployment = z.infer; + /** * Interface reflecting survey telemetry retrieved from the database */ @@ -83,4 +91,26 @@ export class TelemetryRepository extends BaseRepository { return response.rows[0]; } + + /** + * Get deployments for the given critter ids. + * + * Note: SIMS does not store deployment information, beyond an ID. Deployment details must be fetched from the + * external BCTW API. + * + * @param {number[]} critterIds + * @return {*} {Promise} + * @memberof TelemetryRepository + */ + async getDeploymentsByCritterIds(critterIds: number[]): Promise { + const queryBuilder = getKnex() + .queryBuilder() + .select(['deployment_id', 'critter_id', 'bctw_deployment_id']) + .from('deployment') + .whereIn('critter_id', critterIds); + + const response = await this.connection.knex(queryBuilder, Deployment); + + return response.rows; + } } diff --git a/api/src/services/bcgw-layer-service.test.ts b/api/src/services/bcgw-layer-service.test.ts index 36f13c32d3..461aa16efe 100644 --- a/api/src/services/bcgw-layer-service.test.ts +++ b/api/src/services/bcgw-layer-service.test.ts @@ -11,7 +11,7 @@ import { BcgwWildlifeManagementUnitsLayer, RegionDetails } from './bcgw-layer-service'; -import { WebFeatureService } from './geo-service'; +import { Srid3005, WebFeatureService } from './geo-service'; import { PostgisService } from './postgis-service'; chai.use(sinonChai); @@ -643,4 +643,55 @@ describe('BcgwLayerService', () => { ]); }); }); + + describe('getIntersectingNrmRegionsFromFeatures', () => { + it('should return unique list of NRM region names', async () => { + const mockDbConnection = getMockDBConnection(); + const service = new BcgwLayerService(); + + const featureA: Feature = { + type: 'Feature', + geometry: { + type: 'Polygon', + coordinates: [ + [ + [0, 0], + [1, 0], + [1, 1], + [0, 1], + [0, 0] + ] + ] + }, + properties: {} + }; + + const featureB: Feature = { + type: 'Feature', + geometry: { + type: 'Polygon', + coordinates: [[[0, 0]]] + }, + properties: {} + }; + + const mockGetGeoJsonString = sinon.stub(PostgisService.prototype, 'getGeoJsonGeometryAsWkt'); + const mockGetNrmRegionNames = sinon.stub(service, 'getNrmRegionNames'); + + mockGetGeoJsonString.onFirstCall().resolves('A'); + mockGetGeoJsonString.onSecondCall().resolves('B'); + mockGetNrmRegionNames.onFirstCall().resolves(['Cariboo']); + mockGetNrmRegionNames.onSecondCall().resolves(['South', 'Cariboo']); + + const regions = await service.getIntersectingNrmRegionNamesFromFeatures([featureA, featureB], mockDbConnection); + + expect(mockGetGeoJsonString.firstCall.calledWithExactly(featureA.geometry, Srid3005)).to.be.true; + expect(mockGetGeoJsonString.secondCall.calledWithExactly(featureB.geometry, Srid3005)).to.be.true; + + expect(mockGetNrmRegionNames.firstCall.calledWithExactly('A')).to.be.true; + expect(mockGetNrmRegionNames.secondCall.calledWithExactly('B')).to.be.true; + + expect(regions).to.eqls(['Cariboo', 'South']); + }); + }); }); diff --git a/api/src/services/bcgw-layer-service.ts b/api/src/services/bcgw-layer-service.ts index 8d732253b6..578f9b7c41 100644 --- a/api/src/services/bcgw-layer-service.ts +++ b/api/src/services/bcgw-layer-service.ts @@ -1,5 +1,6 @@ import { XMLParser } from 'fast-xml-parser'; import { Feature } from 'geojson'; +import { flatten } from 'lodash'; import { z } from 'zod'; import { IDBConnection } from '../database/db'; import { getLogger } from '../utils/logger'; @@ -498,4 +499,30 @@ export class BcgwLayerService { return regionNames.map((name) => ({ regionName: name, sourceLayer: BcgwWildlifeManagementUnitsLayer })); } + + /** + * Get intersecting NRM region names from a list of features + * + * @async + * @param {Feature[]} features - Array of geojson features + * @param {IDBConnection} connection - Database connection + * @returns {Promise} Array of unique region names + */ + async getIntersectingNrmRegionNamesFromFeatures(features: Feature[], connection: IDBConnection): Promise { + const postgisService = new PostgisService(connection); + + // Generate list of PostGIS geometry strings from features + const wktStringArr = await Promise.all( + features.map((feature) => postgisService.getGeoJsonGeometryAsWkt(feature.geometry, Srid3005)) + ); + + // Get NRM region names from Postgis geometry strings + const nrmRegionNames = await Promise.all(wktStringArr.map((wkt) => this.getNrmRegionNames(wkt))); + + // Flatten nested arrays and filter out undefined values + const flattenedRegionNames = flatten(nrmRegionNames).filter((item) => item); + + // Return de-duped array + return Array.from(new Set(flattenedRegionNames)); + } } diff --git a/api/src/services/bctw-service.ts b/api/src/services/bctw-service.ts index 816fbad25b..2860f4add6 100644 --- a/api/src/services/bctw-service.ts +++ b/api/src/services/bctw-service.ts @@ -86,12 +86,51 @@ export const IKeyXDetails = z.object({ export type IKeyXDetails = z.infer; +export const IAllTelemetry = z + .object({ + deployment_id: z.string().uuid(), + latitude: z.number(), + longitude: z.number(), + acquisition_date: z.string(), + telemetry_type: z.string() + }) + .and( + // One of telemetry_id or telemetry_manual_id is expected to be non-null + z.union([ + z.object({ + telemetry_id: z.string().uuid(), + telemetry_manual_id: z.null() + }), + z.object({ + telemetry_id: z.null(), + telemetry_manual_id: z.string().uuid() + }) + ]) + ); + +export type IAllTelemetry = z.infer; + +export const IVendorTelemetry = z.object({ + telemetry_id: z.string(), + deployment_id: z.string().uuid(), + collar_transaction_id: z.string().uuid(), + critter_id: z.string().uuid(), + deviceid: z.number(), + latitude: z.number(), + longitude: z.number(), + elevation: z.number(), + vendor: z.string(), + acquisition_date: z.string() +}); + +export type IVendorTelemetry = z.infer; + export const IManualTelemetry = z.object({ telemetry_manual_id: z.string().uuid(), deployment_id: z.string().uuid(), latitude: z.number(), longitude: z.number(), - date: z.string() + acquisition_date: z.string() }); export type IManualTelemetry = z.infer; @@ -238,6 +277,7 @@ export class BctwService { const params = new URLSearchParams(queryParams); url += `?${params.toString()}`; } + const response = await this.axiosInstance.get(url); return response.data; } @@ -475,7 +515,7 @@ export class BctwService { * @param {string[]} deployment_ids - bctw deployments * @returns {*} IManualTelemetry[] */ - async getVendorTelemetryByDeploymentIds(deployment_ids: string[]): Promise { + async getVendorTelemetryByDeploymentIds(deployment_ids: string[]): Promise { const res = await this.axiosInstance.post(`${VENDOR_TELEMETRY}/deployments`, deployment_ids); return res.data; } @@ -487,7 +527,7 @@ export class BctwService { * @param {string[]} deployment_ids - bctw deployments * @returns {*} IManualTelemetry[] */ - async getAllTelemetryByDeploymentIds(deployment_ids: string[]): Promise { + async getAllTelemetryByDeploymentIds(deployment_ids: string[]): Promise { const res = await this.axiosInstance.post(`${MANUAL_AND_VENDOR_TELEMETRY}/deployments`, deployment_ids); return res.data; } diff --git a/api/src/services/critterbase-service.ts b/api/src/services/critterbase-service.ts index b5a2aff137..240aa1b74e 100644 --- a/api/src/services/critterbase-service.ts +++ b/api/src/services/critterbase-service.ts @@ -17,7 +17,7 @@ export interface QueryParam { export interface ICritter { critter_id?: string; - wlh_id: string; + wlh_id: string | null; animal_id: string; sex: string; itis_tsn: number; @@ -379,7 +379,7 @@ export class CritterbaseService { return response.data; } - async getMultipleCrittersByIds(critter_ids: string[]) { + async getMultipleCrittersByIds(critter_ids: string[]): Promise { const response = await this.axiosInstance.post(CRITTER_ENDPOINT, { critter_ids }); return response.data; } diff --git a/api/src/services/observation-service.test.ts b/api/src/services/observation-service.test.ts index be821fbf48..8c4e9726b5 100644 --- a/api/src/services/observation-service.test.ts +++ b/api/src/services/observation-service.test.ts @@ -7,7 +7,7 @@ import { ObservationRecordWithSamplingAndSubcountData, ObservationRepository, UpdateObservation -} from '../repositories/observation-repository'; +} from '../repositories/observation-repository/observation-repository'; import * as file_utils from '../utils/file-utils'; import { getMockDBConnection } from '../__mocks__/db'; import { ObservationService } from './observation-service'; diff --git a/api/src/services/observation-service.ts b/api/src/services/observation-service.ts index eb6953b9cf..798ebb7082 100644 --- a/api/src/services/observation-service.ts +++ b/api/src/services/observation-service.ts @@ -1,5 +1,6 @@ import { IDBConnection } from '../database/db'; import { ApiGeneralError } from '../errors/api-error'; +import { IObservationAdvancedFilters } from '../models/observation-view'; import { InsertObservation, ObservationGeometryRecord, @@ -8,7 +9,7 @@ import { ObservationRepository, ObservationSubmissionRecord, UpdateObservation -} from '../repositories/observation-repository'; +} from '../repositories/observation-repository/observation-repository'; import { InsertObservationSubCountQualitativeEnvironmentRecord, InsertObservationSubCountQuantitativeEnvironmentRecord, @@ -119,6 +120,26 @@ export class ObservationService extends DBService { this.observationRepository = new ObservationRepository(connection); } + /** + * Retrieves the paginated list of all observations that are available to the user, based on their permissions and + * provided filter criteria. + * + * @param {boolean} isUserAdmin + * @param {(number | null)} systemUserId The system user id of the user making the request + * @param {IObservationAdvancedFilters} filterFields + * @param {ApiPaginationOptions} [pagination] + * @return {*} {Promise} + * @memberof ObservationService + */ + async findObservations( + isUserAdmin: boolean, + systemUserId: number | null, + filterFields: IObservationAdvancedFilters, + pagination?: ApiPaginationOptions + ): Promise { + return this.observationRepository.findObservations(isUserAdmin, systemUserId, filterFields, pagination); + } + /** * Performs an upsert for all observation records belonging to the given survey, while removing * any records associated for the survey that aren't included in the given records, then @@ -326,6 +347,23 @@ export class ObservationService extends DBService { return this.observationRepository.getSurveyObservationCount(surveyId); } + /** + * Retrieves the count of survey observations for the given survey + * + * @param {boolean} isUserAdmin + * @param {(number | null)} systemUserId + * @param {IObservationAdvancedFilters} filterFields + * @return {*} {Promise} + * @memberof ObservationRepository + */ + async findObservationsCount( + isUserAdmin: boolean, + systemUserId: number | null, + filterFields: IObservationAdvancedFilters + ): Promise { + return this.observationRepository.findObservationsCount(isUserAdmin, systemUserId, filterFields); + } + /** * Inserts a survey observation submission record into the database and returns the key * @@ -500,7 +538,7 @@ export class ObservationService extends DBService { const observationSubCountEnvironmentService = new ObservationSubCountEnvironmentService(this.connection); - // Fetch all measurement type definitions from Critterbase for all unique environment column names in the CSV file + // Fetch all environment type definitions from SIMS for all unique environment column names in the CSV file const environmentTypeDefinitions = await getEnvironmentTypeDefinitionsFromColumnNames( environmentColumnNames, observationSubCountEnvironmentService diff --git a/api/src/services/platform-service.test.ts b/api/src/services/platform-service.test.ts index 692bf69d76..12171fe315 100644 --- a/api/src/services/platform-service.test.ts +++ b/api/src/services/platform-service.test.ts @@ -3,7 +3,7 @@ import chai, { expect } from 'chai'; import { describe } from 'mocha'; import sinon from 'sinon'; import sinonChai from 'sinon-chai'; -import { ObservationRecord } from '../repositories/observation-repository'; +import { ObservationRecord } from '../repositories/observation-repository/observation-repository'; import { getMockDBConnection } from '../__mocks__/db'; import { AttachmentService } from './attachment-service'; import { HistoryPublishService } from './history-publish-service'; diff --git a/api/src/services/project-service.test.ts b/api/src/services/project-service.test.ts index 577928b8dc..7e1bd0722e 100644 --- a/api/src/services/project-service.test.ts +++ b/api/src/services/project-service.test.ts @@ -2,7 +2,12 @@ import chai, { expect } from 'chai'; import { describe } from 'mocha'; import sinon from 'sinon'; import sinonChai from 'sinon-chai'; -import { GetIUCNClassificationData, GetObjectivesData, ProjectData, ProjectListData } from '../models/project-view'; +import { + FindProjectsResponse, + GetIUCNClassificationData, + GetObjectivesData, + ProjectData +} from '../models/project-view'; import { ProjectRepository } from '../repositories/project-repository'; import { getMockDBConnection } from '../__mocks__/db'; import { ProjectService } from './project-service'; @@ -14,27 +19,35 @@ describe('ProjectService', () => { sinon.restore(); }); - describe('getProjectList', () => { + describe('findProjects', () => { it('returns rows on success', async () => { const dbConnection = getMockDBConnection(); const service = new ProjectService(dbConnection); - const data: ProjectListData[] = [ + const data: FindProjectsResponse[] = [ { project_id: 123, name: 'Project 1', - regions: [] + start_date: '2021-01-01', + end_date: '2021-12-31', + regions: [], + focal_species: [], + types: [1, 2, 3] }, { project_id: 456, name: 'Project 2', - regions: [] + start_date: '2021-01-01', + end_date: '2021-12-31', + regions: [], + focal_species: [], + types: [1, 2, 3] } ]; - const repoStub = sinon.stub(ProjectRepository.prototype, 'getProjectList').resolves(data); + const repoStub = sinon.stub(ProjectRepository.prototype, 'findProjects').resolves(data); - const response = await service.getProjectList(true, 1, {}); + const response = await service.findProjects(true, 1, {}); expect(repoStub).to.be.calledOnce; expect(response[0].project_id).to.equal(123); @@ -45,14 +58,14 @@ describe('ProjectService', () => { }); }); - describe('getProjectCount', () => { + describe('findProjectsCount', () => { it('returns the total project count', async () => { const dbConnection = getMockDBConnection(); const service = new ProjectService(dbConnection); - const repoStub = sinon.stub(ProjectRepository.prototype, 'getProjectCount').resolves(69); + const repoStub = sinon.stub(ProjectRepository.prototype, 'findProjectsCount').resolves(69); - const response = await service.getProjectCount({}, false, 1001); + const response = await service.findProjectsCount(false, 1001, {}); expect(repoStub).to.be.calledOnce; expect(response).to.eql(69); diff --git a/api/src/services/project-service.ts b/api/src/services/project-service.ts index ab0109c023..ebfb236a03 100644 --- a/api/src/services/project-service.ts +++ b/api/src/services/project-service.ts @@ -3,14 +3,14 @@ import { HTTP400 } from '../errors/http-error'; import { IPostIUCN, PostProjectObject } from '../models/project-create'; import { IPutIUCN, PutIUCNData, PutObjectivesData, PutProjectData } from '../models/project-update'; import { + FindProjectsResponse, GetAttachmentsData, GetIUCNClassificationData, GetObjectivesData, GetReportAttachmentsData, IGetProject, IProjectAdvancedFilters, - ProjectData, - ProjectListData + ProjectData } from '../models/project-view'; import { GET_ENTITIES, IUpdateProject } from '../paths/project/{projectId}/update'; import { ProjectUser } from '../repositories/project-participation-repository'; @@ -44,41 +44,43 @@ export class ProjectService extends DBService { } /** - * Retrieves the paginated list of all projects that are available to the user. + * Retrieves the paginated list of all projects that are available to the user, based on their permissions and + * provided filter criteria. * * @param {boolean} isUserAdmin - * @param {(number | null)} systemUserId + * @param {(number | null)} systemUserId The system user id of the user making the request * @param {IProjectAdvancedFilters} filterFields * @param {ApiPaginationOptions} [pagination] - * @return {*} {(Promise<(ProjectListData)[]>)} + * @return {*} {(Promise<(FindProjectsResponse)[]>)} * @memberof ProjectService */ - async getProjectList( + async findProjects( isUserAdmin: boolean, systemUserId: number | null, filterFields: IProjectAdvancedFilters, pagination?: ApiPaginationOptions - ): Promise { - const response = await this.projectRepository.getProjectList(isUserAdmin, systemUserId, filterFields, pagination); + ): Promise { + const response = await this.projectRepository.findProjects(isUserAdmin, systemUserId, filterFields, pagination); return response; } /** - * Returns the total count of projects that are visible to the given user. + * Retrieves the count of all projects that are available to the user, based on their permissions and provided + * filter criteria. * - * @param {IProjectAdvancedFilters} filterFields * @param {boolean} isUserAdmin - * @param {(number | null)} systemUserId + * @param {(number | null)} systemUserId The system user id of the user making the request + * @param {IProjectAdvancedFilters} filterFields * @return {*} {Promise} * @memberof ProjectService */ - async getProjectCount( - filterFields: IProjectAdvancedFilters, + async findProjectsCount( isUserAdmin: boolean, - systemUserId: number | null + systemUserId: number | null, + filterFields: IProjectAdvancedFilters ): Promise { - return this.projectRepository.getProjectCount(filterFields, isUserAdmin, systemUserId); + return this.projectRepository.findProjectsCount(isUserAdmin, systemUserId, filterFields); } /** diff --git a/api/src/services/survey-critter-service.ts b/api/src/services/survey-critter-service.ts index ca4c9dc070..6674774c4e 100644 --- a/api/src/services/survey-critter-service.ts +++ b/api/src/services/survey-critter-service.ts @@ -1,7 +1,17 @@ import { IDBConnection } from '../database/db'; +import { IAnimalAdvancedFilters } from '../models/animal-view'; +import { ITelemetryAdvancedFilters } from '../models/telemetry-view'; import { SurveyCritterRecord, SurveyCritterRepository } from '../repositories/survey-critter-repository'; +import { ApiPaginationOptions } from '../zod-schema/pagination'; +import { CritterbaseService, ICritter } from './critterbase-service'; import { DBService } from './db-service'; +export type FindCrittersResponse = Pick< + ICritter, + 'wlh_id' | 'animal_id' | 'sex' | 'itis_tsn' | 'itis_scientific_name' | 'critter_comment' +> & + Pick; + /** * Service layer for survey critters. * @@ -18,7 +28,8 @@ export class SurveyCritterService extends DBService { this.critterRepository = new SurveyCritterRepository(connection); } /** - * Get all critter associations for the given survey. This only gets you critter ids, which can be used to fetch details from the external system. + * Get all critter associations for the given survey. This only gets you critter ids, which can be used to fetch + * details from the external system. * * @param {number} surveyId * @return {*} {Promise} @@ -28,6 +39,88 @@ export class SurveyCritterService extends DBService { return this.critterRepository.getCrittersInSurvey(surveyId); } + /** + * Retrieves all critters that are available to the user, based on their permissions. + * + * @param {boolean} isUserAdmin + * @param {(number | null)} systemUserId The system user id of the user making the request + * @param {IAnimalAdvancedFilters} [filterFields] + * @param {ApiPaginationOptions} [pagination] + * @return {*} {Promise} + * @memberof SurveyCritterService + */ + async findCritters( + isUserAdmin: boolean, + systemUserId: number | null, + filterFields?: IAnimalAdvancedFilters, + pagination?: ApiPaginationOptions + ): Promise { + // --- Step 1 ----------------------------- + + // The SIMS critter records the user has access to + const simsCritters = await this.critterRepository.findCritters(isUserAdmin, systemUserId, filterFields, pagination); + + if (!simsCritters.length) { + // Exit early if there are no SIMS critters + return []; + } + + // --- Step 2 ----------------------------- + + const critterbaseCritterIds = simsCritters.map((critter) => critter.critterbase_critter_id); + + const critterbaseService = new CritterbaseService({ + keycloak_guid: this.connection.systemUserGUID(), + username: this.connection.systemUserIdentifier() + }); + // The detailed critter records from Critterbase + const critterbaseCritters = await critterbaseService.getMultipleCrittersByIds(critterbaseCritterIds); + + // --- Step 3 ----------------------------- + + // Parse/combine the telemetry, deployment, and critter records into the final response + const response: FindCrittersResponse[] = []; + for (const critterbaseCritter of critterbaseCritters) { + const simsCritter = simsCritters.find( + (critter) => critter.critterbase_critter_id === critterbaseCritter.critter_id + ); + + if (!simsCritter) { + continue; + } + + response.push({ + wlh_id: critterbaseCritter.wlh_id, + animal_id: critterbaseCritter.animal_id, + sex: critterbaseCritter.sex, + itis_tsn: critterbaseCritter.itis_tsn, + itis_scientific_name: critterbaseCritter.itis_scientific_name, + critter_comment: critterbaseCritter.critter_comment, + ...simsCritter + }); + } + + return response; + } + + /** + * Retrieves the count of all critters that are available to the user, based on their permissions and provided + * filter criteria. + * + * @param {boolean} isUserAdmin + * @param {(number | null)} systemUserId The system user id of the user making the request + * @param {ITelemetryAdvancedFilters} [filterFields] + * @return {*} {Promise} + * @memberof SurveyCritterService + */ + async findCrittersCount( + isUserAdmin: boolean, + systemUserId: number | null, + filterFields?: ITelemetryAdvancedFilters + ): Promise { + return this.critterRepository.findCrittersCount(isUserAdmin, systemUserId, filterFields); + } + /** * Add a critter as part of this survey. Does not create anything in the external system. * diff --git a/api/src/services/survey-service.ts b/api/src/services/survey-service.ts index 36902bf16a..ccba496253 100644 --- a/api/src/services/survey-service.ts +++ b/api/src/services/survey-service.ts @@ -3,6 +3,7 @@ import { IDBConnection } from '../database/db'; import { PostProprietorData, PostSurveyObject } from '../models/survey-create'; import { PostSurveyLocationData, PutPartnershipsData, PutSurveyObject } from '../models/survey-update'; import { + FindSurveysResponse, GetAncillarySpeciesData, GetAttachmentsData, GetFocalSpeciesData, @@ -12,6 +13,7 @@ import { GetSurveyFundingSourceData, GetSurveyProprietorData, GetSurveyPurposeAndMethodologyData, + ISurveyAdvancedFilters, ISurveyPartnerships, SurveyObject, SurveySupplementaryData @@ -328,6 +330,44 @@ export class SurveyService extends DBService { return this.surveyRepository.getSurveyCountByProjectId(projectId); } + /** + * Retrieves the paginated list of all surveys that are available to the user, based on their permissions and provided + * filter criteria. + * + * @param {boolean} isUserAdmin + * @param {(number | null)} systemUserId The system user id of the user making the request + * @param {ISurveyAdvancedFilters} filterFields + * @param {ApiPaginationOptions} [pagination] + * @returns {*} {Promise<{id: number}[]>} + * @memberof SurveyRepository + */ + async findSurveys( + isUserAdmin: boolean, + systemUserId: number | null, + filterFields: ISurveyAdvancedFilters, + pagination?: ApiPaginationOptions + ): Promise { + return this.surveyRepository.findSurveys(isUserAdmin, systemUserId, filterFields, pagination); + } + + /** + * Retrieves the count of all surveys that are available to the user, based on their permissions and provided filter + * criteria. + * + * @param {ISurveyAdvancedFilters} filterFields + * @param {boolean} isUserAdmin + * @param {(number | null)} systemUserId The system user id of the user making the request + * @return {*} {Promise} + * @memberof SurveyService + */ + async findSurveysCount( + isUserAdmin: boolean, + systemUserId: number | null, + filterFields: ISurveyAdvancedFilters + ): Promise { + return this.surveyRepository.findSurveysCount(isUserAdmin, systemUserId, filterFields); + } + /** * Creates the survey * diff --git a/api/src/services/telemetry-service.ts b/api/src/services/telemetry-service.ts index 48f17e7d74..a064741af2 100644 --- a/api/src/services/telemetry-service.ts +++ b/api/src/services/telemetry-service.ts @@ -1,7 +1,9 @@ import { default as dayjs } from 'dayjs'; import { IDBConnection } from '../database/db'; import { ApiGeneralError } from '../errors/api-error'; -import { TelemetryRepository, TelemetrySubmissionRecord } from '../repositories/telemetry-repository'; +import { ITelemetryAdvancedFilters } from '../models/telemetry-view'; +import { SurveyCritterRecord } from '../repositories/survey-critter-repository'; +import { Deployment, TelemetryRepository, TelemetrySubmissionRecord } from '../repositories/telemetry-repository'; import { generateS3FileKey, getFileFromS3 } from '../utils/file-utils'; import { parseS3File } from '../utils/media/media-utils'; import { @@ -11,11 +13,21 @@ import { IXLSXCSVValidator, validateCsvFile } from '../utils/xlsx-utils/worksheet-utils'; -import { BctwService, ICreateManualTelemetry } from './bctw-service'; -import { ICritterbaseUser } from './critterbase-service'; +import { ApiPaginationOptions } from '../zod-schema/pagination'; +import { BctwService, IAllTelemetry, ICreateManualTelemetry, IDeploymentRecord } from './bctw-service'; +import { ICritter, ICritterbaseUser } from './critterbase-service'; import { DBService } from './db-service'; import { SurveyCritterService } from './survey-critter-service'; +export type FindTelemetryResponse = { telemetry_id: string } & Pick< + IAllTelemetry, + 'acquisition_date' | 'latitude' | 'longitude' | 'telemetry_type' +> & + Pick & + Pick & + Pick & + Pick; + const telemetryCSVColumnValidator: IXLSXCSVValidator = { columnNames: ['DEVICE_ID', 'DATE', 'TIME', 'LATITUDE', 'LONGITUDE'], columnTypes: ['number', 'date', 'string', 'number', 'number'], @@ -26,10 +38,11 @@ const telemetryCSVColumnValidator: IXLSXCSVValidator = { }; export class TelemetryService extends DBService { - repository: TelemetryRepository; + telemetryRepository: TelemetryRepository; + constructor(connection: IDBConnection) { super(connection); - this.repository = new TelemetryRepository(connection); + this.telemetryRepository = new TelemetryRepository(connection); } /** @@ -47,9 +60,9 @@ export class TelemetryService extends DBService { projectId: number, surveyId: number ): Promise<{ submission_id: number; key: string }> { - const submissionId = await this.repository.getNextSubmissionId(); + const submissionId = await this.telemetryRepository.getNextSubmissionId(); const key = generateS3FileKey({ projectId, surveyId, submissionId, fileName: file.originalname }); - const result = await this.repository.insertSurveyTelemetrySubmission( + const result = await this.telemetryRepository.insertSurveyTelemetrySubmission( submissionId, key, surveyId, @@ -152,6 +165,163 @@ export class TelemetryService extends DBService { } async getTelemetrySubmissionById(submissionId: number): Promise { - return this.repository.getTelemetrySubmissionById(submissionId); + return this.telemetryRepository.getTelemetrySubmissionById(submissionId); + } + + /** + * Get deployments for the given critter ids. + * + * Note: SIMS does not store deployment information, beyond an ID. Deployment details must be fetched from the + * external BCTW API. + * + * @param {number[]} critterIds + * @return {*} {Promise} + * @memberof TelemetryService + */ + async getDeploymentsByCritterIds(critterIds: number[]): Promise { + return this.telemetryRepository.getDeploymentsByCritterIds(critterIds); + } + + /** + * Retrieves the paginated list of all telemetry records that are available to the user, based on their permissions + * and provided filter criteria. + * + * @param {boolean} isUserAdmin + * @param {(number | null)} systemUserId The system user id of the user making the request + * @param {ITelemetryAdvancedFilters} [filterFields] + * @param {ApiPaginationOptions} [pagination] + * @return {*} {Promise} + * @memberof TelemetryService + */ + async findTelemetry( + isUserAdmin: boolean, + systemUserId: number | null, + filterFields?: ITelemetryAdvancedFilters, + pagination?: ApiPaginationOptions + ): Promise { + // --- Step 1 ----------------------------- + + const surveyCritterService = new SurveyCritterService(this.connection); + // The SIMS critter records the user has access to + const simsCritters = await surveyCritterService.findCritters( + isUserAdmin, + systemUserId, + filterFields, + // Remove the sort and order from the pagination object as these are based on the telemetry sort columns and + // may not be valid for the critter columns + // TODO: Is there a better way to achieve this pagination safety? + pagination + ? { + ...pagination, + sort: undefined, + order: undefined + } + : undefined + ); + + if (!simsCritters.length) { + // Exit early if there are no SIMS critters, and therefore no telemetry + return []; + } + + // --- Step 2 ------------------------------ + + const simsCritterIds = simsCritters.map((critter) => critter.critter_id); + // The sims deployment records the user has access to + const simsDeployments = await this.telemetryRepository.getDeploymentsByCritterIds(simsCritterIds); + + if (!simsDeployments.length) { + // Exit early if there are no SIMS deployments, and therefore no telemetry + return []; + } + + // --- Step 3 ------------------------------ + + const critterbaseCritterIds = simsCritters + .filter((simsCritter) => + simsDeployments.some((surveyDeployment) => surveyDeployment.critter_id === simsCritter.critter_id) + ) + .map((critter) => critter.critterbase_critter_id); + + if (!critterbaseCritterIds.length) { + // Exit early if there are no critterbase critters, and therefore no telemetry + return []; + } + + const bctwService = new BctwService({ + keycloak_guid: this.connection.systemUserGUID(), + username: this.connection.systemUserIdentifier() + }); + // The detailed deployment records from BCTW + // Note: This may include records the user does not have acces to (A critter may have multiple deployments over its + // lifespan, but the user may only have access to a subset of them). + const allBctwDeploymentsForCritters = await bctwService.getDeploymentsByCritterId(critterbaseCritterIds); + + // Remove records the user does not have access to + const usersBctwDeployments = allBctwDeploymentsForCritters.filter((deployment) => + simsDeployments.some((item) => item.bctw_deployment_id === deployment.deployment_id) + ); + const usersBctwDeploymentIds = usersBctwDeployments.map((deployment) => deployment.deployment_id); + + if (!usersBctwDeploymentIds.length) { + // Exit early if there are no BCTW deployments the user has access to, and therefore no telemetry + return []; + } + + // --- Step 4 ------------------------------ + + // The telemetry records for the deployments the user has access to + const allTelemetryRecords = await bctwService.getAllTelemetryByDeploymentIds(usersBctwDeploymentIds); + + // --- Step 5 ------------------------------ + + // Parse/combine the telemetry, deployment, and critter records into the final response + const response: FindTelemetryResponse[] = []; + for (const telemetryRecord of allTelemetryRecords) { + const usersBctwDeployment = usersBctwDeployments.find( + (usersBctwDeployment) => usersBctwDeployment.deployment_id === telemetryRecord.deployment_id + ); + + if (!usersBctwDeployment) { + continue; + } + + const simsDeployment = simsDeployments.find( + (simsDeployment) => simsDeployment.bctw_deployment_id === telemetryRecord.deployment_id + ); + + if (!simsDeployment) { + continue; + } + + const simsCritter = simsCritters.find( + (simsCritter) => simsCritter.critterbase_critter_id === usersBctwDeployment?.critter_id + ); + + if (!simsCritter) { + continue; + } + + response.push({ + // IAllTelemetry + telemetry_id: telemetryRecord.telemetry_id ?? telemetryRecord.telemetry_manual_id, + acquisition_date: telemetryRecord.acquisition_date, + latitude: telemetryRecord.latitude, + longitude: telemetryRecord.longitude, + telemetry_type: telemetryRecord.telemetry_type, + // IDeploymentRecord + device_id: usersBctwDeployment.device_id, + // Deployment + bctw_deployment_id: telemetryRecord.deployment_id, + critter_id: simsDeployment.critter_id, + deployment_id: simsDeployment.deployment_id, + // SurveyCritterRecord + critterbase_critter_id: usersBctwDeployment.critter_id, + // ICritter + animal_id: simsCritter.animal_id + }); + } + + return response; } } diff --git a/api/src/utils/pagination.ts b/api/src/utils/pagination.ts index d8b6feb76e..5ced9d2134 100644 --- a/api/src/utils/pagination.ts +++ b/api/src/utils/pagination.ts @@ -2,10 +2,22 @@ import { Request } from 'express'; import { ApiPaginationOptions, ApiPaginationResults } from '../zod-schema/pagination'; /** - * Generates the API pagination options object from the given request + * Generates the API pagination options object from the given request. + * + * Used in conjunction with a request leveraging the `paginationRequestQueryParamSchema` params. + * + * @example + * GET.apiDoc = { + * //... + * parameters: [ + * //... + * ...paginationRequestQueryParamSchema + * ], + * //... + * } * * @param {Request} request - * @returns {ApiPaginationOptions | undefined} + * @return {*} {Partial} */ export const makePaginationOptionsFromRequest = (request: Request): Partial => { const page: number | undefined = request.query.page ? Number(request.query.page) : undefined; @@ -26,6 +38,8 @@ export const makePaginationOptionsFromRequest = (request: Request): Partial} [pagination] * @returns @@ -45,9 +59,10 @@ export const makePaginationResponse = ( }; /** - * If the given pagination object contains all of the necessary request params - * needed to facilitate pagination, returns an instance of `ApiPaginationOptions`. - * Else, returns `undefined`. + * Returns `ApiPaginationOptions` if the given pagination object contains all of the necessary request params needed to + * facilitate pagination, otherwise returns `undefined`. + * + * Used in conjunction with the output of `makePaginationOptionsFromRequest`. * * @param {Partial} pagination * @returns {boolean} diff --git a/api/tsconfig.production.json b/api/tsconfig.production.json index 7dd5260812..a89e4d7504 100644 --- a/api/tsconfig.production.json +++ b/api/tsconfig.production.json @@ -1,31 +1,7 @@ { + "extends": "./tsconfig.json", "compilerOptions": { - "module": "commonjs", - "lib": ["es2020"], - "outDir": "dist", - "target": "es2018", - "sourceMap": false, - "allowJs": false, - "moduleResolution": "node", - "forceConsistentCasingInFileNames": true, - "noImplicitThis": true, - "noImplicitAny": true, - "suppressImplicitAnyIndexErrors": true, - "allowSyntheticDefaultImports": true, - "noUnusedLocals": true, - "esModuleInterop": true, - "emitDecoratorMetadata": true, - "experimentalDecorators": true, - "skipLibCheck": true, - "resolveJsonModule": true, - "isolatedModules": true, - "noFallthroughCasesInSwitch": true, - "strict": true, - "typeRoots": ["node_modules/@types", "src/types"] + "sourceMap": false }, - "include": ["src"], - "exclude": ["node_modules", "dist", "src/**/*.test.ts"], - "ts-node": { - "files": true - } + "exclude": ["node_modules", "dist", "src/**/*.test.ts", "src/**/__mocks__/*"] } diff --git a/app/src/AppRouter.tsx b/app/src/AppRouter.tsx index 3a0ce4c006..4728f20175 100644 --- a/app/src/AppRouter.tsx +++ b/app/src/AppRouter.tsx @@ -7,6 +7,7 @@ import FundingSourcesRouter from 'features/funding-sources/FundingSourcesRouter' import ProjectsRouter from 'features/projects/ProjectsRouter'; import ResourcesPage from 'features/resources/ResourcesPage'; import SpeciesStandardsPage from 'features/standards/SpeciesStandardsPage'; +import SummaryRouter from 'features/summary/SummaryRouter'; import BaseLayout from 'layouts/BaseLayout'; import AccessDenied from 'pages/403/AccessDenied'; import NotFoundPage from 'pages/404/NotFoundPage'; @@ -55,7 +56,17 @@ const AppRouter: React.FC = () => { - + + + + + + + + + + + diff --git a/app/src/components/data-grid/StyledDataGrid.tsx b/app/src/components/data-grid/StyledDataGrid.tsx index 61a8fafec2..728b29493d 100644 --- a/app/src/components/data-grid/StyledDataGrid.tsx +++ b/app/src/components/data-grid/StyledDataGrid.tsx @@ -1,12 +1,10 @@ -import Box from '@mui/material/Box'; import { grey } from '@mui/material/colors'; import { DataGrid, DataGridProps, GridValidRowModel } from '@mui/x-data-grid'; +import { SkeletonList } from 'components/loading/SkeletonLoaders'; import { useCallback } from 'react'; import StyledDataGridOverlay from './StyledDataGridOverlay'; -const StyledLoadingOverlay = () => ( - -); +const StyledLoadingOverlay = () => ; export type StyledDataGridProps = DataGridProps & { noRowsMessage?: string; }; @@ -18,11 +16,12 @@ export const StyledDataGrid = (props: StyledD return ( - {...props} autoHeight + {...props} slots={{ loadingOverlay: StyledLoadingOverlay, - noRowsOverlay: noRowsOverlay + noRowsOverlay: noRowsOverlay, + ...props.slots }} sx={{ border: 'none', @@ -49,7 +48,8 @@ export const StyledDataGrid = (props: StyledD }, '&.MuiDataGrid-root--densityCompact .MuiDataGrid-cell': { py: '8px' }, '&.MuiDataGrid-root--densityStandard .MuiDataGrid-cell': { py: '15px' }, - '&.MuiDataGrid-root--densityComfortable .MuiDataGrid-cell': { py: '22px' } + '&.MuiDataGrid-root--densityComfortable .MuiDataGrid-cell': { py: '22px' }, + ...props.sx }} /> ); diff --git a/app/src/components/data-grid/autocomplete/AsyncAutocompleteDataGridEditCell.tsx b/app/src/components/data-grid/autocomplete/AsyncAutocompleteDataGridEditCell.tsx index 23212c12d7..a46027a4c2 100644 --- a/app/src/components/data-grid/autocomplete/AsyncAutocompleteDataGridEditCell.tsx +++ b/app/src/components/data-grid/autocomplete/AsyncAutocompleteDataGridEditCell.tsx @@ -212,13 +212,7 @@ const AsyncAutocompleteDataGridEditCell = - + ); diff --git a/app/src/components/data-grid/autocomplete/AutocompleteDataGrid.interface.ts b/app/src/components/data-grid/autocomplete/AutocompleteDataGrid.interface.ts index 2334584405..fd0f1183d1 100644 --- a/app/src/components/data-grid/autocomplete/AutocompleteDataGrid.interface.ts +++ b/app/src/components/data-grid/autocomplete/AutocompleteDataGrid.interface.ts @@ -1,3 +1,5 @@ +import { IPartialTaxonomy } from 'interfaces/useTaxonomyApi.interface'; + /** * Defines a single option for a data grid autocomplete control. * @@ -16,13 +18,10 @@ export interface IAutocompleteDataGridOption * * @export * @interface IAutocompleteDataGridTaxonomyOption + * @extends {ITaxonomy} * @template ValueType */ -export interface IAutocompleteDataGridTaxonomyOption { +export interface IAutocompleteDataGridTaxonomyOption extends IPartialTaxonomy { value: ValueType; label: string; - commonNames: string[]; - tsn: number; - rank: string; - kingdom: string; } diff --git a/app/src/components/data-grid/taxonomy/TaxonomyDataGridEditCell.tsx b/app/src/components/data-grid/taxonomy/TaxonomyDataGridEditCell.tsx index 1473cb6e46..d08377ea24 100644 --- a/app/src/components/data-grid/taxonomy/TaxonomyDataGridEditCell.tsx +++ b/app/src/components/data-grid/taxonomy/TaxonomyDataGridEditCell.tsx @@ -51,6 +51,7 @@ const TaxonomyDataGridEditCell = ({ value: item.tsn as ValueType, label: item.scientificName, - commonNames: item.commonNames, tsn: item.tsn, + commonNames: item.commonNames, + scientificName: item.scientificName, rank: item.rank, kingdom: item.kingdom })); diff --git a/app/src/components/data-grid/taxonomy/TaxonomyDataGridViewCell.tsx b/app/src/components/data-grid/taxonomy/TaxonomyDataGridViewCell.tsx index 79fd33eb66..17be244460 100644 --- a/app/src/components/data-grid/taxonomy/TaxonomyDataGridViewCell.tsx +++ b/app/src/components/data-grid/taxonomy/TaxonomyDataGridViewCell.tsx @@ -1,10 +1,10 @@ import Typography from '@mui/material/Typography'; import { GridRenderCellParams, GridValidRowModel } from '@mui/x-data-grid'; import { useTaxonomyContext } from 'hooks/useContext'; -import { ITaxonomy } from 'interfaces/useTaxonomyApi.interface'; +import { IPartialTaxonomy } from 'interfaces/useTaxonomyApi.interface'; import { useEffect, useState } from 'react'; -export interface ITaxonomyDataGridViewCellProps { +export interface IPartialTaxonomyDataGridViewCellProps { dataGridProps: GridRenderCellParams; error?: boolean; } @@ -13,17 +13,17 @@ export interface ITaxonomyDataGridViewCellProps} props + * @param {IPartialTaxonomyDataGridViewCellProps} props * @return {*} */ const TaxonomyDataGridViewCell = ( - props: ITaxonomyDataGridViewCellProps + props: IPartialTaxonomyDataGridViewCellProps ) => { const { dataGridProps } = props; const taxonomyContext = useTaxonomyContext(); - const [taxon, setTaxon] = useState(null); + const [taxon, setTaxon] = useState(null); useEffect(() => { const response = taxonomyContext.getCachedSpeciesTaxonomyById(dataGridProps.value); diff --git a/app/src/components/fields/CustomTextField.tsx b/app/src/components/fields/CustomTextField.tsx index 9d46b7bc2e..012c1477c4 100644 --- a/app/src/components/fields/CustomTextField.tsx +++ b/app/src/components/fields/CustomTextField.tsx @@ -13,7 +13,7 @@ export interface ICustomTextField { other?: any; } -const CustomTextField: React.FC> = (props) => { +const CustomTextField = (props: React.PropsWithChildren) => { const { touched, errors, values, handleChange, handleBlur } = useFormikContext(); const { name, label, other } = props; diff --git a/app/src/components/fields/MultiAutocompleteFieldVariableSize.tsx b/app/src/components/fields/MultiAutocompleteFieldVariableSize.tsx index a64c82c481..12ce881209 100644 --- a/app/src/components/fields/MultiAutocompleteFieldVariableSize.tsx +++ b/app/src/components/fields/MultiAutocompleteFieldVariableSize.tsx @@ -128,7 +128,8 @@ const useStyles = () => { padding: 0, margin: 0 } - } + }, + flex: '1 1 auto' }; }; diff --git a/app/src/components/fields/SingleDateField.tsx b/app/src/components/fields/SingleDateField.tsx index 44ccd18afe..ad95159e56 100644 --- a/app/src/components/fields/SingleDateField.tsx +++ b/app/src/components/fields/SingleDateField.tsx @@ -80,14 +80,16 @@ const SingleDateField: React.FC = (props) => { value={formattedDateValue} onChange={(value) => { other?.onChange?.(value); - if (!value || value === 'Invalid Date') { - // The creation input value will be 'Invalid Date' when the date field is cleared (empty), and will - // contain an actual date string value if the field is not empty but is invalid. - setFieldValue(name, null); - return; - } - setFieldValue(name, dayjs(value).format(DATE_FORMAT.ShortDateFormat)); + const date = dayjs(value); + + if (date.isValid()) { + // If value is null or valid, set the field value accordingly + setFieldValue(name, date.format(DATE_FORMAT.ShortDateFormat)); + } else { + // If value is invalid, set the field value to null + setFieldValue(name, ''); + } }} /> diff --git a/app/src/components/fields/SystemUserAutocompleteField.tsx b/app/src/components/fields/SystemUserAutocompleteField.tsx new file mode 100644 index 0000000000..1af9a1cc89 --- /dev/null +++ b/app/src/components/fields/SystemUserAutocompleteField.tsx @@ -0,0 +1,222 @@ +import { mdiMagnify } from '@mdi/js'; +import { Icon } from '@mdi/react'; +import Autocomplete from '@mui/material/Autocomplete/Autocomplete'; +import Box from '@mui/material/Box'; +import CircularProgress from '@mui/material/CircularProgress'; +import grey from '@mui/material/colors/grey'; +import TextField from '@mui/material/TextField'; +import UserCard from 'components/user/UserCard'; +import { useBiohubApi } from 'hooks/useBioHubApi'; +import useIsMounted from 'hooks/useIsMounted'; +import { ISystemUser } from 'interfaces/useUserApi.interface'; +import { debounce, startCase } from 'lodash-es'; +import { useMemo, useState } from 'react'; + +interface ISystemUserAutocompleteFieldProps { + /** + * Formik field name. + * + * @type {string} + * @memberof ISystemUserAutocompleteFieldProps + */ + formikFieldName: string; + /** + * The field label. + * + * @type {string} + * @memberof ISystemUserAutocompleteFieldProps + */ + label: string; + /** + * Callback fired on option selection. + * + * @type {(species: ITaxonomy) => void} + * @memberof ISystemUserAutocompleteFieldProps + */ + onSelect: (user?: ISystemUser) => void; + /** + * Optional callback fired on option de-selected/cleared. + * + * @memberof ISystemUserAutocompleteFieldProps + */ + onClear?: () => void; + /** + * If field is required. + * + * @type {boolean} + * @memberof ISystemUserAutocompleteFieldProps + */ + required?: boolean; + /** + * If field is disabled. + * + * @type {boolean} + * @memberof ISystemUserAutocompleteFieldProps + */ + disabled?: boolean; + /** + * If `true`, clears the input field after a selection is made. + * + * @type {boolean} + * @memberof ISystemUserAutocompleteFieldProps + */ + clearOnSelect?: boolean; + /** + * Whether to show start adornment magnifying glass or not + * Defaults to false + * + * @type {boolean} + * @memberof ISystemUserAutocompleteFieldProps + */ + showStartAdornment?: boolean; + /** + * Placeholder text for the TextField + * + * @type {string} + * @memberof ISystemUserAutocompleteFieldProps + */ + placeholder?: string; +} + +/** + * Autocomplete field for searching for and selecting a single system user. + * + * @param {ISystemUserAutocompleteFieldProps} props + * @return {*} + */ +export const SystemUserAutocompleteField = (props: ISystemUserAutocompleteFieldProps) => { + const { formikFieldName, disabled, label, showStartAdornment, placeholder, onSelect, onClear, clearOnSelect } = props; + + const biohubApi = useBiohubApi(); + const isMounted = useIsMounted(); + + // The input field value + const [inputValue, setInputValue] = useState(''); + // The array of options to choose from + const [options, setOptions] = useState([]); + // Is control loading (search in progress) + const [isLoading, setIsLoading] = useState(false); + + const handleSearch = useMemo( + () => + debounce(async (keyword: string, callback: (searchedValues: ISystemUser[]) => void) => { + const response = await biohubApi.user.searchSystemUser(keyword).catch(() => { + return []; + }); + + callback(response); + }, 500), + [biohubApi.user] + ); + + return ( + 2 ? 'No matching options' : 'Enter at least 3 letters'} + options={options} + getOptionLabel={(option) => option.display_name} + isOptionEqualToValue={(option, value) => { + return option.system_user_id === value.system_user_id; + }} + filterOptions={(item) => item} + inputValue={inputValue} + // Text field value changed + onInputChange={(_, value, reason) => { + if (clearOnSelect && reason === 'reset') { + setInputValue(''); + setOptions([]); + onClear?.(); + return; + } + + if (reason === 'clear') { + setInputValue(''); + setOptions([]); + onClear?.(); + return; + } + + if (!value) { + setInputValue(''); + setOptions([]); + return; + } + + setIsLoading(true); + setInputValue(value); + handleSearch(value, (newOptions) => { + if (value.length < 3) { + return; + } + if (!isMounted()) { + return; + } + setOptions(() => newOptions); + setIsLoading(false); + }); + }} + // Option selected from dropdown + onChange={(_, option) => { + if (!option) { + onClear?.(); + return; + } + + onSelect(option); + + if (clearOnSelect) { + setInputValue(''); + return; + } + + setInputValue(startCase(option.display_name)); + }} + renderOption={(renderProps, renderOption) => ( + + + + + + )} + renderInput={(params) => ( + + + + ), + endAdornment: ( + <> + {isLoading ? : null} + {params.InputProps.endAdornment} + + ) + }} + /> + )} + /> + ); +}; diff --git a/app/src/components/layout/Header.tsx b/app/src/components/layout/Header.tsx index 037e051e30..3366c386fa 100644 --- a/app/src/components/layout/Header.tsx +++ b/app/src/components/layout/Header.tsx @@ -259,7 +259,7 @@ const Header: React.FC = () => { Projects @@ -340,7 +340,7 @@ const Header: React.FC = () => { - + Projects diff --git a/app/src/components/search-filter/ProjectAdvancedFilters.tsx b/app/src/components/search-filter/ProjectAdvancedFilters.tsx deleted file mode 100644 index 604678f6c7..0000000000 --- a/app/src/components/search-filter/ProjectAdvancedFilters.tsx +++ /dev/null @@ -1,116 +0,0 @@ -import Grid from '@mui/material/Grid'; -import CustomTextField from 'components/fields/CustomTextField'; -import MultiAutocompleteFieldVariableSize, { - IMultiAutocompleteFieldOption -} from 'components/fields/MultiAutocompleteFieldVariableSize'; -import StartEndDateFields from 'components/fields/StartEndDateFields'; -import { useFormikContext } from 'formik'; -import { useBiohubApi } from 'hooks/useBioHubApi'; -import { useCodesContext } from 'hooks/useContext'; -import { ITaxonomy } from 'interfaces/useTaxonomyApi.interface'; -import { debounce } from 'lodash-es'; -import { useMemo } from 'react'; - -export interface IProjectAdvancedFilters { - keyword: string; - project_name: string; - agency_id: number; - agency_project_id: string; - itis_tsns: number[]; -} - -export const ProjectAdvancedFiltersInitialValues: IProjectAdvancedFilters = { - keyword: '', - project_name: '', - agency_id: '' as unknown as number, - agency_project_id: '', - itis_tsns: [] -}; - -/** - * Project - Advanced filters - * - * @return {*} - */ -const ProjectAdvancedFilters = () => { - const formikProps = useFormikContext(); - - const biohubApi = useBiohubApi(); - - const { handleSubmit } = formikProps; - - const codesContext = useCodesContext(); - - const convertOptions = (value: ITaxonomy[]): IMultiAutocompleteFieldOption[] => - value.map((item: any) => { - return { - value: parseInt(item.tsn), - label: [...item.commonNames, `(${item.scientificName})`].filter(Boolean).join(' ') - }; - }); - - const handleGetInitList = async (initialvalues: number[]) => { - if (!initialvalues.length) { - return []; - } - - const response = await biohubApi.taxonomy.getSpeciesFromIds(initialvalues); - return convertOptions(response); - }; - - const handleSearch = useMemo( - () => - debounce( - async ( - inputValue: string, - existingValues: (string | number)[], - callback: (searchedValues: IMultiAutocompleteFieldOption[]) => void - ) => { - const searchTerms = inputValue.split(' ').filter(Boolean); - const response = await biohubApi.taxonomy.searchSpeciesByTerms(searchTerms); - const newOptions = convertOptions(response).filter((item) => !existingValues?.includes(item.value)); - callback(newOptions); - }, - 500 - ), - [biohubApi.taxonomy] - ); - - if (!codesContext.codesDataLoader.data) { - return <>; - } - - return ( -
- - - - - - - - - - - - - - -
- ); -}; - -export default ProjectAdvancedFilters; diff --git a/app/src/components/species/AncillarySpeciesComponent.tsx b/app/src/components/species/AncillarySpeciesComponent.tsx index bd5ae0076b..3945ad7b3c 100644 --- a/app/src/components/species/AncillarySpeciesComponent.tsx +++ b/app/src/components/species/AncillarySpeciesComponent.tsx @@ -1,7 +1,7 @@ import Box from '@mui/material/Box'; import AlertBar from 'components/alert/AlertBar'; import { useFormikContext } from 'formik'; -import { ITaxonomy } from 'interfaces/useTaxonomyApi.interface'; +import { IPartialTaxonomy, ITaxonomy } from 'interfaces/useTaxonomyApi.interface'; import get from 'lodash-es/get'; import SelectedSpecies from './components/SelectedSpecies'; import SpeciesAutocompleteField from './components/SpeciesAutocompleteField'; @@ -11,7 +11,7 @@ const AncillarySpeciesComponent = () => { const selectedSpecies: ITaxonomy[] = get(values, 'species.ancillary_species') || []; - const handleAddSpecies = (species?: ITaxonomy) => { + const handleAddSpecies = (species?: IPartialTaxonomy) => { setFieldValue(`species.ancillary_species[${selectedSpecies.length}]`, species); setFieldError(`species.ancillary_species`, undefined); }; diff --git a/app/src/components/species/FocalSpeciesComponent.tsx b/app/src/components/species/FocalSpeciesComponent.tsx index a5217194f7..9940355875 100644 --- a/app/src/components/species/FocalSpeciesComponent.tsx +++ b/app/src/components/species/FocalSpeciesComponent.tsx @@ -1,7 +1,7 @@ import Stack from '@mui/material/Stack'; import AlertBar from 'components/alert/AlertBar'; import { useFormikContext } from 'formik'; -import { ITaxonomy } from 'interfaces/useTaxonomyApi.interface'; +import { IPartialTaxonomy, ITaxonomy } from 'interfaces/useTaxonomyApi.interface'; import get from 'lodash-es/get'; import SelectedSpecies from './components/SelectedSpecies'; import SpeciesAutocompleteField from './components/SpeciesAutocompleteField'; @@ -11,7 +11,7 @@ const FocalSpeciesComponent = () => { const selectedSpecies: ITaxonomy[] = get(values, 'species.focal_species') || []; - const handleAddSpecies = (species?: ITaxonomy) => { + const handleAddSpecies = (species?: IPartialTaxonomy) => { setFieldValue(`species.focal_species[${selectedSpecies.length}]`, species); setFieldError(`species.focal_species`, undefined); }; diff --git a/app/src/components/species/components/SelectedSpecies.tsx b/app/src/components/species/components/SelectedSpecies.tsx index 4b4f2dd2b6..3b72605553 100644 --- a/app/src/components/species/components/SelectedSpecies.tsx +++ b/app/src/components/species/components/SelectedSpecies.tsx @@ -1,17 +1,17 @@ import Box from '@mui/material/Box'; import Collapse from '@mui/material/Collapse'; import SpeciesSelectedCard from 'components/species/components/SpeciesSelectedCard'; -import { ITaxonomy } from 'interfaces/useTaxonomyApi.interface'; +import { IPartialTaxonomy } from 'interfaces/useTaxonomyApi.interface'; import { TransitionGroup } from 'react-transition-group'; export interface ISelectedSpeciesProps { /** * List of selected species to display. * - * @type {ITaxonomy[]} + * @type {IPartialTaxonomy[]} * @memberof ISelectedSpeciesProps */ - selectedSpecies: ITaxonomy[]; + selectedSpecies: IPartialTaxonomy[]; /** * Callback to remove a species from the selected species list. * If not provided, the remove button will not be displayed. @@ -28,7 +28,7 @@ const SelectedSpecies = (props: ISelectedSpeciesProps) => { {selectedSpecies && - selectedSpecies.map((species: ITaxonomy, index: number) => { + selectedSpecies.map((species: IPartialTaxonomy, index: number) => { return ( diff --git a/app/src/components/species/components/SpeciesAutocompleteField.tsx b/app/src/components/species/components/SpeciesAutocompleteField.tsx index 5c3ae3821d..de90f1e808 100644 --- a/app/src/components/species/components/SpeciesAutocompleteField.tsx +++ b/app/src/components/species/components/SpeciesAutocompleteField.tsx @@ -8,9 +8,9 @@ import TextField from '@mui/material/TextField'; import SpeciesCard from 'components/species/components/SpeciesCard'; import { useBiohubApi } from 'hooks/useBioHubApi'; import useIsMounted from 'hooks/useIsMounted'; -import { ITaxonomy } from 'interfaces/useTaxonomyApi.interface'; +import { IPartialTaxonomy, ITaxonomy } from 'interfaces/useTaxonomyApi.interface'; import { debounce, startCase } from 'lodash-es'; -import { ChangeEvent, useMemo, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; export interface ISpeciesAutocompleteFieldProps { /** @@ -30,17 +30,27 @@ export interface ISpeciesAutocompleteFieldProps { /** * Callback to fire on species option selection. * - * @type {(species: ITaxonomy) => void} + * @type {(species: ITaxonomy | IPartialTaxonomy) => void} * @memberof ISpeciesAutocompleteFieldProps */ - handleSpecies: (species?: ITaxonomy) => void; + handleSpecies: (species?: ITaxonomy | IPartialTaxonomy) => void; + /** + * Optional callback to fire on species option being cleared + * + * @memberof ISpeciesAutocompleteFieldProps + */ + handleClear?: () => void; /** * Default species to render for input and options. * - * @type {ITaxonomy} + * @type {ITaxonomy | IPartialTaxonomy} * @memberof ISpeciesAutocompleteFieldProps */ - defaultSpecies?: ITaxonomy; + defaultSpecies?: + | ITaxonomy + | IPartialTaxonomy + | Promise + | Promise; /** * The error message to display. * @@ -72,58 +82,110 @@ export interface ISpeciesAutocompleteFieldProps { * @memberof ISpeciesAutocompleteFieldProps */ clearOnSelect?: boolean; + /** + * Whether to show start adornment magnifying glass or not + * Defaults to false + * + * @type {boolean} + * @memberof ISpeciesAutocompleteFieldProps + */ + showStartAdornment?: boolean; + /** + * Placeholder text for the TextField + * + * @type {string} + * @memberof ISpeciesAutocompleteFieldProps + */ + placeholder?: string; } +/** + * Autocomplete field for searching for and selecting a single taxon. + * + * Note: Depends on the external BioHub API for fetching species records. + * + * @param {ISpeciesAutocompleteFieldProps} props + * @return {*} + */ const SpeciesAutocompleteField = (props: ISpeciesAutocompleteFieldProps) => { - const { formikFieldName, label, required, error, handleSpecies, defaultSpecies } = props; + const { + formikFieldName, + clearOnSelect, + required, + label, + error, + placeholder, + disabled, + handleSpecies, + handleClear, + defaultSpecies, + showStartAdornment + } = props; const biohubApi = useBiohubApi(); const isMounted = useIsMounted(); + // A default species has been provided and it is not a promise + const isDefaultSpecies = defaultSpecies && !('then' in defaultSpecies); + + const [hasLoadedDefaultSpecies, setHasLoadedDefaultSpecies] = useState(false); // The input field value - const [inputValue, setInputValue] = useState(defaultSpecies?.scientificName ?? ''); + const [inputValue, setInputValue] = useState(isDefaultSpecies ? defaultSpecies?.scientificName : ''); // The array of options to choose from - const [options, setOptions] = useState(defaultSpecies ? [defaultSpecies] : []); + const [options, setOptions] = useState<(ITaxonomy | IPartialTaxonomy)[]>(isDefaultSpecies ? [defaultSpecies] : []); // Is control loading (search in progress) const [isLoading, setIsLoading] = useState(false); - const search = useMemo( + useEffect(() => { + if (defaultSpecies && 'then' in defaultSpecies) { + // A default species has been provided and it is a promise + defaultSpecies.then((taxonomy) => { + if (hasLoadedDefaultSpecies) { + // Only ever run this once + return; + } + + if (inputValue !== '') { + // Input value has been set by the user, do not override it + return; + } + + if (!taxonomy) { + // No default taxon returned from promise + return; + } + + if (!isMounted()) { + // Component has been unmounted + return; + } + + // Set the default taxon as the input value and options + setHasLoadedDefaultSpecies(true); + setInputValue(taxonomy.scientificName); + setOptions([taxonomy]); + }); + } + }, [defaultSpecies, hasLoadedDefaultSpecies, inputValue, isMounted]); + + const handleSearch = useMemo( () => debounce(async (inputValue: string, callback: (searchedValues: ITaxonomy[]) => void) => { const searchTerms = inputValue.split(' ').filter(Boolean); - // TODO: Add error handling if this call throws an error - const response = await biohubApi.taxonomy.searchSpeciesByTerms(searchTerms); + const response = await biohubApi.taxonomy.searchSpeciesByTerms(searchTerms).catch(() => { + return []; + }); callback(response); }, 500), [biohubApi.taxonomy] ); - const handleOnChange = (event: ChangeEvent) => { - const input = event.target.value; - setInputValue(input); - - if (!input) { - setOptions([]); - search.cancel(); - handleSpecies(); - return; - } - setIsLoading(true); - search(input, (speciesOptions) => { - if (!isMounted()) { - return; - } - setOptions(speciesOptions); - setIsLoading(false); - }); - }; - return ( { }} filterOptions={(item) => item} inputValue={inputValue} - onInputChange={(_, _value, reason) => { - if (props.clearOnSelect && reason === 'reset') { + // Text field value changed + onInputChange={(_, value, reason) => { + if (reason === 'reset') { + if (clearOnSelect) { + setInputValue(''); + setOptions([]); + handleClear?.(); + } + return; + } + + if (reason === 'clear') { setInputValue(''); + setOptions([]); + handleClear?.(); + return; } + + if (!value) { + setInputValue(''); + setOptions([]); + return; + } + + setIsLoading(true); + setInputValue(value); + handleSearch(value, (newOptions) => { + if (!isMounted()) { + return; + } + + setOptions(() => newOptions); + setIsLoading(false); + }); }} + // Option selected from dropdown onChange={(_, option) => { - if (option) { - handleSpecies(option); - setInputValue(startCase(option?.commonNames?.length ? option.commonNames[0] : option.scientificName)); + if (!option) { + handleClear?.(); + return; + } + + handleSpecies(option); + + if (clearOnSelect) { + setInputValue(''); + return; } + + setInputValue(startCase(option?.commonNames?.length ? option.commonNames[0] : option.scientificName)); }} renderOption={(renderProps, renderOption) => { return ( @@ -155,14 +257,8 @@ const SpeciesAutocompleteField = (props: ISpeciesAutocompleteFieldProps) => { }} key={`${renderOption.tsn}-${renderOption.scientificName}`} {...renderProps}> - - + + ); @@ -171,22 +267,26 @@ const SpeciesAutocompleteField = (props: ISpeciesAutocompleteFieldProps) => { 1 ? 'italic' : 'normal' + } + }} variant="outlined" fullWidth - placeholder="Type to start searching" + placeholder={placeholder || 'Enter a species or taxon'} InputProps={{ ...params.InputProps, - startAdornment: ( + startAdornment: showStartAdornment && ( ), endAdornment: ( <> - {isLoading ? : null} + {inputValue && isLoading ? : null} {params.InputProps.endAdornment} ) diff --git a/app/src/components/species/components/SpeciesCard.tsx b/app/src/components/species/components/SpeciesCard.tsx index 77d6349616..1f7ffc08b0 100644 --- a/app/src/components/species/components/SpeciesCard.tsx +++ b/app/src/components/species/components/SpeciesCard.tsx @@ -1,85 +1,56 @@ -import { mdiCircle } from '@mdi/js'; -import Icon from '@mdi/react'; -import Chip from '@mui/material/Chip'; -import { grey } from '@mui/material/colors'; +import Box from '@mui/material/Box'; import Stack from '@mui/material/Stack'; import Typography from '@mui/material/Typography'; -import { taxonRankColours } from 'constants/taxon'; -import React from 'react'; +import ColouredRectangleChip from 'components/chips/ColouredRectangleChip'; +import { getTaxonRankColour, TaxonRankKeys } from 'constants/colours'; +import { IPartialTaxonomy, ITaxonomy } from 'interfaces/useTaxonomyApi.interface'; -interface ISpeciesCard { - commonNames: string[]; - scientificName: string; - tsn: number; - rank: string; - kingdom: string; +interface ISpeciesCardProps { + taxon: ITaxonomy | IPartialTaxonomy; } -const SpeciesCard = (props: ISpeciesCard) => { +const SpeciesCard = (props: ISpeciesCardProps) => { + const { taxon } = props; + + // combine all common names and join them with a middot + const commonNames = taxon.commonNames.filter((item) => item !== null).join(`\u00A0\u00B7\u00A0`); + return ( - - - - {props.scientificName.split(' ').length > 1 ? {props.scientificName} : <>{props.scientificName}} - {props.rank && ( - color.ranks.includes(props.rank))?.color || grey[800], - '& .MuiChip-label': { - letterSpacing: '0.03rem', - color: '#fff', - fontWeight: 100, - fontSize: '0.6rem' - } - }} + + + + + {taxon.scientificName.split(' ')?.length > 1 ? ( + {taxon.scientificName} + ) : ( + <>{taxon.scientificName} + )} + + {taxon?.rank && ( + )} - - - {props.commonNames?.length > 0 && - props.commonNames.map((name, index) => ( - - {index > 0 && } - - {name} - - - ))} + + {commonNames} + + + + {taxon.tsn} - - - ); }; diff --git a/app/src/components/species/components/SpeciesSelectedCard.tsx b/app/src/components/species/components/SpeciesSelectedCard.tsx index a678be5b14..32e17112d4 100644 --- a/app/src/components/species/components/SpeciesSelectedCard.tsx +++ b/app/src/components/species/components/SpeciesSelectedCard.tsx @@ -4,17 +4,17 @@ import Box from '@mui/material/Box'; import grey from '@mui/material/colors/grey'; import IconButton from '@mui/material/IconButton'; import Paper from '@mui/material/Paper'; -import { ITaxonomy } from 'interfaces/useTaxonomyApi.interface'; +import { IPartialTaxonomy } from 'interfaces/useTaxonomyApi.interface'; import SpeciesCard from './SpeciesCard'; interface ISpeciesSelectedCardProps { /** * The species to display. * - * @type {ITaxonomy} + * @type {IPartialTaxonomy} * @memberof ISpeciesSelectedCardProps */ - species: ITaxonomy; + species: IPartialTaxonomy; /** * Callback to remove a species from the selected species list. * If not provided, the remove button will not be displayed. @@ -38,13 +38,7 @@ const SpeciesSelectedCard = (props: ISpeciesSelectedCardProps) => { - + {handleRemove && ( diff --git a/app/src/constants/colours.ts b/app/src/constants/colours.ts new file mode 100644 index 0000000000..ff70dc131a --- /dev/null +++ b/app/src/constants/colours.ts @@ -0,0 +1,104 @@ +import { Color } from '@mui/material'; +import { + blue, + blueGrey, + brown, + cyan, + deepOrange, + deepPurple, + green, + grey, + indigo, + orange, + pink, + purple, + red, + teal +} from '@mui/material/colors'; + +type ColourMap = Record; + +/** + * Default colour fallback. + * + */ +const DEFAULT_COLOUR = blueGrey; + +/** + * Colour map for `Survey Progress` chips. + * + */ +const SURVEY_PROGRESS_COLOUR_MAP = { + Planning: { colour: blueGrey }, + 'In progress': { colour: deepPurple }, + Completed: { colour: green } +}; + +/** + * Colour map for `Taxon Rank` chips. + * + */ +const TAXON_RANK_COLOUR_MAP = { + Subspecies: { colour: blue }, + Variety: { colour: blue }, + Species: { colour: purple }, + Genus: { colour: teal }, + Family: { colour: red }, + Order: { colour: indigo }, + Class: { colour: deepOrange }, + Phylum: { colour: pink }, + Kingdom: { colour: grey } +}; + +/** + * Colour map for `NRM Region` chips. + * + */ +const NRM_REGION_COLOUR_MAP = { + 'Kootenay-Boundary Natural Resource Region': { colour: cyan }, + 'Thompson-Okanagan Natural Resource Region': { colour: orange }, + 'West Coast Natural Resource Region': { colour: green }, + 'Cariboo Natural Resource Region': { colour: deepPurple }, + 'South Coast Natural Resource Region': { colour: blue }, + 'Northeast Natural Resource Region': { colour: brown }, + 'Omineca Natural Resource Region': { colour: pink }, + 'Skeena Natural Resource Region': { colour: red } +}; + +/** + * ColourMap key types + * + */ +export type SurveyProgressKeys = keyof typeof SURVEY_PROGRESS_COLOUR_MAP; +export type TaxonRankKeys = keyof typeof TAXON_RANK_COLOUR_MAP; +export type NrmRegionKeys = keyof typeof NRM_REGION_COLOUR_MAP; + +/** + * Generate colour getter from ColourMap. + * + * @template T - Extends ColourMap + * @param {T} colourMap - Coulour mapping + * @param {ColourMap} [fallbackColour] - Default colour + * @returns {(lookup: string) => Colour} + */ +const generateColourMapGetter = (colourMap: T, fallbackColour = DEFAULT_COLOUR) => { + return (lookup: keyof T) => colourMap[lookup]?.colour ?? fallbackColour; +}; + +/** + * Get survey progress colour mapping. + * + */ +export const getSurveyProgressColour = generateColourMapGetter(SURVEY_PROGRESS_COLOUR_MAP); + +/** + * Get taxon rank colour mapping. + * + */ +export const getTaxonRankColour = generateColourMapGetter(TAXON_RANK_COLOUR_MAP); + +/** + * Get NRM region colour mapping. + * + */ +export const getNrmRegionColour = generateColourMapGetter(NRM_REGION_COLOUR_MAP); diff --git a/app/src/constants/misc.ts b/app/src/constants/misc.ts index 993ae26501..f52321e41b 100644 --- a/app/src/constants/misc.ts +++ b/app/src/constants/misc.ts @@ -1,8 +1,3 @@ -import { Color } from '@mui/material'; -import blue from '@mui/material/colors/blue'; -import green from '@mui/material/colors/green'; -import purple from '@mui/material/colors/purple'; - export enum AdministrativeActivityType { SYSTEM_ACCESS = 'System Access' } @@ -12,9 +7,3 @@ export enum AdministrativeActivityStatusType { ACTIONED = 'Actioned', REJECTED = 'Rejected' } - -export const SurveyProgressChipColours: Record = { - PLANNING: blue, - 'IN PROGRESS': purple, - COMPLETED: green -}; diff --git a/app/src/constants/regions.ts b/app/src/constants/regions.ts index 8451623cff..ddb04270e9 100644 --- a/app/src/constants/regions.ts +++ b/app/src/constants/regions.ts @@ -1,38 +1,6 @@ -import blue from '@mui/material/colors/blue'; -import blueGrey from '@mui/material/colors/blueGrey'; -import brown from '@mui/material/colors/brown'; -import deepPurple from '@mui/material/colors/deepPurple'; -import orange from '@mui/material/colors/orange'; -import pink from '@mui/material/colors/pink'; -import red from '@mui/material/colors/red'; -import teal from '@mui/material/colors/teal'; /** * `Natural Resource Regions` appended text * ie: `Cariboo Natural Resource Region` * */ export const NRM_REGION_APPENDED_TEXT = ' Natural Resource Region'; - -/** - * Used to colour region chips. - * - */ -export const NRM_REGION_COLOUR_MAP = { - 'Kootenay-Boundary Natural Resource Region': blueGrey, - 'Thompson-Okanagan Natural Resource Region': orange, - 'West Coast Natural Resource Region': teal, - 'Cariboo Natural Resource Region': deepPurple, - 'South Coast Natural Resource Region': blue, - 'Northeast Natural Resource Region': brown, - 'Omineca Natural Resource Region': pink, - 'Skeena Natural Resource Region': red -}; - -/** - * Helper function to retrive nrm region colour - * @param {string} region - name of region - * @returns {*} mui colour object - */ -export const getNrmRegionColour = (region: string) => { - return NRM_REGION_COLOUR_MAP[region as keyof typeof NRM_REGION_COLOUR_MAP] ?? blueGrey; -}; diff --git a/app/src/constants/taxon.ts b/app/src/constants/taxon.ts deleted file mode 100644 index 93973e6ef0..0000000000 --- a/app/src/constants/taxon.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { blue, deepOrange, grey, indigo, pink, purple, teal } from '@mui/material/colors'; - -export const taxonRankColours = [ - { color: blue[600], ranks: ['Subspecies', 'Variety'] }, - { color: purple[600], ranks: ['Species'] }, - { color: teal[600], ranks: ['Genus'] }, - { color: blue[600], ranks: ['Family'] }, - { color: indigo[600], ranks: ['Order'] }, - { color: deepOrange[600], ranks: ['Class'] }, - { color: pink[600], ranks: ['Phylum'] }, - { color: grey[600], ranks: ['Kingdom'] } -]; diff --git a/app/src/contexts/observationsTableContext.tsx b/app/src/contexts/observationsTableContext.tsx index 1a13f362db..7b9f981f69 100644 --- a/app/src/contexts/observationsTableContext.tsx +++ b/app/src/contexts/observationsTableContext.tsx @@ -906,7 +906,7 @@ export const ObservationsTableContextProvider = (props: IObservationsTableContex survey_sample_method_id: null as unknown as number, survey_sample_period_id: null, count: null as unknown as number, - observation_date: null as unknown as Date, + observation_date: '', observation_time: '', latitude: null as unknown as number, longitude: null as unknown as number, diff --git a/app/src/contexts/projectContext.tsx b/app/src/contexts/projectContext.tsx index bbc4cd5446..22212bbf3d 100644 --- a/app/src/contexts/projectContext.tsx +++ b/app/src/contexts/projectContext.tsx @@ -4,7 +4,7 @@ import { useBiohubApi } from 'hooks/useBioHubApi'; import useDataLoader, { DataLoader } from 'hooks/useDataLoader'; import useDataLoaderError from 'hooks/useDataLoaderError'; import { IGetProjectAttachmentsResponse, IGetProjectForViewResponse } from 'interfaces/useProjectApi.interface'; -import { IGetSurveyListResponse } from 'interfaces/useSurveyApi.interface'; +import { IFindSurveysResponse } from 'interfaces/useSurveyApi.interface'; import { createContext, PropsWithChildren, useEffect, useMemo } from 'react'; import { useParams } from 'react-router'; import { ApiPaginationRequestOptions } from 'types/misc'; @@ -27,10 +27,10 @@ export interface IProjectContext { /** * The Data Loader used to load project data * - * @type {DataLoader<[pagination?: ApiPaginationRequestOptions], IGetSurveyListResponse, unknown>} + * @type {DataLoader<[pagination?: ApiPaginationRequestOptions], IFindSurveysResponse, unknown>} * @memberof IProjectContext */ - surveysListDataLoader: DataLoader<[pagination?: ApiPaginationRequestOptions], IGetSurveyListResponse, unknown>; + surveysListDataLoader: DataLoader<[pagination?: ApiPaginationRequestOptions], IFindSurveysResponse, unknown>; /** * The Data Loader used to load project data @@ -51,7 +51,7 @@ export interface IProjectContext { export const ProjectContext = createContext({ projectDataLoader: {} as DataLoader<[project_id: number], IGetProjectForViewResponse, unknown>, - surveysListDataLoader: {} as DataLoader<[pagination?: ApiPaginationRequestOptions], IGetSurveyListResponse, unknown>, + surveysListDataLoader: {} as DataLoader<[pagination?: ApiPaginationRequestOptions], IFindSurveysResponse, unknown>, artifactDataLoader: {} as DataLoader<[project_id: number], IGetProjectAttachmentsResponse, unknown>, projectId: -1 }); diff --git a/app/src/contexts/taxonomyContext.tsx b/app/src/contexts/taxonomyContext.tsx index 36210f2bb9..a39eb18ea6 100644 --- a/app/src/contexts/taxonomyContext.tsx +++ b/app/src/contexts/taxonomyContext.tsx @@ -1,20 +1,26 @@ import { useBiohubApi } from 'hooks/useBioHubApi'; import useIsMounted from 'hooks/useIsMounted'; -import { ITaxonomy } from 'interfaces/useTaxonomyApi.interface'; +import { IPartialTaxonomy } from 'interfaces/useTaxonomyApi.interface'; import { get as getProperty, has as hasProperty } from 'lodash'; import { createContext, PropsWithChildren, useCallback, useMemo, useRef, useState } from 'react'; export interface ITaxonomyContext { /** - * Fetches taxonomy data for the given IDs. For each ID, if the results of its query + * Fetches taxonomy data for the given ITIS TSN. For each TSN, if the results of its query * is already in the cache, it is immediately available. Otherwise, `null` is * returned, and the the taxonomic data is fetched and subsequently cached. */ - getCachedSpeciesTaxonomyById: (id: number) => ITaxonomy | null; + getCachedSpeciesTaxonomyById: (tsn: number) => IPartialTaxonomy | null; /** - * Caches taxonomy data for the given IDs. + * Fetches taxonomy data for the given ITIS TSN. For each TSN, if the results of its query + * is already in the cache, it is immediately resolved. Otherwise, a request is dispatched + * and the pending promise is returned. */ - cacheSpeciesTaxonomyByIds: (ids: number[]) => Promise; + getCachedSpeciesTaxonomyByIdAsync: (tsn: number) => Promise; + /** + * Caches taxonomy data for the given ITIS TSNs. + */ + cacheSpeciesTaxonomyByIds: (tsns: number[]) => Promise; } export const TaxonomyContext = createContext(undefined); @@ -24,67 +30,98 @@ export const TaxonomyContextProvider = (props: PropsWithChildren) => { const isMounted = useIsMounted(); - const [_taxonomyCache, _setTaxonomyCache] = useState>({}); - const _dispatchedIds = useRef>(new Set([])); + const [taxonomyCache, setTaxonomyCache] = useState>({}); + const _dispatchedTsnPromises = useRef>>({}); const cacheSpeciesTaxonomyByIds = useCallback( - async (ids: number[]) => { - if (!isMounted()) { - return; - } - - ids.forEach((id) => _dispatchedIds.current.add(id)); - await biohubApi.taxonomy - .getSpeciesFromIds(ids) - .then((result) => { - const newTaxonomyItems: Record = {}; - - for (const item of result) { - newTaxonomyItems[item.tsn] = item; + async (tsns: number[]) => { + const fetchTaxonomiesPromise = biohubApi.taxonomy + .getSpeciesFromIds(tsns) + .then((taxonomies) => { + if (!isMounted()) { + return null; } - if (!isMounted()) { - return; + // Update the sync cache for the current tsn + const newTaxonomyItems: Record = {}; + for (const taxon of taxonomies) { + newTaxonomyItems[taxon.tsn] = taxon; } + setTaxonomyCache((previous) => ({ ...previous, ...newTaxonomyItems })); - _setTaxonomyCache((previous) => ({ ...previous, ...newTaxonomyItems })); + return taxonomies; }) - .catch(() => {}) - .finally(() => { - if (!isMounted()) { - return; - } + .catch(() => { + return null; }); + + for (const tsn of tsns) { + // Track the promise against each tsn, resolving the matching value for each tsn + _dispatchedTsnPromises.current[tsn] = fetchTaxonomiesPromise + .then((taxonomies) => { + if (!isMounted()) { + return null; + } + + if (!taxonomies) { + return null; + } + + // Return the taxon data for the current tsn + return taxonomies.find((taxonomy) => taxonomy.tsn === tsn) ?? null; + }) + .catch(() => { + return null; + }); + } + + return fetchTaxonomiesPromise; }, [biohubApi.taxonomy, isMounted] ); const getCachedSpeciesTaxonomyById = useCallback( - (id: number): ITaxonomy | null => { - if (hasProperty(_taxonomyCache, id)) { - // Taxonomy id was found in the cache, return cached data - return getProperty(_taxonomyCache, id); + (tsn: number): IPartialTaxonomy | null => { + if (hasProperty(taxonomyCache, tsn)) { + // Taxonomy tsn was found in the cache, return cached data + return getProperty(taxonomyCache, tsn); } - if (_dispatchedIds.current.has(id)) { - // Request to fetch this taxon id is still pending + if (_dispatchedTsnPromises.current[tsn] !== undefined) { + // Request to fetch this taxon tsn is still pending return null; } // Dispatch a request to fetch the taxonomy and cache the result - cacheSpeciesTaxonomyByIds([id]); + cacheSpeciesTaxonomyByIds([tsn]); return null; }, - [_taxonomyCache, cacheSpeciesTaxonomyByIds] + [taxonomyCache, cacheSpeciesTaxonomyByIds] + ); + + const getCachedSpeciesTaxonomyByIdAsync = useCallback( + async (tsn: number): Promise => { + if (_dispatchedTsnPromises.current[tsn] !== undefined) { + // Return pending promise for this taxon tsn + return _dispatchedTsnPromises.current[tsn]; + } + + // Dispatch a request to fetch the taxonomy and cache the result + await cacheSpeciesTaxonomyByIds([tsn]); + + return _dispatchedTsnPromises.current[tsn]; + }, + [cacheSpeciesTaxonomyByIds] ); const taxonomyContext: ITaxonomyContext = useMemo( () => ({ getCachedSpeciesTaxonomyById, + getCachedSpeciesTaxonomyByIdAsync, cacheSpeciesTaxonomyByIds }), - [cacheSpeciesTaxonomyByIds, getCachedSpeciesTaxonomyById] + [cacheSpeciesTaxonomyByIds, getCachedSpeciesTaxonomyById, getCachedSpeciesTaxonomyByIdAsync] ); return {props.children}; diff --git a/app/src/features/admin/users/UsersDetailPage.test.tsx b/app/src/features/admin/users/UsersDetailPage.test.tsx index 8e8c6c2af4..1de69e46d3 100644 --- a/app/src/features/admin/users/UsersDetailPage.test.tsx +++ b/app/src/features/admin/users/UsersDetailPage.test.tsx @@ -15,13 +15,16 @@ const mockBiohubApi = useBiohubApi as jest.Mock; const mockUseApi = { user: { - getUserById: jest.fn, []>() + deleteSystemUser: jest.fn, []>(), + getUserById: jest.fn, []>(), + getProjectList: jest.fn, []>() }, codes: { getAllCodeSets: jest.fn, []>() }, - project: { - getAllUserProjectsForView: jest.fn, []>() + projectParticipants: { + updateProjectParticipantRole: jest.fn, []>(), + removeProjectParticipant: jest.fn, []>() } }; @@ -63,7 +66,7 @@ describe('UsersDetailPage', () => { agency: '' }); - mockUseApi.project.getAllUserProjectsForView.mockResolvedValue({ + mockUseApi.user.getProjectList.mockResolvedValue({ project_participation_id: 3, project_id: 321, project_name: 'test', diff --git a/app/src/features/admin/users/UsersDetailProjects.test.tsx b/app/src/features/admin/users/UsersDetailProjects.test.tsx index 1ef44fbf13..4759fcb244 100644 --- a/app/src/features/admin/users/UsersDetailProjects.test.tsx +++ b/app/src/features/admin/users/UsersDetailProjects.test.tsx @@ -15,8 +15,8 @@ jest.mock('../../../hooks/useBioHubApi'); const mockBiohubApi = useBiohubApi as jest.Mock; const mockUseApi = { - project: { - getAllUserProjectsForView: jest.fn, []>() + user: { + getProjectList: jest.fn, []>() }, projectParticipants: { removeProjectParticipant: jest.fn, []>(), @@ -43,7 +43,7 @@ const mockUser: ISystemUser = { describe('UsersDetailProjects', () => { beforeEach(() => { mockBiohubApi.mockImplementation(() => mockUseApi); - mockUseApi.project.getAllUserProjectsForView.mockClear(); + mockUseApi.user.getProjectList.mockClear(); mockUseApi.codes.getAllCodeSets.mockClear(); }); @@ -72,7 +72,7 @@ describe('UsersDetailProjects', () => { coordinator_agency: [{ id: 1, name: 'agency 1' }] } as any); - mockUseApi.project.getAllUserProjectsForView.mockResolvedValue([]); + mockUseApi.user.getProjectList.mockResolvedValue([]); const { getByTestId, getAllByText } = render( @@ -94,7 +94,7 @@ describe('UsersDetailProjects', () => { project_roles: [{ id: 1, name: 'Coordinator' }] } as any); - mockUseApi.project.getAllUserProjectsForView.mockResolvedValue([ + mockUseApi.user.getProjectList.mockResolvedValue([ { project_participation_id: 4, project_id: 2, @@ -126,7 +126,7 @@ describe('UsersDetailProjects', () => { project_roles: [{ id: 1, name: 'Coordinator' }] } as any); - mockUseApi.project.getAllUserProjectsForView.mockResolvedValue([ + mockUseApi.user.getProjectList.mockResolvedValue([ { project_participation_id: 4, project_id: 1, @@ -168,7 +168,7 @@ describe('UsersDetailProjects', () => { project_roles: [{ id: 1, name: 'Coordinator' }] } as any); - mockUseApi.project.getAllUserProjectsForView.mockResolvedValue([ + mockUseApi.user.getProjectList.mockResolvedValue([ { project_participation_id: 4, project_id: 1, @@ -206,7 +206,7 @@ describe('UsersDetailProjects', () => { project_roles: [{ id: 1, name: 'Coordinator' }] } as any); - mockUseApi.project.getAllUserProjectsForView.mockResolvedValue([ + mockUseApi.user.getProjectList.mockResolvedValue([ { project_participation_id: 4, project_id: 1, @@ -253,7 +253,7 @@ describe('UsersDetailProjects', () => { mockUseApi.projectParticipants.removeProjectParticipant.mockResolvedValue(true); - mockUseApi.project.getAllUserProjectsForView.mockResolvedValue([ + mockUseApi.user.getProjectList.mockResolvedValue([ { project_participation_id: 4, project_id: 1, @@ -288,7 +288,7 @@ describe('UsersDetailProjects', () => { expect(getAllByText('secondProjectName').length).toEqual(1); }); - mockUseApi.project.getAllUserProjectsForView.mockResolvedValue([ + mockUseApi.user.getProjectList.mockResolvedValue([ { project_participation_id: 8, project_id: 5, @@ -328,7 +328,7 @@ describe('UsersDetailProjects', () => { ] } as any); - mockUseApi.project.getAllUserProjectsForView.mockResolvedValue([ + mockUseApi.user.getProjectList.mockResolvedValue([ { project_participation_id: 4, project_id: 1, @@ -372,7 +372,7 @@ describe('UsersDetailProjects', () => { ] } as any); - mockUseApi.project.getAllUserProjectsForView.mockResolvedValue([ + mockUseApi.user.getProjectList.mockResolvedValue([ { project_participation_id: 4, project_id: 1, @@ -430,7 +430,7 @@ describe('UsersDetailProjects', () => { ] } as any); - mockUseApi.project.getAllUserProjectsForView.mockResolvedValue([ + mockUseApi.user.getProjectList.mockResolvedValue([ { project_participation_id: 4, project_id: 1, diff --git a/app/src/features/admin/users/UsersDetailProjects.tsx b/app/src/features/admin/users/UsersDetailProjects.tsx index a60a689f99..7000e425e0 100644 --- a/app/src/features/admin/users/UsersDetailProjects.tsx +++ b/app/src/features/admin/users/UsersDetailProjects.tsx @@ -48,10 +48,10 @@ const UsersDetailProjects: React.FC = (props) => { const handleGetUserProjects = useCallback( async (systemUserId: number) => { - const userProjectsListResponse = await biohubApi.project.getAllUserProjectsForView(systemUserId); + const userProjectsListResponse = await biohubApi.user.getProjectList(systemUserId); setAssignedProjects(userProjectsListResponse); }, - [biohubApi.project] + [biohubApi.user] ); const refresh = () => handleGetUserProjects(userDetails.system_user_id); diff --git a/app/src/features/projects/ProjectsRouter.tsx b/app/src/features/projects/ProjectsRouter.tsx index 01422adf6d..fd59a5f8a4 100644 --- a/app/src/features/projects/ProjectsRouter.tsx +++ b/app/src/features/projects/ProjectsRouter.tsx @@ -15,7 +15,6 @@ import RouteWithTitle from 'utils/RouteWithTitle'; import { getTitle } from 'utils/Utils'; import CreateProjectPage from './create/CreateProjectPage'; import EditProjectPage from './edit/EditProjectPage'; -import ProjectsListPage from './list/ProjectsListPage'; import ProjectParticipantsPage from './participants/ProjectParticipantsPage'; /** @@ -26,11 +25,9 @@ import ProjectParticipantsPage from './participants/ProjectParticipantsPage'; const ProjectsRouter: React.FC = () => { return ( - {/* Project List Routes */} + {/* Summary Page Redirect */} - - - + {/* Create Project Route */} @@ -46,7 +43,7 @@ const ProjectsRouter: React.FC = () => { {/* Project Routes */} - + @@ -88,7 +85,7 @@ const ProjectsRouter: React.FC = () => { {/* Survey Routes */} - + { expect(history.location.pathname).toEqual('/admin/projects/create'); fireEvent.click(AreYouSureYesButton); - expect(history.location.pathname).toEqual('/admin/projects'); + expect(history.location.pathname).toEqual('/admin/summary'); }); it('does nothing if the user clicks `No`', async () => { diff --git a/app/src/features/projects/create/CreateProjectPage.tsx b/app/src/features/projects/create/CreateProjectPage.tsx index 9e63f23adf..5512dd0d2c 100644 --- a/app/src/features/projects/create/CreateProjectPage.tsx +++ b/app/src/features/projects/create/CreateProjectPage.tsx @@ -95,7 +95,7 @@ const CreateProjectPage = () => { }; const handleCancel = () => { - history.push('/admin/projects'); + history.push('/admin/summary'); }; /** diff --git a/app/src/features/projects/list/ProjectsListFilterForm.tsx b/app/src/features/projects/list/ProjectsListFilterForm.tsx deleted file mode 100644 index a455086b9d..0000000000 --- a/app/src/features/projects/list/ProjectsListFilterForm.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import Box from '@mui/material/Box'; -import Button from '@mui/material/Button'; -import Divider from '@mui/material/Divider'; -import Stack from '@mui/material/Stack'; -import ProjectAdvancedFilters, { - IProjectAdvancedFilters, - ProjectAdvancedFiltersInitialValues -} from 'components/search-filter/ProjectAdvancedFilters'; -import { Formik, FormikProps } from 'formik'; -import React, { useRef } from 'react'; - -export interface IProjectsListFilterFormProps { - handleSubmit: (filterValues: IProjectAdvancedFilters) => void; - handleReset: () => void; -} - -const ProjectsListFilterForm: React.FC = (props) => { - const formikRef = useRef>(null); - - return ( - <> - - - - - - - - - - - - - - ); -}; - -export default ProjectsListFilterForm; diff --git a/app/src/features/projects/list/ProjectsListPage.test.tsx b/app/src/features/projects/list/ProjectsListPage.test.tsx deleted file mode 100644 index 12154fed19..0000000000 --- a/app/src/features/projects/list/ProjectsListPage.test.tsx +++ /dev/null @@ -1,156 +0,0 @@ -import { AuthStateContext } from 'contexts/authStateContext'; -import { CodesContext, ICodesContext } from 'contexts/codesContext'; -import { createMemoryHistory } from 'history'; -import { useBiohubApi } from 'hooks/useBioHubApi'; -import { DataLoader } from 'hooks/useDataLoader'; -import { MemoryRouter, Router } from 'react-router-dom'; -import { getMockAuthState, SystemAdminAuthState } from 'test-helpers/auth-helpers'; -import { codes } from 'test-helpers/code-helpers'; -import { cleanup, fireEvent, render, waitFor } from 'test-helpers/test-utils'; -import ProjectsListPage from './ProjectsListPage'; - -const history = createMemoryHistory(); - -jest.mock('../../../hooks/useBioHubApi'); -const mockBiohubApi = useBiohubApi as jest.Mock; - -const mockUseApi = { - project: { - getProjectsList: jest.fn() - }, - codes: { - getAllCodeSets: jest.fn() - } -}; - -describe('ProjectsListPage', () => { - beforeEach(() => { - mockBiohubApi.mockImplementation(() => mockUseApi); - mockUseApi.project.getProjectsList.mockClear(); - }); - - afterEach(() => { - cleanup(); - }); - - it('renders with the create project button', async () => { - mockUseApi.project.getProjectsList.mockResolvedValue({ - projects: [], - pagination: { - current_page: 1, - last_page: 1, - total: 0 - } - }); - - const authState = getMockAuthState({ base: SystemAdminAuthState }); - - const mockCodesContext: ICodesContext = { - codesDataLoader: { - data: [], - load: jest.fn(), - refresh: jest.fn() - } as unknown as DataLoader, - surveyId: 1, - projectId: 1 - } as unknown as ICodesContext; - - const { getByText } = render( - - - - - - - - ); - - await waitFor(() => { - expect(getByText('Create Project')).toBeInTheDocument(); - }); - }); - - it('renders with the open advanced filters button', async () => { - mockUseApi.project.getProjectsList.mockResolvedValue({ - projects: [], - pagination: { - current_page: 1, - last_page: 1, - total: 0 - } - }); - - const authState = getMockAuthState({ base: SystemAdminAuthState }); - - const mockCodesContext: ICodesContext = { - codesDataLoader: { - data: [], - load: jest.fn(), - refresh: jest.fn() - } as unknown as DataLoader, - surveyId: 1, - projectId: 1 - } as unknown as ICodesContext; - - const { getByText } = render( - - - - - - - - ); - - await waitFor(() => { - expect(getByText('Show Filters')).toBeInTheDocument(); - }); - }); - - it('navigating to the project works', async () => { - mockUseApi.project.getProjectsList.mockResolvedValue({ - projects: [ - { - project_id: 1, - name: 'Project 1', - start_date: null, - end_date: null, - regions: ['region'] - } - ], - pagination: { - current_page: 1, - last_page: 1, - total: 1 - } - }); - - const authState = getMockAuthState({ base: SystemAdminAuthState }); - - const mockCodesContext: ICodesContext = { - codesDataLoader: { - data: codes, - load: jest.fn(), - refresh: jest.fn() - } as unknown as DataLoader, - surveyId: 1, - projectId: 1 - } as unknown as ICodesContext; - - const { findByText } = render( - - - - - - - - ); - - fireEvent.click(await findByText('Project 1')); - - await waitFor(() => { - expect(history.location.pathname).toEqual('/admin/projects/1'); - }); - }); -}); diff --git a/app/src/features/projects/list/ProjectsListPage.tsx b/app/src/features/projects/list/ProjectsListPage.tsx deleted file mode 100644 index 88589e08d3..0000000000 --- a/app/src/features/projects/list/ProjectsListPage.tsx +++ /dev/null @@ -1,229 +0,0 @@ -import { mdiFilterOutline, mdiPlus } from '@mdi/js'; -import Icon from '@mdi/react'; -import Box from '@mui/material/Box'; -import Button from '@mui/material/Button'; -import Collapse from '@mui/material/Collapse'; -import Container from '@mui/material/Container'; -import Divider from '@mui/material/Divider'; -import Link from '@mui/material/Link'; -import Paper from '@mui/material/Paper'; -import Stack from '@mui/material/Stack'; -import Toolbar from '@mui/material/Toolbar'; -import Typography from '@mui/material/Typography'; -import { GridColDef, GridPaginationModel, GridSortModel } from '@mui/x-data-grid'; -import ColouredRectangleChip from 'components/chips/ColouredRectangleChip'; -import { StyledDataGrid } from 'components/data-grid/StyledDataGrid'; -import PageHeader from 'components/layout/PageHeader'; -import { IProjectAdvancedFilters } from 'components/search-filter/ProjectAdvancedFilters'; -import { SystemRoleGuard } from 'components/security/Guards'; -import { DATE_FORMAT } from 'constants/dateTimeFormats'; -import { ListProjectsI18N } from 'constants/i18n'; -import { getNrmRegionColour, NRM_REGION_APPENDED_TEXT } from 'constants/regions'; -import { SYSTEM_ROLE } from 'constants/roles'; -import { APIError } from 'hooks/api/useAxios'; -import { useBiohubApi } from 'hooks/useBioHubApi'; -import { useCodesContext } from 'hooks/useContext'; -import useDataLoader from 'hooks/useDataLoader'; -import useDataLoaderError from 'hooks/useDataLoaderError'; -import { IProjectsListItemData } from 'interfaces/useProjectApi.interface'; -import { useEffect, useState } from 'react'; -import { Link as RouterLink } from 'react-router-dom'; -import { ApiPaginationRequestOptions } from 'types/misc'; -import { firstOrNull, getFormattedDate } from 'utils/Utils'; -import ProjectsListFilterForm from './ProjectsListFilterForm'; - -const pageSizeOptions = [10, 25, 50]; - -/** - * Page to display a list of projects. - * - * @return {*} - */ -const ProjectsListPage = () => { - const [paginationModel, setPaginationModel] = useState({ - page: 0, - pageSize: pageSizeOptions[0] - }); - - const [sortModel, setSortModel] = useState([]); - const [advancedFiltersModel, setAdvancedFiltersModel] = useState(undefined); - const [showAdvancedFilters, setShowAdvancedFilters] = useState(false); - - const biohubApi = useBiohubApi(); - - const codesContext = useCodesContext(); - - useEffect(() => { - codesContext.codesDataLoader.load(); - }, [codesContext.codesDataLoader]); - - const projectsDataLoader = useDataLoader( - (pagination: ApiPaginationRequestOptions, filter?: IProjectAdvancedFilters) => { - return biohubApi.project.getProjectsList(pagination, filter); - } - ); - - useDataLoaderError(projectsDataLoader, (dataLoader) => { - return { - dialogTitle: ListProjectsI18N.listProjectsErrorDialogTitle, - dialogText: ListProjectsI18N.listProjectsErrorDialogText, - dialogError: (dataLoader.error as APIError).message, - dialogErrorDetails: (dataLoader.error as APIError).errors - }; - }); - - const projectRows = projectsDataLoader.data?.projects ?? []; - - const columns: GridColDef[] = [ - { - field: 'name', - headerName: 'Name', - flex: 1, - disableColumnMenu: true, - renderCell: (params) => ( - - ) - }, - { - field: 'regions', - headerName: 'Regions', - type: 'string', - flex: 1, - renderCell: (params) => ( - - {params.row.regions.map((region) => { - const label = region.replace(NRM_REGION_APPENDED_TEXT, ''); - return ( - - ); - })} - - ) - }, - { - field: 'start_date', - headerName: 'Start Date', - minWidth: 150, - valueGetter: ({ value }) => (value ? new Date(value) : undefined), - valueFormatter: ({ value }) => (value ? getFormattedDate(DATE_FORMAT.ShortMediumDateFormat, value) : undefined) - }, - { - field: 'end_date', - headerName: 'End Date', - minWidth: 150, - valueGetter: ({ value }) => (value ? new Date(value) : undefined), - valueFormatter: ({ value }) => (value ? getFormattedDate(DATE_FORMAT.ShortMediumDateFormat, value) : undefined) - } - ]; - - // Refresh projects when pagination or sort changes - useEffect(() => { - const sort = firstOrNull(sortModel); - const pagination = { - limit: paginationModel.pageSize, - sort: sort?.field || undefined, - order: sort?.sort || undefined, - - // API pagination pages begin at 1, but MUI DataGrid pagination begins at 0. - page: paginationModel.page + 1 - }; - - projectsDataLoader.refresh(pagination, advancedFiltersModel); - - // Adding a DataLoader as a dependency causes an infinite rerender loop if a useEffect calls `.refresh` - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [sortModel, paginationModel, advancedFiltersModel]); - - /** - * Displays project list. - */ - return ( - <> - - -
- } - /> - - - - - - Records Found ‌ - - ({Number(projectsDataLoader.data?.pagination.total || 0).toLocaleString()}) - - - - - - - setAdvancedFiltersModel(undefined)} - /> - - - 'auto'} - getEstimatedRowHeight={() => 500} - rows={projectRows} - rowCount={projectsDataLoader.data?.pagination.total ?? 0} - getRowId={(row) => row.project_id} - columns={columns} - pageSizeOptions={[...pageSizeOptions]} - paginationMode="server" - sortingMode="server" - sortModel={sortModel} - paginationModel={paginationModel} - onPaginationModelChange={setPaginationModel} - onSortModelChange={setSortModel} - rowSelection={false} - checkboxSelection={false} - disableRowSelectionOnClick - disableColumnSelector - disableColumnFilter - disableColumnMenu - sortingOrder={['asc', 'desc']} - /> - - - - - ); -}; - -export default ProjectsListPage; diff --git a/app/src/features/projects/view/ProjectHeader.tsx b/app/src/features/projects/view/ProjectHeader.tsx index 60d2144ef7..943fc4138a 100644 --- a/app/src/features/projects/view/ProjectHeader.tsx +++ b/app/src/features/projects/view/ProjectHeader.tsx @@ -69,7 +69,7 @@ const ProjectHeader = () => { return; } - history.push(`/admin/projects`); + history.push(`/admin/summary`); } catch (error) { const apiError = error as APIError; showDeleteErrorDialog({ dialogErrorDetails: [apiError.message], open: true }); diff --git a/app/src/features/standards/SpeciesStandardsPage.tsx b/app/src/features/standards/SpeciesStandardsPage.tsx index b3f7b8a0af..881d08a1aa 100644 --- a/app/src/features/standards/SpeciesStandardsPage.tsx +++ b/app/src/features/standards/SpeciesStandardsPage.tsx @@ -3,7 +3,7 @@ import PageHeader from 'components/layout/PageHeader'; import SpeciesAutocompleteField from 'components/species/components/SpeciesAutocompleteField'; import { useBiohubApi } from 'hooks/useBioHubApi'; import useDataLoader from 'hooks/useDataLoader'; -import { ITaxonomy } from 'interfaces/useTaxonomyApi.interface'; +import { IPartialTaxonomy } from 'interfaces/useTaxonomyApi.interface'; import SpeciesStandardsResults from './view/SpeciesStandardsResults'; /** @@ -13,7 +13,7 @@ import SpeciesStandardsResults from './view/SpeciesStandardsResults'; */ const SpeciesStandardsPage = () => { const biohubApi = useBiohubApi(); - const standardsDataLoader = useDataLoader((species: ITaxonomy) => + const standardsDataLoader = useDataLoader((species: IPartialTaxonomy) => biohubApi.standards.getSpeciesStandards(species.tsn) ); diff --git a/app/src/features/summary/SummaryPage.tsx b/app/src/features/summary/SummaryPage.tsx new file mode 100644 index 0000000000..b70623d604 --- /dev/null +++ b/app/src/features/summary/SummaryPage.tsx @@ -0,0 +1,50 @@ +import { mdiPlus } from '@mdi/js'; +import Icon from '@mdi/react'; +import Button from '@mui/material/Button'; +import Container from '@mui/material/Container'; +import Paper from '@mui/material/Paper'; +import PageHeader from 'components/layout/PageHeader'; +import { SystemRoleGuard } from 'components/security/Guards'; +import { SYSTEM_ROLE } from 'constants/roles'; +import { ListDataTableContainer } from 'features/summary/list-data/ListDataTableContainer'; +import { TabularDataTableContainer } from 'features/summary/tabular-data/TabularDataTableContainer'; +import { Link as RouterLink } from 'react-router-dom'; + +/** + * Page to display a summary of a user's field data. + * + * @return {*} + */ +const SummaryPage = () => { + return ( + <> + + +
+ } + /> + + + + + + + + + + + ); +}; + +export default SummaryPage; diff --git a/app/src/features/summary/SummaryRouter.tsx b/app/src/features/summary/SummaryRouter.tsx new file mode 100644 index 0000000000..f0bc6acd12 --- /dev/null +++ b/app/src/features/summary/SummaryRouter.tsx @@ -0,0 +1,34 @@ +import { DialogContextProvider } from 'contexts/dialogContext'; +import { TaxonomyContextProvider } from 'contexts/taxonomyContext'; +import React from 'react'; +import { Redirect, Route, Switch } from 'react-router'; +import RouteWithTitle from 'utils/RouteWithTitle'; +import { getTitle } from 'utils/Utils'; +import SummaryPage from './SummaryPage'; + +/** + * Router for all `/admin/summary/*` pages. + * + * @return {*} + */ +const SummaryRouter: React.FC = () => { + return ( + + {/* Summary Routes */} + + + + + + + + + {/* Catch any unknown routes, and re-direct to the not found page */} + + + + + ); +}; + +export default SummaryRouter; diff --git a/app/src/features/summary/components/FilterFieldsContainer.tsx b/app/src/features/summary/components/FilterFieldsContainer.tsx new file mode 100644 index 0000000000..df6086cc96 --- /dev/null +++ b/app/src/features/summary/components/FilterFieldsContainer.tsx @@ -0,0 +1,59 @@ +import grey from '@mui/material/colors/grey'; +import Grid from '@mui/material/Grid'; +import { useFormikContext } from 'formik'; +import { useDeepCompareEffect } from 'hooks/useDeepCompareEffect'; +import { debounce } from 'lodash-es'; +import { PropsWithChildren, ReactElement, useMemo } from 'react'; + +interface ISearchFiltersProps { + fields: ReactElement[]; +} + +/** + * A component that wraps/renders the provided filter fields and triggers a form submit on change. + * + * @template FormikValues + * @param {PropsWithChildren} props + * @return {*} + */ +export const FilterFieldsContainer = >( + props: PropsWithChildren +) => { + const { fields } = props; + + const { values, submitForm } = useFormikContext(); + + const debouncedSubmitForm = useMemo(() => debounce(submitForm, 500), [submitForm]); + + useDeepCompareEffect(() => { + debouncedSubmitForm(); + }, [submitForm, values]); + + return ( + + {fields.map((field, index) => ( + + {field} + + ))} + + ); +}; diff --git a/app/src/features/summary/list-data/ListDataTableContainer.tsx b/app/src/features/summary/list-data/ListDataTableContainer.tsx new file mode 100644 index 0000000000..109bfad200 --- /dev/null +++ b/app/src/features/summary/list-data/ListDataTableContainer.tsx @@ -0,0 +1,110 @@ +import { mdiFolder, mdiListBoxOutline, mdiMagnify } from '@mdi/js'; +import Icon from '@mdi/react'; +import Button from '@mui/material/Button'; +import Divider from '@mui/material/Divider'; +import ToggleButton from '@mui/material/ToggleButton'; +import ToggleButtonGroup from '@mui/material/ToggleButtonGroup'; +import Toolbar from '@mui/material/Toolbar'; +import ProjectsListContainer from 'features/summary/list-data/project/ProjectsListContainer'; +import SurveysListContainer from 'features/summary/list-data/survey/SurveysListContainer'; +import { useSearchParams } from 'hooks/useSearchParams'; +import { useState } from 'react'; + +export const ACTIVE_VIEW_KEY = 'lvk'; +export enum ACTIVE_VIEW_VALUE { + projects = 'pv', + surveys = 'sv' +} + +export const SHOW_SEARCH_KEY = 'lvsk'; +export enum SHOW_SEARCH_VALUE { + true = 'true', + false = 'false' +} + +// Supported URL parameters +type ListDataTableURLParams = { + [ACTIVE_VIEW_KEY]: ACTIVE_VIEW_VALUE; + [SHOW_SEARCH_KEY]: SHOW_SEARCH_VALUE; +}; + +const buttonSx = { + py: 0.5, + px: 1.5, + border: 'none !important', + fontWeight: 700, + borderRadius: '4px !important', + fontSize: '0.875rem', + letterSpacing: '0.02rem' +}; + +/** + * Data table component for list data (ie: projects, surveys). + * + * @return {*} + */ +export const ListDataTableContainer = () => { + const { searchParams, setSearchParams } = useSearchParams(); + + const [activeView, setActiveView] = useState(searchParams.get(ACTIVE_VIEW_KEY) ?? ACTIVE_VIEW_VALUE.projects); + const [showSearch, setShowSearch] = useState(searchParams.get(SHOW_SEARCH_KEY) === SHOW_SEARCH_VALUE.true); + + const views = [ + { value: ACTIVE_VIEW_VALUE.projects, label: 'projects', icon: mdiFolder }, + { value: ACTIVE_VIEW_VALUE.surveys, label: 'surveys', icon: mdiListBoxOutline } + ]; + + const onChangeView = (_: React.MouseEvent, value: ACTIVE_VIEW_VALUE) => { + if (!value) { + // User has clicked the active view, do nothing + return; + } + + setSearchParams(searchParams.set(ACTIVE_VIEW_KEY, value)); + setActiveView(value); + }; + + return ( + <> + + + {views.map((view) => ( + } + value={view.value}> + {view.label} + + ))} + + + + + + {activeView === ACTIVE_VIEW_VALUE.projects && } + {activeView === ACTIVE_VIEW_VALUE.surveys && } + + ); +}; diff --git a/app/src/features/summary/list-data/project/ProjectsListContainer.tsx b/app/src/features/summary/list-data/project/ProjectsListContainer.tsx new file mode 100644 index 0000000000..8b24443bd4 --- /dev/null +++ b/app/src/features/summary/list-data/project/ProjectsListContainer.tsx @@ -0,0 +1,281 @@ +import Box from '@mui/material/Box'; +import Collapse from '@mui/material/Collapse'; +import grey from '@mui/material/colors/grey'; +import Divider from '@mui/material/Divider'; +import Link from '@mui/material/Link'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import { GridColDef, GridPaginationModel, GridSortDirection, GridSortModel } from '@mui/x-data-grid'; +import ColouredRectangleChip from 'components/chips/ColouredRectangleChip'; +import { StyledDataGrid } from 'components/data-grid/StyledDataGrid'; +import { SystemRoleGuard } from 'components/security/Guards'; +import { getNrmRegionColour, NrmRegionKeys } from 'constants/colours'; +import { NRM_REGION_APPENDED_TEXT } from 'constants/regions'; +import { SYSTEM_ROLE } from 'constants/roles'; +import { useBiohubApi } from 'hooks/useBioHubApi'; +import { useCodesContext, useTaxonomyContext } from 'hooks/useContext'; +import useDataLoader from 'hooks/useDataLoader'; +import { useDeepCompareEffect } from 'hooks/useDeepCompareEffect'; +import { useSearchParams } from 'hooks/useSearchParams'; +import { IProjectsListItemData } from 'interfaces/useProjectApi.interface'; +import { useEffect, useMemo, useState } from 'react'; +import { Link as RouterLink } from 'react-router-dom'; +import { ApiPaginationRequestOptions, StringValues } from 'types/misc'; +import { firstOrNull, getCodesName } from 'utils/Utils'; +import ProjectsListFilterForm, { + IProjectAdvancedFilters, + ProjectAdvancedFiltersInitialValues +} from './ProjectsListFilterForm'; + +// Supported URL parameters +// Note: Prefix 'p_' is used to avoid conflicts with similar query params from other components +type ProjectDataTableURLParams = { + // filter + p_keyword?: string; + p_itis_tsn?: number; + p_system_user_id?: string; + // pagination + p_page?: string; + p_limit?: string; + p_sort?: string; + p_order?: 'asc' | 'desc'; +}; + +const pageSizeOptions = [10, 25, 50]; + +interface IProjectsListContainerProps { + showSearch: boolean; +} + +// Default pagination parameters +const ApiPaginationRequestOptionsInitialValues: Required = { + page: 0, + limit: 10, + sort: 'project_id', + order: 'desc' +}; + +/** + * Displays a list of projects. + * + * @return {*} + */ +const ProjectsListContainer = (props: IProjectsListContainerProps) => { + const { showSearch } = props; + + const biohubApi = useBiohubApi(); + const codesContext = useCodesContext(); + const taxonomyContext = useTaxonomyContext(); + + const { searchParams, setSearchParams } = useSearchParams>(); + + useEffect(() => { + codesContext.codesDataLoader.load(); + }, [codesContext.codesDataLoader]); + + const [paginationModel, setPaginationModel] = useState({ + pageSize: Number(searchParams.get('p_limit') ?? ApiPaginationRequestOptionsInitialValues.limit), + page: Number(searchParams.get('p_page') ?? ApiPaginationRequestOptionsInitialValues.page) + }); + + const [sortModel, setSortModel] = useState([ + { + field: searchParams.get('p_sort') ?? ApiPaginationRequestOptionsInitialValues.sort, + sort: (searchParams.get('p_order') ?? ApiPaginationRequestOptionsInitialValues.order) as GridSortDirection + } + ]); + + const [advancedFiltersModel, setAdvancedFiltersModel] = useState({ + keyword: searchParams.get('p_keyword') ?? ProjectAdvancedFiltersInitialValues.keyword, + itis_tsn: searchParams.get('p_itis_tsn') + ? Number(searchParams.get('p_itis_tsn')) + : ProjectAdvancedFiltersInitialValues.itis_tsn, + system_user_id: searchParams.get('p_system_user_id') ?? ProjectAdvancedFiltersInitialValues.system_user_id + }); + + const sort = firstOrNull(sortModel); + const paginationSort: ApiPaginationRequestOptions = useMemo( + () => ({ + limit: paginationModel.pageSize, + sort: sort?.field || undefined, + order: sort?.sort || undefined, + page: paginationModel.page + 1 // API pagination pages begin at 1, but MUI DataGrid pagination begins at 0. + }), + [paginationModel.page, paginationModel.pageSize, sort?.field, sort?.sort] + ); + + const { refresh, isReady, data } = useDataLoader( + (pagination: ApiPaginationRequestOptions, filter?: IProjectAdvancedFilters) => + biohubApi.project.findProjects(pagination, filter) + ); + + // Fetch projects when either the pagination, sort, or advanced filters change + useDeepCompareEffect(() => { + refresh(paginationSort, advancedFiltersModel); + }, [advancedFiltersModel, paginationSort]); + + const rows = data?.projects ?? []; + + // Define the columns for the DataGrid + const columns: GridColDef[] = [ + { + field: 'project_id', + headerName: 'ID', + width: 70, + minWidth: 70, + renderHeader: () => ( + + ID + + ), + renderCell: (params) => ( + + {params.row.project_id} + + ) + }, + { + field: 'name', + headerName: 'Name', + flex: 1, + disableColumnMenu: true, + renderCell: (params) => { + const focalSpecies = params.row.focal_species + .map((species) => taxonomyContext.getCachedSpeciesTaxonomyById(species)?.commonNames) + .filter(Boolean) + .join(' \u2013 '); // n-dash with spaces + + const types = params.row.types + .map((type) => getCodesName(codesContext.codesDataLoader.data, 'type', type || 0)) + .filter(Boolean) + .join(' \u2013 '); // n-dash with spaces + + const detailsArray = [focalSpecies, types].filter(Boolean).join(' \u2013 '); + + return ( + + + {/* Only administrators see the second title to help them find Projects with a standard naming convention */} + + {detailsArray.length > 0 ? ( + + {detailsArray} + + ) : ( + + There are no Surveys in this Project + + )} + + + ); + } + }, + { + field: 'regions', + headerName: 'Region', + type: 'string', + flex: 0.4, + renderCell: (params) => ( + + {params.row.regions.map((region) => { + const label = region.replace(NRM_REGION_APPENDED_TEXT, ''); + return ( + + ); + })} + + ) + } + ]; + + return ( + <> + + + { + setSearchParams( + searchParams + .setOrDelete('p_keyword', values.keyword) + .setOrDelete('p_itis_tsn', values.itis_tsn) + .setOrDelete('p_system_user_id', values.system_user_id) + ); + setAdvancedFiltersModel(values); + }} + /> + + + + + row.project_id} + // Pagination + paginationMode="server" + paginationModel={paginationModel} + pageSizeOptions={pageSizeOptions} + onPaginationModelChange={(model) => { + if (!model) { + return; + } + setSearchParams(searchParams.set('p_page', String(model.page)).set('p_limit', String(model.pageSize))); + setPaginationModel(model); + }} + // Sorting + sortingMode="server" + sortModel={sortModel} + sortingOrder={['asc', 'desc']} + onSortModelChange={(model) => { + if (!model.length) { + return; + } + setSearchParams(searchParams.set('p_sort', model[0].field).set('p_order', model[0].sort ?? 'desc')); + setSortModel(model); + }} + // Row options + rowSelection={false} + checkboxSelection={false} + disableRowSelectionOnClick + // Column options + disableColumnSelector + disableColumnFilter + disableColumnMenu + // Styling + rowHeight={70} + getRowHeight={() => 'auto'} + autoHeight={false} + sx={{ + '& .MuiDataGrid-overlay': { + background: grey[50] + }, + '& .MuiDataGrid-cell': { + py: 0.75, + background: '#fff', + '&.MuiDataGrid-cell--editing:focus-within': { + outline: 'none' + } + } + }} + /> + + + ); +}; + +export default ProjectsListContainer; diff --git a/app/src/features/summary/list-data/project/ProjectsListFilterForm.tsx b/app/src/features/summary/list-data/project/ProjectsListFilterForm.tsx new file mode 100644 index 0000000000..83dceda5f0 --- /dev/null +++ b/app/src/features/summary/list-data/project/ProjectsListFilterForm.tsx @@ -0,0 +1,92 @@ +import CustomTextField from 'components/fields/CustomTextField'; +import { SystemUserAutocompleteField } from 'components/fields/SystemUserAutocompleteField'; +import SpeciesAutocompleteField from 'components/species/components/SpeciesAutocompleteField'; +import { FilterFieldsContainer } from 'features/summary/components/FilterFieldsContainer'; +import { Formik } from 'formik'; +import { useTaxonomyContext } from 'hooks/useContext'; + +export type IProjectAdvancedFilters = { + keyword?: string; + itis_tsn?: number; + system_user_id?: string; +}; + +export const ProjectAdvancedFiltersInitialValues: IProjectAdvancedFilters = { + keyword: undefined, + itis_tsn: undefined, + system_user_id: undefined +}; + +export interface IProjectsListFilterFormProps { + handleSubmit: (filterValues: IProjectAdvancedFilters) => void; + initialValues?: IProjectAdvancedFilters; +} + +/** + * Project advanced filters + * + * @param {IProjectsListFilterFormProps} props + * @return {*} + */ +const ProjectsListFilterForm = (props: IProjectsListFilterFormProps) => { + const { handleSubmit, initialValues } = props; + + const taxonomyContext = useTaxonomyContext(); + + return ( + + {(formikProps) => ( + , + { + if (value?.tsn) { + formikProps.setFieldValue('itis_tsn', value.tsn); + } + }} + handleClear={() => { + formikProps.setFieldValue('itis_tsn', undefined); + }} + key="project-taxon-filter" + />, + { + if (value?.system_user_id) { + formikProps.setFieldValue('system_user_id', value.system_user_id); + } + }} + onClear={() => { + formikProps.setFieldValue('system_user_id', undefined); + }} + key="project-user-filter" + /> + ]} + /> + )} + + ); +}; + +export default ProjectsListFilterForm; diff --git a/app/src/features/summary/list-data/survey/SurveysListContainer.tsx b/app/src/features/summary/list-data/survey/SurveysListContainer.tsx new file mode 100644 index 0000000000..0f9dccb619 --- /dev/null +++ b/app/src/features/summary/list-data/survey/SurveysListContainer.tsx @@ -0,0 +1,308 @@ +import Box from '@mui/material/Box'; +import Collapse from '@mui/material/Collapse'; +import grey from '@mui/material/colors/grey'; +import Divider from '@mui/material/Divider'; +import Link from '@mui/material/Link'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import { GridColDef, GridPaginationModel, GridSortDirection, GridSortModel } from '@mui/x-data-grid'; +import ColouredRectangleChip from 'components/chips/ColouredRectangleChip'; +import { StyledDataGrid } from 'components/data-grid/StyledDataGrid'; +import { SystemRoleGuard } from 'components/security/Guards'; +import { getNrmRegionColour, NrmRegionKeys } from 'constants/colours'; +import { DATE_FORMAT } from 'constants/dateTimeFormats'; +import { NRM_REGION_APPENDED_TEXT } from 'constants/regions'; +import { SYSTEM_ROLE } from 'constants/roles'; +import dayjs from 'dayjs'; +import { useBiohubApi } from 'hooks/useBioHubApi'; +import { useCodesContext, useTaxonomyContext } from 'hooks/useContext'; +import useDataLoader from 'hooks/useDataLoader'; +import { useDeepCompareEffect } from 'hooks/useDeepCompareEffect'; +import { useSearchParams } from 'hooks/useSearchParams'; +import { SurveyBasicFieldsObject } from 'interfaces/useSurveyApi.interface'; +import { useEffect, useState } from 'react'; +import { Link as RouterLink } from 'react-router-dom'; +import { ApiPaginationRequestOptions, StringValues } from 'types/misc'; +import { firstOrNull, getCodesName } from 'utils/Utils'; +import { SurveyProgressChip } from '../../../surveys/components/SurveyProgressChip'; +import SurveysListFilterForm, { + ISurveyAdvancedFilters, + SurveyAdvancedFiltersInitialValues +} from './SurveysListFilterForm'; + +// Supported URL parameters +// Note: Prefix 's_' is used to avoid conflicts with similar query params from other components +type SurveyDataTableURLParams = { + // filter + s_keyword?: string; + s_itis_tsn?: number; + s_system_user_id?: string; + // pagination + s_page?: string; + s_limit?: string; + s_sort?: string; + s_order?: 'asc' | 'desc'; +}; + +const pageSizeOptions = [10, 25, 50]; + +interface ISurveysListContainerProps { + showSearch: boolean; +} + +// Default pagination parameters +const initialPaginationParams: Required = { + page: 0, + limit: 10, + sort: 'survey_id', + order: 'desc' +}; + +/** + * Displays a list of surveys. + * + * @return {*} + */ +const SurveysListContainer = (props: ISurveysListContainerProps) => { + const { showSearch } = props; + + const biohubApi = useBiohubApi(); + const codesContext = useCodesContext(); + const taxonomyContext = useTaxonomyContext(); + + const { searchParams, setSearchParams } = useSearchParams>(); + + useEffect(() => { + codesContext.codesDataLoader.load(); + }, [codesContext.codesDataLoader]); + + const [paginationModel, setPaginationModel] = useState({ + pageSize: Number(searchParams.get('s_limit') ?? initialPaginationParams.limit), + page: Number(searchParams.get('s_page') ?? initialPaginationParams.page) + }); + + const [sortModel, setSortModel] = useState([ + { + field: searchParams.get('s_sort') ?? initialPaginationParams.sort, + sort: (searchParams.get('s_order') ?? initialPaginationParams.order) as GridSortDirection + } + ]); + + const [advancedFiltersModel, setAdvancedFiltersModel] = useState({ + keyword: searchParams.get('s_keyword') ?? SurveyAdvancedFiltersInitialValues.keyword, + itis_tsn: searchParams.get('s_itis_tsn') + ? Number(searchParams.get('s_itis_tsn')) + : SurveyAdvancedFiltersInitialValues.itis_tsn, + system_user_id: searchParams.get('s_system_user_id') ?? SurveyAdvancedFiltersInitialValues.system_user_id + }); + + const sort = firstOrNull(sortModel); + const paginationSort: ApiPaginationRequestOptions = { + limit: paginationModel.pageSize, + sort: sort?.field || undefined, + order: sort?.sort || undefined, + page: paginationModel.page + 1 // API pagination pages begin at 1, but MUI DataGrid pagination begins at 0. + }; + + const surveysDataLoader = useDataLoader((pagination?: ApiPaginationRequestOptions, filter?: ISurveyAdvancedFilters) => + biohubApi.survey.findSurveys(pagination, filter) + ); + + // Fetch projects when either the pagination, sort, or advanced filters change + useDeepCompareEffect(() => { + surveysDataLoader.refresh(paginationSort, advancedFiltersModel); + }, [advancedFiltersModel, paginationSort]); + + const rows = surveysDataLoader.data?.surveys ?? []; + + const columns: GridColDef[] = [ + { + field: 'survey_id', + headerName: 'ID', + width: 70, + minWidth: 70, + renderHeader: () => ( + + ID + + ), + renderCell: (params) => ( + + {params.row.survey_id} + + ) + }, + { + field: 'name', + headerName: 'Name', + flex: 1, + disableColumnMenu: true, + renderCell: (params) => { + const dates = [params.row.start_date?.split('-')[0], params.row.end_date?.split('-')[0]] + .filter(Boolean) + .join(' \u2013 '); // n-dash with spaces + + const focalSpecies = params.row.focal_species + .map((species) => taxonomyContext.getCachedSpeciesTaxonomyById(species)?.commonNames) + .filter(Boolean) + .join(' \u2013 '); // n-dash with spaces + + const types = params.row.types + .map((type) => getCodesName(codesContext.codesDataLoader.data, 'type', type || 0)) + .filter(Boolean) + .join(' \u2013 '); // n-dash with spaces + + const detailsArray = [dates, focalSpecies, types].filter(Boolean).join(' \u2013 '); + + return ( + + + {/* Only administrators see the second title to help them find Projects with a standard naming convention */} + + + {detailsArray} + + + + ); + } + }, + { + field: 'progress_id', + headerName: 'Progress', + flex: 0.2, + disableColumnMenu: true, + renderCell: (params) => + }, + { + field: 'start_date', + headerName: 'Start Date', + flex: 0.2, + disableColumnMenu: true, + renderCell: (params) => ( + {dayjs(params.row.start_date).format(DATE_FORMAT.MediumDateFormat)} + ) + }, + { + field: 'end_date', + headerName: 'End Date', + flex: 0.2, + disableColumnMenu: true, + renderCell: (params) => + params.row.end_date ? ( + {dayjs(params.row.end_date).format(DATE_FORMAT.MediumDateFormat)} + ) : ( + + None + + ) + }, + { + field: 'regions', + headerName: 'Region', + minWidth: 50, + flex: 0.3, + disableColumnMenu: true, + renderCell: (params) => ( + + {params.row.regions.map((region) => { + const label = region.replace(NRM_REGION_APPENDED_TEXT, ''); + return ( + + ); + })} + + ) + } + ]; + + return ( + <> + + + { + setSearchParams( + searchParams + .setOrDelete('s_keyword', values.keyword) + .setOrDelete('s_itis_tsn', values.itis_tsn) + .setOrDelete('s_system_user_id', values.system_user_id) + ); + setAdvancedFiltersModel(values); + }} + /> + + + + + row.survey_id} + // Pagination + paginationMode="server" + paginationModel={paginationModel} + pageSizeOptions={pageSizeOptions} + onPaginationModelChange={(model) => { + if (!model) { + return; + } + setSearchParams(searchParams.set('s_page', String(model.page)).set('s_limit', String(model.pageSize))); + setPaginationModel(model); + }} + // Sorting + sortingMode="server" + sortModel={sortModel} + sortingOrder={['asc', 'desc']} + onSortModelChange={(model) => { + if (!model.length) { + return; + } + setSearchParams(searchParams.set('s_sort', model[0].field).set('s_order', model[0].sort ?? 'desc')); + setSortModel(model); + }} + // Row options + checkboxSelection={false} + disableRowSelectionOnClick + rowSelection={false} + // Column options + disableColumnSelector + disableColumnFilter + disableColumnMenu + // Styling + rowHeight={70} + getRowHeight={() => 'auto'} + autoHeight={false} + sx={{ + '& .MuiDataGrid-overlay': { + background: grey[50] + }, + '& .MuiDataGrid-cell': { + py: 0.75, + background: '#fff', + '&.MuiDataGrid-cell--editing:focus-within': { + outline: 'none' + } + } + }} + /> + + + ); +}; + +export default SurveysListContainer; diff --git a/app/src/features/summary/list-data/survey/SurveysListFilterForm.tsx b/app/src/features/summary/list-data/survey/SurveysListFilterForm.tsx new file mode 100644 index 0000000000..d015ca82c1 --- /dev/null +++ b/app/src/features/summary/list-data/survey/SurveysListFilterForm.tsx @@ -0,0 +1,92 @@ +import CustomTextField from 'components/fields/CustomTextField'; +import { SystemUserAutocompleteField } from 'components/fields/SystemUserAutocompleteField'; +import SpeciesAutocompleteField from 'components/species/components/SpeciesAutocompleteField'; +import { FilterFieldsContainer } from 'features/summary/components/FilterFieldsContainer'; +import { Formik } from 'formik'; +import { useTaxonomyContext } from 'hooks/useContext'; + +export type ISurveyAdvancedFilters = { + keyword?: string; + itis_tsn?: number; + system_user_id?: string; +}; + +export const SurveyAdvancedFiltersInitialValues: ISurveyAdvancedFilters = { + keyword: undefined, + itis_tsn: undefined, + system_user_id: undefined +}; + +export interface ISurveysListFilterFormProps { + handleSubmit: (filterValues: ISurveyAdvancedFilters) => void; + initialValues?: ISurveyAdvancedFilters; +} + +/** + * Survey advanced filters + * + * @param {ISurveysListFilterFormProps} props + * @return {*} + */ +const SurveysListFilterForm = (props: ISurveysListFilterFormProps) => { + const { handleSubmit, initialValues } = props; + + const taxonomyContext = useTaxonomyContext(); + + return ( + + {(formikProps) => ( + , + { + if (value?.tsn) { + formikProps.setFieldValue('itis_tsn', value.tsn); + } + }} + handleClear={() => { + formikProps.setFieldValue('itis_tsn', undefined); + }} + key="survey-tsn-filter" + />, + { + if (value?.system_user_id) { + formikProps.setFieldValue('system_user_id', value.system_user_id); + } + }} + onClear={() => { + formikProps.setFieldValue('system_user_id', undefined); + }} + key="survey-user-filter" + /> + ]} + /> + )} + + ); +}; + +export default SurveysListFilterForm; diff --git a/app/src/features/summary/tabular-data/TabularDataTableContainer.tsx b/app/src/features/summary/tabular-data/TabularDataTableContainer.tsx new file mode 100644 index 0000000000..7f34f51b7d --- /dev/null +++ b/app/src/features/summary/tabular-data/TabularDataTableContainer.tsx @@ -0,0 +1,114 @@ +import { mdiEye, mdiMagnify, mdiPaw, mdiWifiMarker } from '@mdi/js'; +import Icon from '@mdi/react'; +import Button from '@mui/material/Button'; +import Divider from '@mui/material/Divider'; +import ToggleButton from '@mui/material/ToggleButton'; +import ToggleButtonGroup from '@mui/material/ToggleButtonGroup'; +import Toolbar from '@mui/material/Toolbar'; +import AnimalsListContainer from 'features/summary/tabular-data/animal/AnimalsListContainer'; +import ObservationsListContainer from 'features/summary/tabular-data/observation/ObservationsListContainer'; +import TelemetryListContainer from 'features/summary/tabular-data/telemetry/TelemetryListContainer'; +import { useSearchParams } from 'hooks/useSearchParams'; +import { useState } from 'react'; + +export const ACTIVE_VIEW_KEY = 'tavk'; +export enum ACTIVE_VIEW_VALUE { + observations = 'ov', + telemetry = 'tv', + animals = 'av' +} + +export const SHOW_SEARCH_KEY = 'tssk'; +export enum SHOW_SEARCH_VALUE { + true = 'true', + false = 'false' +} + +// Supported URL parameters +type TabularDataTableURLParams = { + [ACTIVE_VIEW_KEY]: ACTIVE_VIEW_VALUE; + [SHOW_SEARCH_KEY]: SHOW_SEARCH_VALUE; +}; + +const buttonSx = { + py: 0.5, + px: 1.5, + border: 'none', + fontWeight: 700, + borderRadius: '4px !important', + fontSize: '0.875rem', + letterSpacing: '0.02rem' +}; + +/** + * Data table component for tabular data (ie: observations, animals, telemetry). + * + * @return {*} + */ +export const TabularDataTableContainer = () => { + const { searchParams, setSearchParams } = useSearchParams(); + + const [activeView, setActiveView] = useState(searchParams.get(ACTIVE_VIEW_KEY) ?? ACTIVE_VIEW_VALUE.observations); + const [showSearch, setShowSearch] = useState(searchParams.get(SHOW_SEARCH_KEY) === SHOW_SEARCH_VALUE.true); + + const views = [ + { value: ACTIVE_VIEW_VALUE.observations, label: 'observations', icon: mdiEye }, + { value: ACTIVE_VIEW_VALUE.animals, label: 'animals', icon: mdiPaw }, + { value: ACTIVE_VIEW_VALUE.telemetry, label: 'telemetry', icon: mdiWifiMarker } + ]; + + const onChangeView = (_: React.MouseEvent, value: ACTIVE_VIEW_VALUE) => { + if (!value) { + // User has clicked the active view, do nothing + return; + } + + setSearchParams(searchParams.set(ACTIVE_VIEW_KEY, value)); + setActiveView(value); + }; + + return ( + <> + + + {views.map((view) => ( + } + value={view.value}> + {view.label} + + ))} + + + + + + {activeView === ACTIVE_VIEW_VALUE.observations && } + {activeView === ACTIVE_VIEW_VALUE.animals && } + {activeView === ACTIVE_VIEW_VALUE.telemetry && } + + ); +}; diff --git a/app/src/features/summary/tabular-data/animal/AnimalsListContainer.tsx b/app/src/features/summary/tabular-data/animal/AnimalsListContainer.tsx new file mode 100644 index 0000000000..fc53dd8b74 --- /dev/null +++ b/app/src/features/summary/tabular-data/animal/AnimalsListContainer.tsx @@ -0,0 +1,217 @@ +import Box from '@mui/material/Box'; +import Collapse from '@mui/material/Collapse'; +import grey from '@mui/material/colors/grey'; +import Divider from '@mui/material/Divider'; +import Typography from '@mui/material/Typography'; +import { GridColDef, GridPaginationModel, GridSortDirection, GridSortModel } from '@mui/x-data-grid'; +import { StyledDataGrid } from 'components/data-grid/StyledDataGrid'; +import { useBiohubApi } from 'hooks/useBioHubApi'; +import useDataLoader from 'hooks/useDataLoader'; +import { useDeepCompareEffect } from 'hooks/useDeepCompareEffect'; +import { useSearchParams } from 'hooks/useSearchParams'; +import { IFindAnimalObj } from 'interfaces/useAnimalApi.interface'; +import { useState } from 'react'; +import { ApiPaginationRequestOptions, StringValues } from 'types/misc'; +import { firstOrNull } from 'utils/Utils'; +import AnimalsListFilterForm, { + AnimalsAdvancedFiltersInitialValues, + IAnimalsAdvancedFilters +} from './AnimalsListFilterForm'; + +// Supported URL parameters +// Note: Prefix 'a_' is used to avoid conflicts with similar query params from other components +type AnimalDataTableURLParams = { + // filter + a_itis_tsn?: string; + // pagination + a_page?: string; + a_limit?: string; + a_sort?: string; + a_order?: 'asc' | 'desc'; +}; + +const pageSizeOptions = [10, 25, 50]; + +interface IAnimalsListContainerProps { + showSearch: boolean; +} + +// Default pagination parameters +const initialPaginationParams: ApiPaginationRequestOptions = { + page: 0, + limit: 10, + sort: undefined, + order: undefined +}; + +/** + * Displays a list of animals (critters). + * + * @return {*} + */ +const AnimalsListContainer = (props: IAnimalsListContainerProps) => { + const { showSearch } = props; + + const biohubApi = useBiohubApi(); + + const { searchParams, setSearchParams } = useSearchParams>(); + + const [paginationModel, setPaginationModel] = useState({ + pageSize: Number(searchParams.get('a_limit') ?? initialPaginationParams.limit), + page: Number(searchParams.get('a_page') ?? initialPaginationParams.page) + }); + + const [sortModel, setSortModel] = useState([ + { + field: searchParams.get('a_sort') ?? initialPaginationParams.sort ?? '', + sort: (searchParams.get('a_order') ?? initialPaginationParams.order) as GridSortDirection + } + ]); + + const [advancedFiltersModel, setAdvancedFiltersModel] = useState({ + itis_tsn: searchParams.get('a_itis_tsn') + ? Number(searchParams.get('a_itis_tsn')) + : AnimalsAdvancedFiltersInitialValues.itis_tsn + }); + + const sort = firstOrNull(sortModel); + const paginationSort: ApiPaginationRequestOptions = { + limit: paginationModel.pageSize, + sort: sort?.field || undefined, + order: sort?.sort || undefined, + page: paginationModel.page + 1 // API pagination pages begin at 1, but MUI DataGrid pagination begins at 0. + }; + + const animalsDataLoader = useDataLoader( + (pagination?: ApiPaginationRequestOptions, filter?: IAnimalsAdvancedFilters) => + biohubApi.animal.findAnimals(pagination, filter) + ); + + useDeepCompareEffect(() => { + animalsDataLoader.refresh(paginationSort, advancedFiltersModel); + }, [advancedFiltersModel, paginationSort]); + + const animalRows = animalsDataLoader.data?.animals ?? []; + + const columns: GridColDef[] = [ + { + field: 'critter_id', + headerName: 'ID', + width: 70, + minWidth: 70, + sortable: false, + renderHeader: () => ( + + ID + + ), + renderCell: (params) => ( + + {params.row.critter_id} + + ) + }, + { field: 'animal_id', headerName: 'Nickname', flex: 1, sortable: false }, + { + field: 'itis_scientific_name', + headerName: 'Species', + flex: 1, + sortable: false, + renderCell: (params) => ( + 1 ? 'italic' : 'normal'}> + {params.row.itis_scientific_name} + + ) + }, + { + field: 'wlh_id', + headerName: 'Wildlife Health ID', + flex: 1, + sortable: false, + renderCell: (params) => + params.row.wlh_id ? ( + {params.row.wlh_id} + ) : ( + None + ) + }, + { field: 'critterbase_critter_id', headerName: 'Unique ID', flex: 1, sortable: false } + ]; + + return ( + <> + + + { + setSearchParams(searchParams.setOrDelete('a_itis_tsn', values.itis_tsn)); + setAdvancedFiltersModel(values); + }} + /> + + + + + row.critter_id} + // Pagination + paginationMode="server" + pageSizeOptions={pageSizeOptions} + paginationModel={paginationModel} + onPaginationModelChange={(model) => { + if (!model) { + return; + } + setSearchParams(searchParams.set('a_page', String(model.page)).set('a_limit', String(model.pageSize))); + setPaginationModel(model); + }} + // Sorting + sortingMode="server" + sortModel={sortModel} + sortingOrder={['asc', 'desc']} + onSortModelChange={(model) => { + if (!model.length) { + return; + } + setSearchParams(searchParams.set('a_sort', model[0].field).set('a_order', model[0].sort ?? 'desc')); + setSortModel(model); + }} + // Row options + checkboxSelection={false} + disableRowSelectionOnClick + rowSelection={false} + // Column options + disableColumnSelector + disableColumnFilter + disableColumnMenu + // Styling + rowHeight={70} + getRowHeight={() => 'auto'} + autoHeight={false} + sx={{ + '& .MuiDataGrid-overlay': { + background: grey[50] + }, + '& .MuiDataGrid-cell': { + py: 0.75, + background: '#fff', + '&.MuiDataGrid-cell--editing:focus-within': { + outline: 'none' + } + } + }} + /> + + + ); +}; + +export default AnimalsListContainer; diff --git a/app/src/features/summary/tabular-data/animal/AnimalsListFilterForm.tsx b/app/src/features/summary/tabular-data/animal/AnimalsListFilterForm.tsx new file mode 100644 index 0000000000..d8ee5f85d3 --- /dev/null +++ b/app/src/features/summary/tabular-data/animal/AnimalsListFilterForm.tsx @@ -0,0 +1,75 @@ +import CustomTextField from 'components/fields/CustomTextField'; +import SpeciesAutocompleteField from 'components/species/components/SpeciesAutocompleteField'; +import { FilterFieldsContainer } from 'features/summary/components/FilterFieldsContainer'; +import { Formik } from 'formik'; +import { useTaxonomyContext } from 'hooks/useContext'; + +export type IAnimalsAdvancedFilters = { + itis_tsn?: number; +}; + +export const AnimalsAdvancedFiltersInitialValues: IAnimalsAdvancedFilters = { + itis_tsn: undefined +}; + +export interface IAnimalsListFilterFormProps { + handleSubmit: (filterValues: IAnimalsAdvancedFilters) => void; + initialValues?: IAnimalsAdvancedFilters; +} + +/** + * Animal advanced filters + * + * TODO: The filter fields are disabled for now. The fields are functional (the values are captured and passed to the + * backend), but the backend does not currently use them for filtering. + * + * @param {IAnimalsListFilterFormProps} props + * @return {*} + */ +const AnimalsListFilterForm = (props: IAnimalsListFilterFormProps) => { + const { handleSubmit, initialValues } = props; + + const taxonomyContext = useTaxonomyContext(); + + return ( + + {(formikProps) => ( + , + { + if (value?.tsn) { + formikProps.setFieldValue('itis_tsns', value.tsn); + } + }} + handleClear={() => { + formikProps.setFieldValue('itis_tsns', undefined); + }} + disabled={true} // See TODO + key="animal-tsn-filter" + /> + ]} + /> + )} + + ); +}; + +export default AnimalsListFilterForm; diff --git a/app/src/features/summary/tabular-data/observation/ObservationsListContainer.tsx b/app/src/features/summary/tabular-data/observation/ObservationsListContainer.tsx new file mode 100644 index 0000000000..4b4667a275 --- /dev/null +++ b/app/src/features/summary/tabular-data/observation/ObservationsListContainer.tsx @@ -0,0 +1,312 @@ +import Box from '@mui/material/Box'; +import Collapse from '@mui/material/Collapse'; +import grey from '@mui/material/colors/grey'; +import Divider from '@mui/material/Divider'; +import Typography from '@mui/material/Typography'; +import { GridColDef, GridPaginationModel, GridSortDirection, GridSortModel } from '@mui/x-data-grid'; +import { StyledDataGrid } from 'components/data-grid/StyledDataGrid'; +import { DATE_FORMAT } from 'constants/dateTimeFormats'; +import { IObservationTableRow } from 'contexts/observationsTableContext'; +import dayjs from 'dayjs'; +import { useBiohubApi } from 'hooks/useBioHubApi'; +import { useCodesContext } from 'hooks/useContext'; +import useDataLoader from 'hooks/useDataLoader'; +import { useDeepCompareEffect } from 'hooks/useDeepCompareEffect'; +import { useSearchParams } from 'hooks/useSearchParams'; +import { IGetSurveyObservationsResponse } from 'interfaces/useObservationApi.interface'; +import { useCallback, useEffect, useState } from 'react'; +import { ApiPaginationRequestOptions, StringValues } from 'types/misc'; +import { firstOrNull } from 'utils/Utils'; +import { + IObservationsAdvancedFilters, + ObservationAdvancedFiltersInitialValues, + ObservationsListFilterForm +} from './ObservationsListFilterForm'; + +// Supported URL parameters +// Note: Prefix 'o_' is used to avoid conflicts with similar query params from other components +type ObservationDataTableURLParams = { + // filter + o_keyword?: string; + o_itis_tsn?: number; + o_start_date?: string; + o_end_date?: string; + o_start_time?: string; + o_end_time?: string; + o_min_count?: string; + o_system_user_id?: number; + // pagination + o_page?: string; + o_limit?: string; + o_sort?: string; + o_order?: 'asc' | 'desc'; +}; + +const pageSizeOptions = [10, 25, 50]; + +interface IObservationsListContainerProps { + showSearch: boolean; +} + +// Default pagination parameters +const initialPaginationParams: Required = { + page: 0, + limit: 10, + sort: 'survey_observation_id', + order: 'desc' +}; + +/** + * Displays a list of observations. + * + * @return {*} + */ +const ObservationsListContainer = (props: IObservationsListContainerProps) => { + const { showSearch } = props; + + const biohubApi = useBiohubApi(); + const codesContext = useCodesContext(); + + const { searchParams, setSearchParams } = useSearchParams>(); + + useEffect(() => { + codesContext.codesDataLoader.load(); + }, [codesContext.codesDataLoader]); + + const [paginationModel, setPaginationModel] = useState({ + pageSize: Number(searchParams.get('o_limit') ?? initialPaginationParams.limit), + page: Number(searchParams.get('o_page') ?? initialPaginationParams.page) + }); + + const [sortModel, setSortModel] = useState([ + { + field: searchParams.get('o_sort') ?? initialPaginationParams.sort, + sort: (searchParams.get('o_order') ?? initialPaginationParams.order) as GridSortDirection + } + ]); + + const [advancedFiltersModel, setAdvancedFiltersModel] = useState({ + keyword: searchParams.get('o_keyword') ?? ObservationAdvancedFiltersInitialValues.keyword, + itis_tsn: searchParams.get('o_itis_tsn') + ? Number(searchParams.get('o_itis_tsn')) + : ObservationAdvancedFiltersInitialValues.itis_tsn, + start_date: searchParams.get('o_start_date') ?? ObservationAdvancedFiltersInitialValues.start_date, + end_date: searchParams.get('o_end_date') ?? ObservationAdvancedFiltersInitialValues.end_date, + start_time: searchParams.get('o_start_time') ?? ObservationAdvancedFiltersInitialValues.start_time, + end_time: searchParams.get('o_end_time') ?? ObservationAdvancedFiltersInitialValues.end_time, + min_count: searchParams.get('o_min_count') ?? ObservationAdvancedFiltersInitialValues.min_count, + system_user_id: searchParams.get('o_system_user_id') + ? Number(searchParams.get('o_system_user_id')) + : ObservationAdvancedFiltersInitialValues.system_user_id + }); + + const sort = firstOrNull(sortModel); + const paginationSort: ApiPaginationRequestOptions = { + limit: paginationModel.pageSize, + sort: sort?.field || undefined, + order: sort?.sort || undefined, + page: paginationModel.page + 1 // API pagination pages begin at 1, but MUI DataGrid pagination begins at 0. + }; + + const observationsDataLoader = useDataLoader( + (pagination: ApiPaginationRequestOptions, filter?: IObservationsAdvancedFilters) => { + return biohubApi.observation.findObservations(pagination, filter); + } + ); + + useDeepCompareEffect(() => { + observationsDataLoader.refresh(paginationSort, advancedFiltersModel); + }, [advancedFiltersModel, paginationSort]); + + const getRowsFromObservations = useCallback( + (observationsData: IGetSurveyObservationsResponse): IObservationTableRow[] => + observationsData.surveyObservations?.flatMap((observationRow) => { + return observationRow.subcounts.map((subcountRow) => { + return { + // Spread the standard observation row data into the row + id: String(observationRow.survey_observation_id), + ...observationRow, + + // Spread the subcount row data into the row + observation_subcount_id: subcountRow.observation_subcount_id, + // Reduce the array of qualitative measurements into an object and spread into the row + ...subcountRow.qualitative_measurements.reduce((acc, cur) => { + return { + ...acc, + [cur.critterbase_taxon_measurement_id]: cur.critterbase_measurement_qualitative_option_id + }; + }, {}), + // Reduce the array of quantitative measurements into an object and spread into the row + ...subcountRow.quantitative_measurements.reduce((acc, cur) => { + return { + ...acc, + [cur.critterbase_taxon_measurement_id]: cur.value + }; + }, {}), + // Reduce the array of qualitative environments into an object and spread into the row + ...subcountRow.qualitative_environments.reduce((acc, cur) => { + return { + ...acc, + [cur.environment_qualitative_id]: cur.environment_qualitative_option_id + }; + }, {}), + // Reduce the array of quantitative environments into an object and spread into the row + ...subcountRow.quantitative_environments.reduce((acc, cur) => { + return { + ...acc, + [cur.environment_quantitative_id]: cur.value + }; + }, {}) + }; + }); + }), + [] + ); + + const observationRows = observationsDataLoader.data ? getRowsFromObservations(observationsDataLoader.data) : []; + + const columns: GridColDef[] = [ + { + field: 'survey_observation_id', + headerName: 'ID', + width: 70, + minWidth: 70, + renderHeader: () => ( + + ID + + ), + renderCell: (params) => ( + + {params.row.survey_observation_id} + + ) + }, + { + field: 'itis_scientific_name', + headerName: 'Species', + flex: 1, + renderCell: (params) => ( + 1 + ? 'italic' + : 'normal' + }> + {params.row.itis_scientific_name} + + ) + }, + { + field: 'count', + headerName: 'Count', + flex: 1 + }, + { + field: 'observation_date', + headerName: 'Date', + flex: 1, + renderCell: (params) => ( + + {dayjs(params.row.observation_date).format(DATE_FORMAT.MediumDateFormat)} + + ) + }, + { + field: 'observation_time', + headerName: 'Time', + flex: 1, + renderCell: (params) => {params.row.observation_time} + }, + { field: 'latitude', headerName: 'Latitude', flex: 1 }, + { field: 'longitude', headerName: 'Longitude', flex: 1 } + ]; + + return ( + <> + + + { + setSearchParams( + searchParams + .setOrDelete('o_start_date', values.start_date) + .setOrDelete('o_end_date', values.end_date) + .setOrDelete('o_keyword', values.keyword) + .setOrDelete('o_min_count', values.min_count) + .setOrDelete('o_start_time', values.start_time) + .setOrDelete('o_end_time', values.end_time) + .setOrDelete('o_system_user_id', values.system_user_id) + .setOrDelete('o_itis_tsn', values.itis_tsn) + ); + setAdvancedFiltersModel(values); + }} + /> + + + + + row.observation_subcount_id} + // Pagination + paginationMode="server" + pageSizeOptions={pageSizeOptions} + paginationModel={paginationModel} + onPaginationModelChange={(model) => { + if (!model) { + return; + } + setSearchParams(searchParams.set('o_page', String(model.page)).set('o_limit', String(model.pageSize))); + setPaginationModel(model); + }} + // Sorting + sortingMode="server" + sortModel={sortModel} + sortingOrder={['asc', 'desc']} + onSortModelChange={(model) => { + if (!model.length) { + return; + } + setSearchParams(searchParams.set('o_sort', model[0].field).set('o_order', model[0].sort ?? 'desc')); + setSortModel(model); + }} + // Row options + checkboxSelection={false} + disableRowSelectionOnClick + rowSelection={false} + // Column options + disableColumnSelector + disableColumnFilter + disableColumnMenu + // Styling + rowHeight={70} + getRowHeight={() => 'auto'} + autoHeight={false} + sx={{ + '& .MuiDataGrid-overlay': { + background: grey[50] + }, + '& .MuiDataGrid-cell': { + py: 0.75, + background: '#fff', + '&.MuiDataGrid-cell--editing:focus-within': { + outline: 'none' + } + } + }} + /> + + + ); +}; + +export default ObservationsListContainer; diff --git a/app/src/features/summary/tabular-data/observation/ObservationsListFilterForm.tsx b/app/src/features/summary/tabular-data/observation/ObservationsListFilterForm.tsx new file mode 100644 index 0000000000..363a338c6d --- /dev/null +++ b/app/src/features/summary/tabular-data/observation/ObservationsListFilterForm.tsx @@ -0,0 +1,84 @@ +import CustomTextField from 'components/fields/CustomTextField'; +import SingleDateField from 'components/fields/SingleDateField'; +import SpeciesAutocompleteField from 'components/species/components/SpeciesAutocompleteField'; +import { FilterFieldsContainer } from 'features/summary/components/FilterFieldsContainer'; +import { Formik } from 'formik'; +import { useTaxonomyContext } from 'hooks/useContext'; + +export type IObservationsAdvancedFilters = { + keyword?: string; + itis_tsn?: number; + start_date?: string; + end_date?: string; + start_time?: string; + end_time?: string; + min_count?: string; + system_user_id?: number; +}; + +export const ObservationAdvancedFiltersInitialValues: IObservationsAdvancedFilters = { + keyword: undefined, + itis_tsn: undefined, + start_date: undefined, + end_date: undefined, + start_time: undefined, + end_time: undefined, + min_count: undefined, + system_user_id: undefined +}; + +export interface IObservationsListFilterFormProps { + handleSubmit: (filterValues: IObservationsAdvancedFilters) => void; + initialValues?: IObservationsAdvancedFilters; +} + +/** + * Observation advanced filters + * + * @param {IObservationsListFilterFormProps} props + * @return {*} + */ +export const ObservationsListFilterForm = (props: IObservationsListFilterFormProps) => { + const { handleSubmit, initialValues } = props; + + const taxonomyContext = useTaxonomyContext(); + + return ( + + {(formikProps) => ( + , + { + if (value?.tsn) { + formikProps.setFieldValue('itis_tsn', value.tsn); + } + }} + handleClear={() => { + formikProps.setFieldValue('itis_tsn', undefined); + }} + key="observations-tsn-filter" + />, + + , + + ]} + /> + )} + + ); +}; diff --git a/app/src/features/summary/tabular-data/telemetry/TelemetryListContainer.tsx b/app/src/features/summary/tabular-data/telemetry/TelemetryListContainer.tsx new file mode 100644 index 0000000000..843ed8ffa1 --- /dev/null +++ b/app/src/features/summary/tabular-data/telemetry/TelemetryListContainer.tsx @@ -0,0 +1,221 @@ +import Box from '@mui/material/Box'; +import Collapse from '@mui/material/Collapse'; +import grey from '@mui/material/colors/grey'; +import Divider from '@mui/material/Divider'; +import Typography from '@mui/material/Typography'; +import { GridColDef, GridPaginationModel, GridSortDirection, GridSortModel } from '@mui/x-data-grid'; +import { StyledDataGrid } from 'components/data-grid/StyledDataGrid'; +import { DATE_FORMAT } from 'constants/dateTimeFormats'; +import dayjs from 'dayjs'; +import { useBiohubApi } from 'hooks/useBioHubApi'; +import useDataLoader from 'hooks/useDataLoader'; +import { useDeepCompareEffect } from 'hooks/useDeepCompareEffect'; +import { useSearchParams } from 'hooks/useSearchParams'; +import { IFindTelementryObj } from 'interfaces/useTelemetryApi.interface'; +import { useState } from 'react'; +import { ApiPaginationRequestOptions, StringValues } from 'types/misc'; +import { firstOrNull } from 'utils/Utils'; +import TelemetryListFilterForm, { + ITelemetryAdvancedFilters, + TelemetryAdvancedFiltersInitialValues +} from './TelemetryListFilterForm'; + +// Supported URL parameters +// Note: Prefix 't_' is used to avoid conflicts with similar query params from other components +type TelemetryDataTableURLParams = { + // filter + t_itis_tsn?: string; + // pagination + t_page?: string; + t_limit?: string; + t_sort?: string; + t_order?: 'asc' | 'desc'; +}; + +const pageSizeOptions = [10, 25, 50]; + +interface ITelemetryListContainerProps { + showSearch: boolean; +} + +// Default pagination parameters +const initialPaginationParams: ApiPaginationRequestOptions = { + page: 0, + limit: 10, + sort: undefined, + order: undefined +}; + +/** + * Displays a list of telemtry. + * + * @return {*} + */ +const TelemetryListContainer = (props: ITelemetryListContainerProps) => { + const { showSearch } = props; + + const biohubApi = useBiohubApi(); + + const { searchParams, setSearchParams } = useSearchParams>(); + + const [paginationModel, setPaginationModel] = useState({ + pageSize: Number(searchParams.get('t_limit') ?? initialPaginationParams.limit), + page: Number(searchParams.get('t_page') ?? initialPaginationParams.page) + }); + + const [sortModel, setSortModel] = useState([ + { + field: searchParams.get('t_sort') ?? initialPaginationParams.sort ?? '', + sort: (searchParams.get('t_order') ?? initialPaginationParams.order) as GridSortDirection + } + ]); + + const [advancedFiltersModel, setAdvancedFiltersModel] = useState({ + itis_tsn: searchParams.get('t_itis_tsn') + ? Number(searchParams.get('t_itis_tsn')) + : TelemetryAdvancedFiltersInitialValues.itis_tsn + }); + + const sort = firstOrNull(sortModel); + const paginationSort: ApiPaginationRequestOptions = { + limit: paginationModel.pageSize, + sort: sort?.field || undefined, + order: sort?.sort || undefined, + page: paginationModel.page + 1 // API pagination pages begin at 1, but MUI DataGrid pagination begins at 0. + }; + + const telemetryDataLoader = useDataLoader( + (pagination?: ApiPaginationRequestOptions, filter?: ITelemetryAdvancedFilters) => + biohubApi.telemetry.findTelemetry(pagination, filter) + ); + + useDeepCompareEffect(() => { + telemetryDataLoader.refresh(paginationSort, advancedFiltersModel); + }, [advancedFiltersModel, paginationSort]); + + const telemetryRows = telemetryDataLoader.data?.telemetry ?? []; + + const columns: GridColDef[] = [ + { + field: 'telemetry_id', + headerName: 'ID', + width: 50, + minWidth: 50, + sortable: false, + renderHeader: () => ( + + ID + + ), + renderCell: (params) => ( + + {params.row.telemetry_id} + + ) + }, + { + field: 'animal_id', + headerName: 'Nickname', + flex: 1, + sortable: false, + renderCell: (params) => {params.row.animal_id} + }, + { + field: 'device_id', + headerName: 'Device', + flex: 1, + sortable: false, + renderCell: (params) => {params.row.device_id} + }, + { + field: 'acquisition_date', + headerName: 'Date', + flex: 1, + sortable: false, + renderCell: (params) => ( + + {dayjs(params.row.acquisition_date).format(DATE_FORMAT.MediumDateTimeFormat)} + + ) + }, + { field: 'latitude', headerName: 'Latitude', flex: 1, sortable: false }, + { field: 'longitude', headerName: 'Longitude', flex: 1, sortable: false } + ]; + + return ( + <> + + + { + setSearchParams(searchParams.setOrDelete('t_itis_tsn', values.itis_tsn)); + setAdvancedFiltersModel(values); + }} + /> + + + + + row.telemetry_id} + // Pagination + paginationMode="server" + pageSizeOptions={pageSizeOptions} + paginationModel={paginationModel} + onPaginationModelChange={(model) => { + if (!model) { + return; + } + setSearchParams(searchParams.set('t_page', String(model.page)).set('t_limit', String(model.pageSize))); + setPaginationModel(model); + }} + // Sorting + sortingMode="server" + sortModel={sortModel} + sortingOrder={['asc', 'desc']} + onSortModelChange={(model) => { + if (!model[0]) { + return; + } + setSearchParams(searchParams.set('t_sort', model[0].field).set('t_order', model[0].sort ?? 'desc')); + setSortModel(model); + }} + // Row options + checkboxSelection={false} + disableRowSelectionOnClick + rowSelection={false} + // Column options + disableColumnSelector + disableColumnFilter + disableColumnMenu + // Styling + rowHeight={70} + getRowHeight={() => 'auto'} + autoHeight={false} + sx={{ + '& .MuiDataGrid-overlay': { + background: grey[50] + }, + '& .MuiDataGrid-cell': { + py: 0.75, + background: '#fff', + '&.MuiDataGrid-cell--editing:focus-within': { + outline: 'none' + } + } + }} + /> + + + ); +}; + +export default TelemetryListContainer; diff --git a/app/src/features/summary/tabular-data/telemetry/TelemetryListFilterForm.tsx b/app/src/features/summary/tabular-data/telemetry/TelemetryListFilterForm.tsx new file mode 100644 index 0000000000..34fdc80766 --- /dev/null +++ b/app/src/features/summary/tabular-data/telemetry/TelemetryListFilterForm.tsx @@ -0,0 +1,75 @@ +import CustomTextField from 'components/fields/CustomTextField'; +import SpeciesAutocompleteField from 'components/species/components/SpeciesAutocompleteField'; +import { FilterFieldsContainer } from 'features/summary/components/FilterFieldsContainer'; +import { Formik } from 'formik'; +import { useTaxonomyContext } from 'hooks/useContext'; + +export type ITelemetryAdvancedFilters = { + itis_tsn?: number; +}; + +export const TelemetryAdvancedFiltersInitialValues: ITelemetryAdvancedFilters = { + itis_tsn: undefined +}; + +export interface ITelemetryListFilterFormProps { + handleSubmit: (filterValues: ITelemetryAdvancedFilters) => void; + initialValues?: ITelemetryAdvancedFilters; +} + +/** + * Telemetry advanced filters + * + * TODO: The filter fields are disabled for now. The fields are functional (the values are captured and passed to the + * backend), but the backend does not currently use them for filtering. + * + * @param {ITelemetryListFilterFormProps} props + * @return {*} + */ +const TelemetryListFilterForm = (props: ITelemetryListFilterFormProps) => { + const { handleSubmit, initialValues } = props; + + const taxonomyContext = useTaxonomyContext(); + + return ( + + {(formikProps) => ( + , + { + if (value?.tsn) { + formikProps.setFieldValue('itis_tsns', value.tsn); + } + }} + handleClear={() => { + formikProps.setFieldValue('itis_tsns', undefined); + }} + disabled={true} // See TODO + key="telemetry-tsn-filter" + /> + ]} + /> + )} + + ); +}; + +export default TelemetryListFilterForm; diff --git a/app/src/features/surveys/SurveyRouter.tsx b/app/src/features/surveys/SurveyRouter.tsx index 495653144b..322fd4c746 100644 --- a/app/src/features/surveys/SurveyRouter.tsx +++ b/app/src/features/surveys/SurveyRouter.tsx @@ -29,7 +29,7 @@ const SurveyRouter: React.FC = () => { /> {/* Survey Page Routes */} - + { ({ ...unit, critter_id: critter.critter_id })), - wildlife_health_id: critter.wlh_id, - critter_comment: critter.critter_comment - } as ICreateEditAnimalRequest - } + initialAnimalData={{ + critter_id: critter.critter_id, + nickname: critter.animal_id || '', + species: { + commonNames: [], + rank: undefined, + kingdom: undefined, + ...taxonomyContext.getCachedSpeciesTaxonomyById(critter.itis_tsn), + tsn: critter.itis_tsn, + scientificName: critter.itis_scientific_name + }, + ecological_units: critter.collection_units.map((unit) => ({ ...unit, critter_id: critter.critter_id })), + wildlife_health_id: critter.wlh_id, + critter_comment: critter.critter_comment + }} handleSubmit={handleSubmit} formikRef={formikRef} isEdit={true} diff --git a/app/src/features/surveys/animals/profile/details/components/AnimalProfileHeader.tsx b/app/src/features/surveys/animals/profile/details/components/AnimalProfileHeader.tsx index 48e99e2e6e..ba621ae9d9 100644 --- a/app/src/features/surveys/animals/profile/details/components/AnimalProfileHeader.tsx +++ b/app/src/features/surveys/animals/profile/details/components/AnimalProfileHeader.tsx @@ -48,7 +48,7 @@ export const AnimalProfileHeader = (props: IAnimalProfileHeaderProps) => { {critter.animal_id} - + { +export const SurveyProgressChip = (props: ISurveyProgressChipProps) => { const codesContext = useCodesContext(); - const codeName = - getCodesName(codesContext.codesDataLoader.data, 'survey_progress', props.progress_id)?.toUpperCase() ?? ''; - const codeColour = SurveyProgressChipColours[codeName] ?? blueGrey; + const codeName = getCodesName(codesContext.codesDataLoader.data, 'survey_progress', props.progress_id) ?? ''; - return ; + return ( + + ); }; - -export default SurveyProgressChip; diff --git a/app/src/features/surveys/list/SurveysListPage.tsx b/app/src/features/surveys/list/SurveysListPage.tsx index 9ddc5ba032..fe3cc79810 100644 --- a/app/src/features/surveys/list/SurveysListPage.tsx +++ b/app/src/features/surveys/list/SurveysListPage.tsx @@ -17,7 +17,7 @@ import { useContext, useEffect, useState } from 'react'; import { Link as RouterLink } from 'react-router-dom'; import { ApiPaginationRequestOptions } from 'types/misc'; import { firstOrNull, getFormattedDate } from 'utils/Utils'; -import SurveyProgressChip from '../components/SurveyProgressChip'; +import { SurveyProgressChip } from '../components/SurveyProgressChip'; const pageSizeOptions = [10, 25, 50]; diff --git a/app/src/features/surveys/view/SurveyHeader.tsx b/app/src/features/surveys/view/SurveyHeader.tsx index fd4e9afe8b..cad79bf72b 100644 --- a/app/src/features/surveys/view/SurveyHeader.tsx +++ b/app/src/features/surveys/view/SurveyHeader.tsx @@ -28,7 +28,7 @@ import React, { useContext, useState } from 'react'; import { useHistory } from 'react-router'; import { Link as RouterLink } from 'react-router-dom'; import { getFormattedDateRangeString } from 'utils/Utils'; -import SurveyProgressChip from '../components/SurveyProgressChip'; +import { SurveyProgressChip } from '../components/SurveyProgressChip'; /** * Survey header for a single-survey view. diff --git a/app/src/features/surveys/view/survey-animals/animal.ts b/app/src/features/surveys/view/survey-animals/animal.ts index 4f7ca9645a..ff6d0822d3 100644 --- a/app/src/features/surveys/view/survey-animals/animal.ts +++ b/app/src/features/surveys/view/survey-animals/animal.ts @@ -166,7 +166,7 @@ export const CreateCritterSchema = yup.object({ critter_id: yup.string().optional(), itis_tsn: yup.number().required(req), animal_id: yup.string().required(req), - wlh_id: yup.string().optional(), + wlh_id: yup.string().optional().nullable(), sex: yup.mixed().oneOf(Object.values(AnimalSex)).required(req), critter_comment: yup.string().optional().nullable() }); diff --git a/app/src/hooks/api/useAdminApi.test.ts b/app/src/hooks/api/useAdminApi.test.ts index fbbe9a523b..2ba90b8336 100644 --- a/app/src/hooks/api/useAdminApi.test.ts +++ b/app/src/hooks/api/useAdminApi.test.ts @@ -8,7 +8,7 @@ import { import useAdminApi from './useAdminApi'; describe('useAdminApi', () => { - let mock: any; + let mock: MockAdapter; beforeEach(() => { mock = new MockAdapter(axios); diff --git a/app/src/hooks/api/useAnimalApi.test.ts b/app/src/hooks/api/useAnimalApi.test.ts new file mode 100644 index 0000000000..3e6621c302 --- /dev/null +++ b/app/src/hooks/api/useAnimalApi.test.ts @@ -0,0 +1,46 @@ +import axios from 'axios'; +import MockAdapter from 'axios-mock-adapter'; +import useAnimalApi from 'hooks/api/useAnimalApi'; +import { IFindAnimalsResponse } from 'interfaces/useAnimalApi.interface'; + +describe('useAnimalApi', () => { + let mock: MockAdapter; + + beforeEach(() => { + mock = new MockAdapter(axios); + }); + + afterEach(() => { + mock.restore(); + }); + + it('findAnimal works as expected', async () => { + const mockResponse: IFindAnimalsResponse = { + animals: [ + { + wlh_id: null, + animal_id: '123456', + sex: 'unknown', + itis_tsn: 123321, + itis_scientific_name: 'scientific name', + critter_comment: 'comment', + critter_id: 2, + survey_id: 1, + critterbase_critter_id: '345-345-345' + } + ], + pagination: { + total: 100, + current_page: 2, + last_page: 4, + per_page: 25 + } + }; + + mock.onGet('/api/animal', { params: { limit: 25, page: 2, itis_tsn: 12345 } }).reply(200, mockResponse); + + const result = await useAnimalApi(axios).findAnimals({ limit: 25, page: 2 }, { itis_tsn: 12345 }); + + expect(result).toEqual(mockResponse); + }); +}); diff --git a/app/src/hooks/api/useAnimalApi.ts b/app/src/hooks/api/useAnimalApi.ts new file mode 100644 index 0000000000..2889d79abb --- /dev/null +++ b/app/src/hooks/api/useAnimalApi.ts @@ -0,0 +1,41 @@ +import { AxiosInstance } from 'axios'; +import { IAnimalsAdvancedFilters } from 'features/summary/tabular-data/animal/AnimalsListFilterForm'; +import { IFindAnimalsResponse } from 'interfaces/useAnimalApi.interface'; +import qs from 'qs'; +import { ApiPaginationRequestOptions } from 'types/misc'; + +/** + * Returns a set of supported api methods for working with SIMS animal (critter) records. + * + * Note: Not to be confused with the useCritterApi hook, which is for working with Critterbase animal (critter) records. + * Note: SIMS animal records are linked to Critterbase animal records. + * + * @param {AxiosInstance} axios + * @return {*} object whose properties are supported api methods. + */ +const useAnimalApi = (axios: AxiosInstance) => { + /** + * Get animals for a system user id. + * + * @param {ApiPaginationRequestOptions} [pagination] + * @param {IAnimalsAdvancedFilters} filterFieldData + * @return {*} {Promise} + */ + const findAnimals = async ( + pagination?: ApiPaginationRequestOptions, + filterFieldData?: IAnimalsAdvancedFilters + ): Promise => { + const params = { + ...pagination, + ...filterFieldData + }; + + const { data } = await axios.get('/api/animal', { params, paramsSerializer: (params) => qs.stringify(params) }); + + return data; + }; + + return { findAnimals }; +}; + +export default useAnimalApi; diff --git a/app/src/hooks/api/useCodesApi.test.ts b/app/src/hooks/api/useCodesApi.test.ts index b4c5ea9068..70617f848d 100644 --- a/app/src/hooks/api/useCodesApi.test.ts +++ b/app/src/hooks/api/useCodesApi.test.ts @@ -3,7 +3,7 @@ import MockAdapter from 'axios-mock-adapter'; import useCodesApi from './useCodesApi'; describe('useCodesApi', () => { - let mock: any; + let mock: MockAdapter; beforeEach(() => { mock = new MockAdapter(axios); diff --git a/app/src/hooks/api/useExternalApi.test.ts b/app/src/hooks/api/useExternalApi.test.ts index 1f6f4ff2da..caa32cf823 100644 --- a/app/src/hooks/api/useExternalApi.test.ts +++ b/app/src/hooks/api/useExternalApi.test.ts @@ -3,7 +3,7 @@ import MockAdapter from 'axios-mock-adapter'; import useExternalApi from './useExternalApi'; describe('useExternalApi', () => { - let mock: any; + let mock: MockAdapter; beforeEach(() => { mock = new MockAdapter(axios); diff --git a/app/src/hooks/api/useFundingSourceApi.test.ts b/app/src/hooks/api/useFundingSourceApi.test.ts index c2151d5eba..a3bd20c2f2 100644 --- a/app/src/hooks/api/useFundingSourceApi.test.ts +++ b/app/src/hooks/api/useFundingSourceApi.test.ts @@ -4,7 +4,7 @@ import { IGetFundingSourcesResponse } from 'interfaces/useFundingSourceApi.inter import useFundingSourceApi from './useFundingSourceApi'; describe('useFundingSourceApi', () => { - let mock: any; + let mock: MockAdapter; beforeEach(() => { mock = new MockAdapter(axios); diff --git a/app/src/hooks/api/useObservationApi.test.ts b/app/src/hooks/api/useObservationApi.test.ts index ff2f18e90b..0b2e4173ff 100644 --- a/app/src/hooks/api/useObservationApi.test.ts +++ b/app/src/hooks/api/useObservationApi.test.ts @@ -1,9 +1,10 @@ import axios from 'axios'; import MockAdapter from 'axios-mock-adapter'; import useObservationApi from 'hooks/api/useObservationApi'; +import { IGetSurveyObservationsResponse } from 'interfaces/useObservationApi.interface'; describe('useObservationApi', () => { - let mock: any; + let mock: MockAdapter; beforeEach(() => { mock = new MockAdapter(axios); @@ -13,6 +14,49 @@ describe('useObservationApi', () => { mock.restore(); }); + it('findObservations works as expected', async () => { + const mockResponse: IGetSurveyObservationsResponse = { + surveyObservations: [ + { + survey_observation_id: 1, + itis_tsn: 12345, + itis_scientific_name: 'scientific name', + survey_sample_site_id: 1, + survey_sample_method_id: 2, + survey_sample_period_id: 3, + count: 40, + observation_date: '2021-01-01', + observation_time: '12:00:00', + latitude: 49.456, + longitude: -123.456, + survey_sample_site_name: 'site name', + survey_sample_method_name: 'method name', + survey_sample_period_start_datetime: '2021-01-01 12:00:00', + subcounts: [] + } + ], + supplementaryObservationData: { + observationCount: 100, + qualitative_measurements: [], + quantitative_measurements: [], + qualitative_environments: [], + quantitative_environments: [] + }, + pagination: { + total: 100, + current_page: 2, + last_page: 4, + per_page: 25 + } + }; + + mock.onGet('/api/observation', { params: { limit: 25, page: 2, keyword: 'moose' } }).reply(200, mockResponse); + + const result = await useObservationApi(axios).findObservations({ limit: 25, page: 2 }, { keyword: 'moose' }); + + expect(result).toEqual(mockResponse); + }); + describe('uploadCsvForImport', () => { it('works as expected', async () => { const projectId = 1; diff --git a/app/src/hooks/api/useObservationApi.ts b/app/src/hooks/api/useObservationApi.ts index b2b7542146..3d53ab2ee6 100644 --- a/app/src/hooks/api/useObservationApi.ts +++ b/app/src/hooks/api/useObservationApi.ts @@ -1,5 +1,5 @@ import { AxiosInstance, AxiosProgressEvent, CancelTokenSource } from 'axios'; - +import { IObservationsAdvancedFilters } from 'features/summary/tabular-data/observation/ObservationsListFilterForm'; import { IGetSurveyObservationsGeometryResponse, IGetSurveyObservationsResponse, @@ -8,6 +8,7 @@ import { SupplementaryObservationCountData } from 'interfaces/useObservationApi.interface'; import { EnvironmentTypeIds } from 'interfaces/useReferenceApi.interface'; +import qs from 'qs'; import { ApiPaginationRequestOptions } from 'types/misc'; export interface SubcountToSave { @@ -61,6 +62,30 @@ const useObservationApi = (axios: AxiosInstance) => { }); }; + /** + * Get observations for a system user id. + * + * @param {ApiPaginationRequestOptions} [pagination] + * @param {IObservationsAdvancedFilters} filterFieldData + * @return {*} {Promise} + */ + const findObservations = async ( + pagination?: ApiPaginationRequestOptions, + filterFieldData?: IObservationsAdvancedFilters + ): Promise => { + const params = { + ...pagination, + ...filterFieldData + }; + + const { data } = await axios.get('/api/observation', { + params, + paramsSerializer: (params) => qs.stringify(params) + }); + + return data; + }; + /** * Retrieves all survey observation records for the given survey * @@ -270,6 +295,7 @@ const useObservationApi = (axios: AxiosInstance) => { insertUpdateObservationRecords, getObservationRecords, getObservationRecord, + findObservations, getObservationsGeometry, deleteObservationRecords, deleteObservationMeasurements, diff --git a/app/src/hooks/api/useProjectApi.test.ts b/app/src/hooks/api/useProjectApi.test.ts index a36ac851c9..c644f4b985 100644 --- a/app/src/hooks/api/useProjectApi.test.ts +++ b/app/src/hooks/api/useProjectApi.test.ts @@ -4,13 +4,13 @@ import { IEditReportMetaForm } from 'components/attachments/EditReportMetaForm'; import { IProjectDetailsForm } from 'features/projects/components/ProjectDetailsForm'; import { IProjectIUCNForm } from 'features/projects/components/ProjectIUCNForm'; import { IProjectObjectivesForm } from 'features/projects/components/ProjectObjectivesForm'; -import { ICreateProjectRequest, UPDATE_GET_ENTITIES } from 'interfaces/useProjectApi.interface'; +import { ICreateProjectRequest, IFindProjectsResponse, UPDATE_GET_ENTITIES } from 'interfaces/useProjectApi.interface'; import { getProjectForViewResponse } from 'test-helpers/project-helpers'; import { ISurveyPermitForm } from '../../features/surveys/SurveyPermitForm'; import useProjectApi from './useProjectApi'; describe('useProjectApi', () => { - let mock: any; + let mock: MockAdapter; beforeEach(() => { mock = new MockAdapter(axios); @@ -20,7 +20,6 @@ describe('useProjectApi', () => { mock.restore(); }); - const systemUserId = 123; const projectId = 1; const attachmentId = 1; const attachmentType = 'type'; @@ -33,32 +32,6 @@ describe('useProjectApi', () => { revision_count: 1 }; - it('getAllUserProjectsForView works as expected', async () => { - mock.onGet(`/api/user/${systemUserId}/projects/get`).reply(200, [ - { - project_participation_id: 3, - project_id: 321, - project_name: 'test', - system_user_id: 1, - project_role_ids: [2], - project_role_names: ['Role1'], - project_role_permissions: ['Permission1'] - } - ]); - - const result = await useProjectApi(axios).getAllUserProjectsForView(123); - - expect(result[0]).toEqual({ - project_participation_id: 3, - project_id: 321, - project_name: 'test', - system_user_id: 1, - project_role_ids: [2], - project_role_names: ['Role1'], - project_role_permissions: ['Permission1'] - }); - }); - it('getProjectAttachments works as expected', async () => { mock.onGet(`/api/project/${projectId}/attachments/list`).reply(200, { attachmentsList: [ @@ -83,6 +56,34 @@ describe('useProjectApi', () => { ]); }); + it('findProjects works as expected', async () => { + const mockResponse: IFindProjectsResponse = { + projects: [ + { + project_id: 1, + name: 'name', + start_date: '2021-01-01', + end_date: '2021-12-31', + regions: [], + focal_species: [123, 456], + types: [1, 2, 3] + } + ], + pagination: { + total: 100, + current_page: 2, + last_page: 4, + per_page: 25 + } + }; + + mock.onGet('/api/project', { params: { limit: 25, page: 2, keyword: 'moose' } }).reply(200, mockResponse); + + const result = await useProjectApi(axios).findProjects({ limit: 25, page: 2 }, { keyword: 'moose' }); + + expect(result).toEqual(mockResponse); + }); + it('deleteProject works as expected', async () => { mock.onDelete(`/api/project/${projectId}/delete`).reply(200, true); @@ -99,22 +100,6 @@ describe('useProjectApi', () => { expect(result).toEqual(1); }); - describe('getProjectsList', () => { - it('getProjectsList works as expected', async () => { - const response = [ - { - project_id: 1 - } - ]; - - mock.onGet(`/api/project/list?`).reply(200, response); - - const result = await useProjectApi(axios).getProjectsList(); - - expect(result).toEqual([{ project_id: 1 }]); - }); - }); - it('getProjectForView works as expected', async () => { mock.onGet(`/api/project/${projectId}/view`).reply(200, getProjectForViewResponse); diff --git a/app/src/hooks/api/useProjectApi.ts b/app/src/hooks/api/useProjectApi.ts index 33d5ab5d1d..28e2bcc31e 100644 --- a/app/src/hooks/api/useProjectApi.ts +++ b/app/src/hooks/api/useProjectApi.ts @@ -1,17 +1,17 @@ import { AxiosInstance, AxiosProgressEvent, CancelTokenSource } from 'axios'; import { IEditReportMetaForm } from 'components/attachments/EditReportMetaForm'; import { IReportMetaForm } from 'components/attachments/ReportMetaForm'; -import { IProjectAdvancedFilters } from 'components/search-filter/ProjectAdvancedFilters'; +import { IProjectAdvancedFilters } from 'features/summary/list-data/project/ProjectsListFilterForm'; + import { ICreateProjectRequest, ICreateProjectResponse, + IFindProjectsResponse, IGetAttachmentDetails, IGetProjectAttachmentsResponse, IGetProjectForUpdateResponse, IGetProjectForViewResponse, - IGetProjectsListResponse, IGetReportDetails, - IGetUserProjectsListResponse, IUpdateProjectRequest, IUploadAttachmentResponse, UPDATE_GET_ENTITIES @@ -27,24 +27,34 @@ import { ApiPaginationRequestOptions } from 'types/misc'; */ const useProjectApi = (axios: AxiosInstance) => { /** - * Get projects for a system user id. + * Get project attachments based on project ID * - * @param {number} systemUserId - * @return {*} {Promise} + * @param {AxiosInstance} axios + * @returns {*} {Promise} */ - const getAllUserProjectsForView = async (systemUserId: number): Promise => { - const { data } = await axios.get(`/api/user/${systemUserId}/projects/get`); + const getProjectAttachments = async (projectId: number): Promise => { + const { data } = await axios.get(`/api/project/${projectId}/attachments/list`); + return data; }; /** - * Get project attachments based on project ID + * Get projects list (potentially based on filter criteria). * - * @param {AxiosInstance} axios - * @returns {*} {Promise} + * @param {ApiPaginationRequestOptions} [pagination] + * @param {IProjectAdvancedFilters} filterFieldData + * @return {*} {Promise} */ - const getProjectAttachments = async (projectId: number): Promise => { - const { data } = await axios.get(`/api/project/${projectId}/attachments/list`); + const findProjects = async ( + pagination?: ApiPaginationRequestOptions, + filterFieldData?: IProjectAdvancedFilters + ): Promise => { + const params = { + ...pagination, + ...filterFieldData + }; + + const { data } = await axios.get('/api/project', { params, paramsSerializer: (params) => qs.stringify(params) }); return data; }; @@ -104,43 +114,6 @@ const useProjectApi = (axios: AxiosInstance) => { return data; }; - /** - * Get projects list (potentially based on filter criteria). - * - * @param {ApiPaginationRequestOptions} [pagination] - * @param {IProjectAdvancedFilters} filterFieldData - * @return {*} {Promise} - */ - const getProjectsList = async ( - pagination?: ApiPaginationRequestOptions, - filterFieldData?: IProjectAdvancedFilters - ): Promise => { - const params = new URLSearchParams(); - - if (pagination) { - params.append('page', pagination.page.toString()); - params.append('limit', pagination.limit.toString()); - if (pagination.sort) { - params.append('sort', pagination.sort); - } - if (pagination.order) { - params.append('order', pagination.order); - } - } - - if (filterFieldData) { - Object.entries(filterFieldData).forEach(([key, value]) => { - params.append(key, value); - }); - } - - const urlParamsString = `?${params.toString()}`; - - const { data } = await axios.get(`/api/project/list${urlParamsString}`); - - return data; - }; - /** * Get project details based on its ID for viewing purposes. * @@ -337,9 +310,8 @@ const useProjectApi = (axios: AxiosInstance) => { }; return { - getAllUserProjectsForView, - getProjectsList, createProject, + findProjects, getProjectForView, uploadProjectAttachments, uploadProjectReports, diff --git a/app/src/hooks/api/useProjectParticipationApi.test.ts b/app/src/hooks/api/useProjectParticipationApi.test.ts index 026a22faa4..b6f418544c 100644 --- a/app/src/hooks/api/useProjectParticipationApi.test.ts +++ b/app/src/hooks/api/useProjectParticipationApi.test.ts @@ -3,7 +3,7 @@ import MockAdapter from 'axios-mock-adapter'; import useProjectParticipationApi from './useProjectParticipationApi'; describe('useProjectParticipationApi', () => { - let mock: any; + let mock: MockAdapter; beforeEach(() => { mock = new MockAdapter(axios); diff --git a/app/src/hooks/api/useResourcesApi.test.ts b/app/src/hooks/api/useResourcesApi.test.ts index 3ef33b22da..362f1ab532 100644 --- a/app/src/hooks/api/useResourcesApi.test.ts +++ b/app/src/hooks/api/useResourcesApi.test.ts @@ -3,7 +3,7 @@ import MockAdapter from 'axios-mock-adapter'; import useResourcesApi from './useResourcesApi'; describe('useResourcesApi', () => { - let mock: any; + let mock: MockAdapter; beforeEach(() => { mock = new MockAdapter(axios); diff --git a/app/src/hooks/api/useSamplingSiteApi.ts b/app/src/hooks/api/useSamplingSiteApi.ts index c5ff1b58db..3cc0628149 100644 --- a/app/src/hooks/api/useSamplingSiteApi.ts +++ b/app/src/hooks/api/useSamplingSiteApi.ts @@ -44,7 +44,7 @@ const useSamplingSiteApi = (axios: AxiosInstance) => { /** * Get Sample Site by ID - * TODO: Required? not used anywhere + * * @param {number} projectId * @param {number} surveyId * @param {number} sampleSiteId diff --git a/app/src/hooks/api/useSpatialApi.test.ts b/app/src/hooks/api/useSpatialApi.test.ts index 960ae119f8..7e5b8bc1cf 100644 --- a/app/src/hooks/api/useSpatialApi.test.ts +++ b/app/src/hooks/api/useSpatialApi.test.ts @@ -3,7 +3,7 @@ import MockAdapter from 'axios-mock-adapter'; import useSpatialApi from './useSpatialApi'; describe('useSpatialApi', () => { - let mock: any; + let mock: MockAdapter; beforeEach(() => { mock = new MockAdapter(axios); diff --git a/app/src/hooks/api/useStandardsApi.test.ts b/app/src/hooks/api/useStandardsApi.test.ts index 764d95240a..a06e564312 100644 --- a/app/src/hooks/api/useStandardsApi.test.ts +++ b/app/src/hooks/api/useStandardsApi.test.ts @@ -3,7 +3,7 @@ import MockAdapter from 'axios-mock-adapter'; import useStandardsApi from './useStandardsApi'; describe('useStandardsApi', () => { - let mock: any; + let mock: MockAdapter; beforeEach(() => { mock = new MockAdapter(axios); diff --git a/app/src/hooks/api/useSurveyApi.test.ts b/app/src/hooks/api/useSurveyApi.test.ts index 9c47244677..a9763f8a4a 100644 --- a/app/src/hooks/api/useSurveyApi.test.ts +++ b/app/src/hooks/api/useSurveyApi.test.ts @@ -6,7 +6,7 @@ import { ICreateSurveyRequest, ICreateSurveyResponse, IDetailedCritterWithInternalId, - IGetSurveyListResponse, + IFindSurveysResponse, SurveyBasicFieldsObject } from 'interfaces/useSurveyApi.interface'; import { ApiPaginationResponseParams } from 'types/misc'; @@ -14,7 +14,7 @@ import { v4 } from 'uuid'; import useSurveyApi from './useSurveyApi'; describe('useSurveyApi', () => { - let mock: any; + let mock: MockAdapter; beforeEach(() => { mock = new MockAdapter(axios); @@ -49,7 +49,7 @@ describe('useSurveyApi', () => { it('fetches an array of surveys', async () => { const projectId = 1; - const res: IGetSurveyListResponse = { + const res: IFindSurveysResponse = { surveys: [{ survey_id: 1 }, { survey_id: 2 }] as SurveyBasicFieldsObject[], pagination: null as unknown as ApiPaginationResponseParams }; diff --git a/app/src/hooks/api/useSurveyApi.ts b/app/src/hooks/api/useSurveyApi.ts index 887d3341cc..3af8055895 100644 --- a/app/src/hooks/api/useSurveyApi.ts +++ b/app/src/hooks/api/useSurveyApi.ts @@ -2,6 +2,7 @@ import { AxiosInstance, AxiosProgressEvent, CancelTokenSource } from 'axios'; import { IEditReportMetaForm } from 'components/attachments/EditReportMetaForm'; import { IReportMetaForm } from 'components/attachments/ReportMetaForm'; import { ISurveyCritter } from 'contexts/animalPageContext'; +import { ISurveyAdvancedFilters } from 'features/summary/list-data/survey/SurveysListFilterForm'; import { ICreateCritter } from 'features/surveys/view/survey-animals/animal'; import { IAnimalDeployment, @@ -13,10 +14,10 @@ import { IGetReportDetails, IUploadAttachmentResponse } from 'interfaces/useProj import { ICreateSurveyRequest, ICreateSurveyResponse, + IFindSurveysResponse, IGetSurveyAttachmentsResponse, IGetSurveyForUpdateResponse, IGetSurveyForViewResponse, - IGetSurveyListResponse, ISimpleCritterWithInternalId, SurveyUpdateObject } from 'interfaces/useSurveyApi.interface'; @@ -68,17 +69,38 @@ const useSurveyApi = (axios: AxiosInstance) => { return data; }; + /** + * Get surveys for a system user id. + * + * @param {ApiPaginationRequestOptions} [pagination] + * @param {ISurveyAdvancedFilters} filterFieldData + * @return {*} {Promise} + */ + const findSurveys = async ( + pagination?: ApiPaginationRequestOptions, + filterFieldData?: ISurveyAdvancedFilters + ): Promise => { + const params = { + ...pagination, + ...filterFieldData + }; + + const { data } = await axios.get('/api/survey', { params, paramsSerializer: (params) => qs.stringify(params) }); + + return data; + }; + /** * Fetches a subset of survey fields for all surveys under a project. * * @param {number} projectId * @param {ApiPaginationRequestOptions} [pagination] - * @return {*} {Promise} + * @return {*} {Promise} */ const getSurveysBasicFieldsByProjectId = async ( projectId: number, pagination?: ApiPaginationRequestOptions - ): Promise => { + ): Promise => { let urlParamsString = ''; if (pagination) { @@ -517,6 +539,7 @@ const useSurveyApi = (axios: AxiosInstance) => { getSurveyForView, getSurveysBasicFieldsByProjectId, getSurveyForUpdate, + findSurveys, updateSurvey, uploadSurveyAttachments, uploadSurveyKeyx, diff --git a/app/src/hooks/api/useTaxonomyApi.test.tsx b/app/src/hooks/api/useTaxonomyApi.test.tsx index 30505a2dde..7974050119 100644 --- a/app/src/hooks/api/useTaxonomyApi.test.tsx +++ b/app/src/hooks/api/useTaxonomyApi.test.tsx @@ -7,7 +7,7 @@ import { AuthProvider, AuthProviderProps } from 'react-oidc-context'; import useTaxonomyApi from './useTaxonomyApi'; describe('useTaxonomyApi', () => { - let mock: any; + let mock: MockAdapter; const authConfig: AuthProviderProps = { authority: 'authority', diff --git a/app/src/hooks/api/useTaxonomyApi.ts b/app/src/hooks/api/useTaxonomyApi.ts index 2e166d58b1..84b05ee5d6 100644 --- a/app/src/hooks/api/useTaxonomyApi.ts +++ b/app/src/hooks/api/useTaxonomyApi.ts @@ -1,5 +1,5 @@ import { useConfigContext } from 'hooks/useContext'; -import { ITaxonomy } from 'interfaces/useTaxonomyApi.interface'; +import { IPartialTaxonomy, ITaxonomy } from 'interfaces/useTaxonomyApi.interface'; import qs from 'qs'; import useAxios from './useAxios'; @@ -10,11 +10,16 @@ const useTaxonomyApi = () => { /** * Searches for taxon records based on ITIS TSNs. * + * TODO: Update the return type to `ITaxonomy[]` once the BioHub API endpoint is updated to return the extra `rank` + * and `kingdom` fields. + * * @param {number[]} tsns - * @return {*} {Promise} + * @return {*} {Promise} */ - const getSpeciesFromIds = async (tsns: number[]): Promise => { - const { data } = await apiAxios.get<{ searchResponse: ITaxonomy[] }>(config.BIOHUB_TAXON_TSN_PATH, { + const getSpeciesFromIds = async (tsns: number[]): Promise => { + const { data } = await apiAxios.get<{ + searchResponse: IPartialTaxonomy[]; + }>(config.BIOHUB_TAXON_TSN_PATH, { params: { tsn: [...new Set(tsns)] }, diff --git a/app/src/hooks/api/useTelemetryApi.test.ts b/app/src/hooks/api/useTelemetryApi.test.ts new file mode 100644 index 0000000000..8c66b9bb2a --- /dev/null +++ b/app/src/hooks/api/useTelemetryApi.test.ts @@ -0,0 +1,48 @@ +import axios from 'axios'; +import MockAdapter from 'axios-mock-adapter'; +import useTelemetryApi from 'hooks/api/useTelemetryApi'; +import { IFindTelemetryResponse } from 'interfaces/useTelemetryApi.interface'; + +describe('useTelemetryApi', () => { + let mock: MockAdapter; + + beforeEach(() => { + mock = new MockAdapter(axios); + }); + + afterEach(() => { + mock.restore(); + }); + + it('findTelemetry works as expected', async () => { + const mockResponse: IFindTelemetryResponse = { + telemetry: [ + { + telemetry_id: '123', + acquisition_date: '2021-01-01', + latitude: 49.123, + longitude: -126.123, + telemetry_type: 'vendor', + device_id: 12345, + bctw_deployment_id: '123-123-123', + critter_id: 2, + deployment_id: 3, + critterbase_critter_id: '345-345-345-', + animal_id: '567234-234' + } + ], + pagination: { + total: 100, + current_page: 2, + last_page: 4, + per_page: 25 + } + }; + + mock.onGet('/api/telemetry', { params: { limit: 25, page: 2, itis_tsn: 12345 } }).reply(200, mockResponse); + + const result = await useTelemetryApi(axios).findTelemetry({ limit: 25, page: 2 }, { itis_tsn: 12345 }); + + expect(result).toEqual(mockResponse); + }); +}); diff --git a/app/src/hooks/api/useTelemetryApi.ts b/app/src/hooks/api/useTelemetryApi.ts new file mode 100644 index 0000000000..08f51bd1ce --- /dev/null +++ b/app/src/hooks/api/useTelemetryApi.ts @@ -0,0 +1,39 @@ +import { AxiosInstance } from 'axios'; +import { ITelemetryAdvancedFilters } from 'features/summary/tabular-data/telemetry/TelemetryListFilterForm'; + +import { IFindTelemetryResponse } from 'interfaces/useTelemetryApi.interface'; +import qs from 'qs'; +import { ApiPaginationRequestOptions } from 'types/misc'; + +/** + * Returns a set of supported api methods for working with telemetry. + * + * @param {AxiosInstance} axios + * @return {*} object whose properties are supported api methods. + */ +const useTelemetryApi = (axios: AxiosInstance) => { + /** + * Get telemetry for a system user id. + * + * @param {ApiPaginationRequestOptions} [pagination] + * @param {ITelemetryAdvancedFilters} filterFieldData + * @return {*} {Promise} + */ + const findTelemetry = async ( + pagination?: ApiPaginationRequestOptions, + filterFieldData?: ITelemetryAdvancedFilters + ): Promise => { + const params = { + ...pagination, + ...filterFieldData + }; + + const { data } = await axios.get('/api/telemetry', { params, paramsSerializer: (params) => qs.stringify(params) }); + + return data; + }; + + return { findTelemetry }; +}; + +export default useTelemetryApi; diff --git a/app/src/hooks/api/useUserApi.test.ts b/app/src/hooks/api/useUserApi.test.ts index d2840f8d15..04cceb32a9 100644 --- a/app/src/hooks/api/useUserApi.test.ts +++ b/app/src/hooks/api/useUserApi.test.ts @@ -3,7 +3,7 @@ import MockAdapter from 'axios-mock-adapter'; import useUserApi from './useUserApi'; describe('useUserApi', () => { - let mock: any; + let mock: MockAdapter; beforeEach(() => { mock = new MockAdapter(axios); @@ -68,4 +68,30 @@ describe('useUserApi', () => { expect(result[1].user_identifier).toEqual('myidirbossagain'); expect(result[1].role_names).toEqual(['role 1', 'role 4']); }); + + it('getProjectList works as expected', async () => { + mock.onGet(`/api/user/${systemUserId}/projects/get`).reply(200, [ + { + project_participation_id: 3, + project_id: 321, + project_name: 'test', + system_user_id: 1, + project_role_ids: [2], + project_role_names: ['Role1'], + project_role_permissions: ['Permission1'] + } + ]); + + const result = await useUserApi(axios).getProjectList(123); + + expect(result[0]).toEqual({ + project_participation_id: 3, + project_id: 321, + project_name: 'test', + system_user_id: 1, + project_role_ids: [2], + project_role_names: ['Role1'], + project_role_permissions: ['Permission1'] + }); + }); }); diff --git a/app/src/hooks/api/useUserApi.ts b/app/src/hooks/api/useUserApi.ts index fd840a6d4d..3cc143aaff 100644 --- a/app/src/hooks/api/useUserApi.ts +++ b/app/src/hooks/api/useUserApi.ts @@ -1,4 +1,5 @@ import { AxiosInstance } from 'axios'; +import { IGetUserProjectsListResponse } from 'interfaces/useProjectApi.interface'; import { ISystemUser } from 'interfaces/useUserApi.interface'; /** @@ -68,8 +69,20 @@ const useUserApi = (axios: AxiosInstance) => { return data; }; + /** + * Get projects for a system user id. + * + * @param {number} systemUserId + * @return {*} {Promise} + */ + const getProjectList = async (systemUserId: number): Promise => { + const { data } = await axios.get(`/api/user/${systemUserId}/projects/get`); + return data; + }; + return { getUser, + getProjectList, getUserById, getUsersList, deleteSystemUser, diff --git a/app/src/hooks/cb_api/useAuthenticationApi.test.tsx b/app/src/hooks/cb_api/useAuthenticationApi.test.tsx index a9e342b88c..930ccfca26 100644 --- a/app/src/hooks/cb_api/useAuthenticationApi.test.tsx +++ b/app/src/hooks/cb_api/useAuthenticationApi.test.tsx @@ -4,7 +4,7 @@ import { v4 } from 'uuid'; import { useAuthentication } from './useAuthenticationApi'; describe('useAuthenticationApi', () => { - let mock: any; + let mock: MockAdapter; beforeEach(() => { mock = new MockAdapter(axios); diff --git a/app/src/hooks/cb_api/useFamilyApi.test.tsx b/app/src/hooks/cb_api/useFamilyApi.test.tsx index e930a47bce..e9f9d0b293 100644 --- a/app/src/hooks/cb_api/useFamilyApi.test.tsx +++ b/app/src/hooks/cb_api/useFamilyApi.test.tsx @@ -4,7 +4,7 @@ import { v4 } from 'uuid'; import { useFamilyApi } from './useFamilyApi'; describe('useFamily', () => { - let mock: any; + let mock: MockAdapter; beforeEach(() => { mock = new MockAdapter(axios); diff --git a/app/src/hooks/telemetry/useDeviceApi.test.tsx b/app/src/hooks/telemetry/useDeviceApi.test.tsx index f898973a6b..8d94d662c7 100644 --- a/app/src/hooks/telemetry/useDeviceApi.test.tsx +++ b/app/src/hooks/telemetry/useDeviceApi.test.tsx @@ -4,7 +4,7 @@ import { Device } from 'features/surveys/view/survey-animals/telemetry-device/de import { useDeviceApi } from './useDeviceApi'; describe('useDeviceApi', () => { - let mock: any; + let mock: MockAdapter; beforeEach(() => { mock = new MockAdapter(axios); diff --git a/app/src/hooks/useBioHubApi.ts b/app/src/hooks/useBioHubApi.ts index 246aa00384..849948e148 100644 --- a/app/src/hooks/useBioHubApi.ts +++ b/app/src/hooks/useBioHubApi.ts @@ -3,6 +3,7 @@ import useReferenceApi from 'hooks/api/useReferenceApi'; import { useConfigContext } from 'hooks/useContext'; import { useMemo } from 'react'; import useAdminApi from './api/useAdminApi'; +import useAnimalApi from './api/useAnimalApi'; import useAxios from './api/useAxios'; import useCodesApi from './api/useCodesApi'; import useExternalApi from './api/useExternalApi'; @@ -17,6 +18,7 @@ import useSpatialApi from './api/useSpatialApi'; import useStandardsApi from './api/useStandardsApi'; import useSurveyApi from './api/useSurveyApi'; import useTaxonomyApi from './api/useTaxonomyApi'; +import useTelemetryApi from './api/useTelemetryApi'; import useUserApi from './api/useUserApi'; /** @@ -60,6 +62,10 @@ export const useBiohubApi = () => { const reference = useReferenceApi(apiAxios); + const animal = useAnimalApi(apiAxios); + + const telemetry = useTelemetryApi(apiAxios); + return useMemo( () => ({ project, @@ -69,6 +75,7 @@ export const useBiohubApi = () => { observation, resources, codes, + animal, user, admin, external, @@ -77,7 +84,8 @@ export const useBiohubApi = () => { funding, samplingSite, standards, - reference + reference, + telemetry }), // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/app/src/hooks/useDataLoader.ts b/app/src/hooks/useDataLoader.ts index 22c3ee7f2b..ea16873561 100644 --- a/app/src/hooks/useDataLoader.ts +++ b/app/src/hooks/useDataLoader.ts @@ -1,4 +1,4 @@ -import { useCallback, useState } from 'react'; +import { useState } from 'react'; import { AsyncFunction, useAsync } from './useAsync'; import useIsMounted from './useIsMounted'; @@ -85,41 +85,35 @@ export default function useDataLoader => { - try { - setIsLoading(true); - setError(undefined); - setIsReady(false); + const loadData = async (...args: AFArgs): Promise => { + try { + setIsLoading(true); - const response = await getData(...args); + const response = await getData(...args); - if (!isMounted()) { - return; - } + if (!isMounted()) { + return; + } - setData(response); + setData(response); - return response; - } catch (error) { - if (!isMounted()) { - return; - } + return response; + } catch (error) { + if (!isMounted()) { + return; + } - setError(error); - setIsLoading(false); + setError(error); - onError?.(error); - } finally { - if (isMounted()) { - setIsLoading(false); - setIsReady(true); - setHasLoaded(true); - } + onError?.(error); + } finally { + if (isMounted()) { + setIsLoading(false); + setIsReady(true); + setHasLoaded(true); } - }, - [getData, isMounted, onError] - ); + } + }; const load = async (...args: AFArgs) => { if (oneTimeLoad) { @@ -130,19 +124,12 @@ export default function useDataLoader { - // Clear previous data and state - setData(undefined); - setError(undefined); - setIsLoading(false); - setIsReady(false); - - // Call loadData to fetch new data - return loadData(...args); - }, - [loadData] - ); + const refresh = async (...args: AFArgs) => { + setError(undefined); + setIsLoading(false); + setIsReady(false); + return loadData(...args); + }; const clearError = () => { setError(undefined); diff --git a/app/src/hooks/useSearchParams.test.tsx b/app/src/hooks/useSearchParams.test.tsx new file mode 100644 index 0000000000..f5c19488bf --- /dev/null +++ b/app/src/hooks/useSearchParams.test.tsx @@ -0,0 +1,340 @@ +import { renderHook } from '@testing-library/react'; +import { createMemoryHistory } from 'history'; +import { TypedURLSearchParams, useSearchParams } from 'hooks/useSearchParams'; +import { PropsWithChildren } from 'react'; +import { Router } from 'react-router'; + +describe('useSearchParams', () => { + it('updates history with new params', () => { + const history = createMemoryHistory(); + + const { result } = renderHook(() => useSearchParams(), { + wrapper: ({ children }: PropsWithChildren) => {children} + }); + + result.current.setSearchParams(result.current.searchParams.set('key1', 'value1').set('key2', 'value2')); + + expect(history.location.search).toBe('?key1=value1&key2=value2'); + }); +}); + +describe('TypedURLSearchParams', () => { + describe('set', () => { + it('sets a key/value', async () => { + const typedURLSearchParams = new TypedURLSearchParams(); + + typedURLSearchParams.set('key1', 'value1'); + const stringValue1 = typedURLSearchParams.toString(); + expect(stringValue1).toBe('key1=value1'); + + typedURLSearchParams.set('key2', 'value2'); + const stringValue2 = typedURLSearchParams.toString(); + expect(stringValue2).toBe('key1=value1&key2=value2'); + }); + }); + + describe('setOrDelete', () => { + it('deletes a key if the value is null', async () => { + const typedURLSearchParams = new TypedURLSearchParams(); + + typedURLSearchParams.set('key1', 'value1'); + const stringValue1 = typedURLSearchParams.toString(); + expect(stringValue1).toBe('key1=value1'); + + typedURLSearchParams.set('key2', 'value2'); + const stringValue2 = typedURLSearchParams.toString(); + expect(stringValue2).toBe('key1=value1&key2=value2'); + + typedURLSearchParams.setOrDelete('key2', null); + const stringValue3 = typedURLSearchParams.toString(); + expect(stringValue3).toBe('key1=value1'); + }); + + it('deletes a key if the value is undefined', async () => { + const typedURLSearchParams = new TypedURLSearchParams(); + + typedURLSearchParams.set('key1', 'value1'); + const stringValue1 = typedURLSearchParams.toString(); + expect(stringValue1).toBe('key1=value1'); + + typedURLSearchParams.set('key2', 'value2'); + const stringValue2 = typedURLSearchParams.toString(); + expect(stringValue2).toBe('key1=value1&key2=value2'); + + typedURLSearchParams.setOrDelete('key2', undefined); + const stringValue3 = typedURLSearchParams.toString(); + expect(stringValue3).toBe('key1=value1'); + }); + + it('deletes a key if the value is an empty string', async () => { + const typedURLSearchParams = new TypedURLSearchParams(); + + typedURLSearchParams.set('key1', 'value1'); + const stringValue1 = typedURLSearchParams.toString(); + expect(stringValue1).toBe('key1=value1'); + + typedURLSearchParams.set('key2', 'value2'); + const stringValue2 = typedURLSearchParams.toString(); + expect(stringValue2).toBe('key1=value1&key2=value2'); + + typedURLSearchParams.setOrDelete('key2', ''); + const stringValue3 = typedURLSearchParams.toString(); + expect(stringValue3).toBe('key1=value1'); + }); + + it('stringifies and sets the value if it is not a string', async () => { + const typedURLSearchParams = new TypedURLSearchParams(); + + typedURLSearchParams.set('key1', 'value1'); + const stringValue1 = typedURLSearchParams.toString(); + expect(stringValue1).toBe('key1=value1'); + + typedURLSearchParams.setOrDelete('key2', { + key3: 'value3', + key4: { + Key5: 'value5' + } + }); + const stringValue2 = typedURLSearchParams.toString(); + expect(stringValue2).toBe('key1=value1&key2=key3%3Dvalue3%26key4%255BKey5%255D%3Dvalue5'); + }); + + it('sets a key/value', async () => { + const typedURLSearchParams = new TypedURLSearchParams(); + + typedURLSearchParams.set('key1', 'value1'); + const stringValue1 = typedURLSearchParams.toString(); + expect(stringValue1).toBe('key1=value1'); + + typedURLSearchParams.setOrDelete('key2', 'value2'); + const stringValue2 = typedURLSearchParams.toString(); + expect(stringValue2).toBe('key1=value1&key2=value2'); + }); + }); + + describe('get', () => { + it('returns null if key does not exist', async () => { + const typedURLSearchParams = new TypedURLSearchParams(); + + typedURLSearchParams.set('key1', 'value1'); + const stringValue1 = typedURLSearchParams.toString(); + expect(stringValue1).toBe('key1=value1'); + + const value = typedURLSearchParams.get('key2'); + expect(value).toBeNull(); + }); + + it('returns the value if the key exists', async () => { + const typedURLSearchParams = new TypedURLSearchParams(); + + typedURLSearchParams.set('key1', 'value1'); + const stringValue1 = typedURLSearchParams.toString(); + expect(stringValue1).toBe('key1=value1'); + + typedURLSearchParams.set('key2', 'value2'); + const value = typedURLSearchParams.get('key2'); + expect(value).toBe('value2'); + }); + }); + + describe('delete', () => { + it('does nothing if the key does not exist', async () => { + const typedURLSearchParams = new TypedURLSearchParams(); + + typedURLSearchParams.set('key1', 'value1'); + const stringValue1 = typedURLSearchParams.toString(); + expect(stringValue1).toBe('key1=value1'); + + typedURLSearchParams.delete('key2'); + const stringValue3 = typedURLSearchParams.toString(); + expect(stringValue3).toBe('key1=value1'); + }); + + it('deletes a key', async () => { + const typedURLSearchParams = new TypedURLSearchParams(); + + typedURLSearchParams.set('key1', 'value1'); + const stringValue1 = typedURLSearchParams.toString(); + expect(stringValue1).toBe('key1=value1'); + + typedURLSearchParams.set('key2', 'value2'); + const stringValue2 = typedURLSearchParams.toString(); + expect(stringValue2).toBe('key1=value1&key2=value2'); + + typedURLSearchParams.delete('key2'); + const stringValue3 = typedURLSearchParams.toString(); + expect(stringValue3).toBe('key1=value1'); + }); + }); + + describe('toString', () => { + it('returns the params as a string', async () => { + const typedURLSearchParams = new TypedURLSearchParams(); + + typedURLSearchParams.set('key1', 'value1'); + const stringValue1 = typedURLSearchParams.toString(); + expect(stringValue1).toBe('key1=value1'); + }); + }); + + describe('append', () => { + it('appends a key/value', async () => { + const typedURLSearchParams = new TypedURLSearchParams(); + + typedURLSearchParams.set('key1', 'value1'); + const stringValue1 = typedURLSearchParams.toString(); + expect(stringValue1).toBe('key1=value1'); + + typedURLSearchParams.append('key1', 'value2'); + const stringValue2 = typedURLSearchParams.toString(); + expect(stringValue2).toBe('key1=value1&key1=value2'); + }); + }); + + describe('entries', () => { + it('returns the entries', async () => { + const typedURLSearchParams = new TypedURLSearchParams(); + + typedURLSearchParams.set('key1', 'value1'); + const stringValue1 = typedURLSearchParams.toString(); + expect(stringValue1).toBe('key1=value1'); + + typedURLSearchParams.set('key2', 'value2'); + const stringValue2 = typedURLSearchParams.toString(); + expect(stringValue2).toBe('key1=value1&key2=value2'); + + const entries = typedURLSearchParams.entries(); + const entry1 = entries.next().value; + expect(entry1).toEqual(['key1', 'value1']); + const entry2 = entries.next().value; + expect(entry2).toEqual(['key2', 'value2']); + }); + }); + + describe('forEach', () => { + it('calls a callback on each param', async () => { + const typedURLSearchParams = new TypedURLSearchParams(); + + typedURLSearchParams.set('key1', 'value1'); + const stringValue1 = typedURLSearchParams.toString(); + expect(stringValue1).toBe('key1=value1'); + + typedURLSearchParams.set('key2', 'value2'); + const stringValue2 = typedURLSearchParams.toString(); + expect(stringValue2).toBe('key1=value1&key2=value2'); + + const callback = jest.fn(); + typedURLSearchParams.forEach(callback); + expect(callback).toHaveBeenCalledTimes(2); + expect(callback).toHaveBeenCalledWith('value1', 'key1', typedURLSearchParams); + expect(callback).toHaveBeenCalledWith('value2', 'key2', typedURLSearchParams); + }); + }); + + describe('getAll', () => { + it('returns all params', async () => { + const typedURLSearchParams = new TypedURLSearchParams(); + + typedURLSearchParams.set('key1', 'value1'); + const stringValue1 = typedURLSearchParams.toString(); + expect(stringValue1).toBe('key1=value1'); + + typedURLSearchParams.append('key1', 'value2'); + const stringValue2 = typedURLSearchParams.toString(); + expect(stringValue2).toBe('key1=value1&key1=value2'); + + const all = typedURLSearchParams.getAll('key1'); + expect(all).toEqual(['value1', 'value2']); + }); + }); + + describe('has', () => { + it('returns true if the key exists', async () => { + const typedURLSearchParams = new TypedURLSearchParams(); + + typedURLSearchParams.set('key1', 'value1'); + const stringValue1 = typedURLSearchParams.toString(); + expect(stringValue1).toBe('key1=value1'); + + const hasKey1 = typedURLSearchParams.has('key1'); + expect(hasKey1).toBe(true); + }); + + it('returns false if the key does not exist', async () => { + const typedURLSearchParams = new TypedURLSearchParams(); + + typedURLSearchParams.set('key1', 'value1'); + const stringValue1 = typedURLSearchParams.toString(); + expect(stringValue1).toBe('key1=value1'); + + const hasKey1 = typedURLSearchParams.has('key2'); + expect(hasKey1).toBe(false); + }); + }); + + describe('keys', () => { + it('returns all keys', async () => { + const typedURLSearchParams = new TypedURLSearchParams(); + + typedURLSearchParams.set('key1', 'value1'); + const stringValue1 = typedURLSearchParams.toString(); + expect(stringValue1).toBe('key1=value1'); + + typedURLSearchParams.set('key2', 'value2'); + const stringValue2 = typedURLSearchParams.toString(); + expect(stringValue2).toBe('key1=value1&key2=value2'); + + const keys = typedURLSearchParams.keys(); + const key1 = keys.next().value; + expect(key1).toBe('key1'); + const key2 = keys.next().value; + expect(key2).toBe('key2'); + }); + }); + + describe('sort', () => { + it('sorts all of the keys', async () => { + const typedURLSearchParams = new TypedURLSearchParams(); + + typedURLSearchParams.set('keyB', 'value2'); + const stringValue1 = typedURLSearchParams.toString(); + expect(stringValue1).toBe('keyB=value2'); + + typedURLSearchParams.set('keyA', 'value3'); + const stringValue2 = typedURLSearchParams.toString(); + expect(stringValue2).toBe('keyB=value2&keyA=value3'); + + typedURLSearchParams.set('keyC', 'value4'); + const stringValue3 = typedURLSearchParams.toString(); + expect(stringValue3).toBe('keyB=value2&keyA=value3&keyC=value4'); + + typedURLSearchParams.append('keyB', 'value1'); + const stringValue4 = typedURLSearchParams.toString(); + expect(stringValue4).toBe('keyB=value2&keyA=value3&keyC=value4&keyB=value1'); + + typedURLSearchParams.sort(); + const stringValue5 = typedURLSearchParams.toString(); + expect(stringValue5).toBe('keyA=value3&keyB=value2&keyB=value1&keyC=value4'); + }); + }); + + describe('values', () => { + it('returns all the values', async () => { + const typedURLSearchParams = new TypedURLSearchParams(); + + typedURLSearchParams.set('key1', 'value1'); + const stringValue1 = typedURLSearchParams.toString(); + expect(stringValue1).toBe('key1=value1'); + + typedURLSearchParams.set('key2', 'value2'); + const stringValue2 = typedURLSearchParams.toString(); + expect(stringValue2).toBe('key1=value1&key2=value2'); + + const values = typedURLSearchParams.values(); + const value1 = values.next().value; + expect(value1).toBe('value1'); + const value2 = values.next().value; + expect(value2).toBe('value2'); + }); + }); +}); diff --git a/app/src/hooks/useSearchParams.tsx b/app/src/hooks/useSearchParams.tsx new file mode 100644 index 0000000000..734cd46708 --- /dev/null +++ b/app/src/hooks/useSearchParams.tsx @@ -0,0 +1,143 @@ +import qs from 'qs'; +import { useHistory } from 'react-router'; + +/** + * A hook that provides methods for reading and writing URL search params. + * + * @example + * const { searchParams} = useSearchParams(); + * searchParams.set('key', 'value'); + * //setSearchParams(searchParams); + * + * @example + * type MyType = { key1: 'value1' | 'value2' } + * const { searchParams} = useSearchParams(); + * const key1Value = searchParams.get('key1'); + * //setSearchParams(searchParams.set('key1', 'value2')); + * + * @export + * @return {*} + */ +export function useSearchParams = Record>() { + const history = useHistory(); + + const searchParams = new TypedURLSearchParams(history.location.search); + + const setSearchParams = (urlSearchParams: TypedURLSearchParams) => { + history.push({ + ...location, + search: urlSearchParams.toString() + }); + }; + + return { + searchParams, + setSearchParams + }; +} + +/** + * An extension of URLSearchParams that wraps the original methods to provide type safety. + * + * @export + * @class TypedURLSearchParams + * @extends {URLSearchParams} + * @template ParamType + */ +export class TypedURLSearchParams< + ParamType extends Record = Record +> extends URLSearchParams { + set(key: K, value: ParamType[K]) { + super.set(key, value); + return this; + } + + /** + * Given a key and a value of unknown type: + * - If the value is null or undefined, the key is deleted. + * - If the value is not a string, it is stringified using qs.stringify and set. + * - If the value is an empty string, the key is deleted. + * - Otherwise, if the value is a non-empty string, it is set. + * + * @template K + * @param {K} key + * @param {unknown} [value] + * @return {*} + * @memberof TypedURLSearchParams + */ + setOrDelete(key: K, value?: unknown) { + if (value === null || value === undefined) { + super.delete(key); + return this; + } + + if (typeof value === 'string') { + if (value.length === 0) { + super.delete(key); + return this; + } + + super.set(key, value); + return this; + } + + if (typeof value === 'number') { + super.set(key, String(value)); + return this; + } + + // Note: the value will need to be parsed `qs.parse(value)` after being fetched via this classes `get(key)` + super.set(key, qs.stringify(value)); + return this; + } + + get(key: K) { + return super.get(key); + } + + delete(key: K) { + super.delete(key); + return this; + } + + toString() { + return super.toString(); + } + + append(key: K, value: ParamType[K]) { + super.append(key, value); + return this; + } + + entries() { + return super.entries() as IterableIterator<[K, ParamType[K]]>; + } + + forEach( + callback: (value: ParamType[K], key: K, searchParams: URLSearchParams) => void + ) { + super.forEach(callback as (value: string, key: string, searchParams: URLSearchParams) => void); + return this; + } + + getAll(key: K) { + return super.getAll(key) as K[]; + } + + has(key: K) { + return super.has(key); + } + + keys() { + return super.keys() as IterableIterator; + } + + sort() { + super.sort(); + return this; + } + + values(): IterableIterator { + return super.values() as IterableIterator; + } +} diff --git a/app/src/hooks/useTelemetryApi.ts b/app/src/hooks/useTelemetryApi.ts index 6e629206c3..3cc286a43b 100644 --- a/app/src/hooks/useTelemetryApi.ts +++ b/app/src/hooks/useTelemetryApi.ts @@ -178,7 +178,3 @@ export const useTelemetryApi = () => { processTelemetryCsvSubmission }; }; - -type TelemetryApiReturnType = ReturnType; - -export type TelemetryApiLookupFunctions = keyof TelemetryApiReturnType['devices']; // Add more options as needed. diff --git a/app/src/interfaces/useAnimalApi.interface.ts b/app/src/interfaces/useAnimalApi.interface.ts new file mode 100644 index 0000000000..02b9243480 --- /dev/null +++ b/app/src/interfaces/useAnimalApi.interface.ts @@ -0,0 +1,24 @@ +import { ApiPaginationResponseParams } from 'types/misc'; + +export interface IFindAnimalObj { + wlh_id: string | null; + animal_id: string; + sex: string; + itis_tsn: number; + itis_scientific_name: string; + critter_comment: string; + critter_id: number; + survey_id: number; + critterbase_critter_id: string; +} + +/** + * Response object for findAnimals. + * + * @export + * @interface IFindAnimalsResponse + */ +export interface IFindAnimalsResponse { + animals: IFindAnimalObj[]; + pagination: ApiPaginationResponseParams; +} diff --git a/app/src/interfaces/useCritterApi.interface.ts b/app/src/interfaces/useCritterApi.interface.ts index 5e704aac64..c0c6bcb672 100644 --- a/app/src/interfaces/useCritterApi.interface.ts +++ b/app/src/interfaces/useCritterApi.interface.ts @@ -1,6 +1,6 @@ import { ICreateCritterCollectionUnit } from 'features/surveys/view/survey-animals/animal'; import { Feature } from 'geojson'; -import { ITaxonomy } from './useTaxonomyApi.interface'; +import { IPartialTaxonomy } from './useTaxonomyApi.interface'; export interface ICritterCreate { critter_id?: string; @@ -15,9 +15,9 @@ export interface ICritterCreate { export interface ICreateEditAnimalRequest { critter_id?: string; nickname: string; - species: ITaxonomy | null; + species: IPartialTaxonomy | null; ecological_units: ICreateCritterCollectionUnit[]; - wildlife_health_id: string; + wildlife_health_id: string | null; critter_comment: string | null; } diff --git a/app/src/interfaces/useObservationApi.interface.ts b/app/src/interfaces/useObservationApi.interface.ts index 18ee4f4d19..05a861f614 100644 --- a/app/src/interfaces/useObservationApi.interface.ts +++ b/app/src/interfaces/useObservationApi.interface.ts @@ -35,7 +35,7 @@ export type StandardObservationColumns = { survey_sample_method_id: number | null; survey_sample_period_id: number | null; count: number | null; - observation_date: Date; + observation_date: string; observation_time: string; latitude: number | null; longitude: number | null; diff --git a/app/src/interfaces/useProjectApi.interface.ts b/app/src/interfaces/useProjectApi.interface.ts index c15075b738..38adfe2455 100644 --- a/app/src/interfaces/useProjectApi.interface.ts +++ b/app/src/interfaces/useProjectApi.interface.ts @@ -68,6 +68,22 @@ export interface IGetUserProjectsListResponse { project_role_permissions: string[]; } +/** + * Get surveys list response object. + * + * @export + * @interface IGetUserSurveysListResponse + */ +export interface IGetUserSurveysListResponse { + project_participation_id: number; + project_id: number; + project_name: string; + system_user_id: number; + project_role_ids: number[]; + project_role_names: string[]; + project_role_permissions: string[]; +} + /** * An interface that describes project supplementary data * @export @@ -78,20 +94,42 @@ export interface IProjectSupplementaryData { } /** - * Get projects list response object. + * Find projects response object. * * @export - * @interface IGetProjectsListResponse + * @interface IFindProjectsResponse */ -export interface IGetProjectsListResponse { +export interface IFindProjectsResponse { projects: IProjectsListItemData[]; pagination: ApiPaginationResponseParams; } export interface IProjectsListItemData { project_id: number; + /** + * The name of the project. + */ name: string; + /** + * The earliest start date of the surveys in the project. + */ + start_date: string | null; + /** + * The latest end date of the surveys in the project. + */ + end_date: string | null; + /** + * The regions of the surveys in the project. + */ regions: string[]; + /** + * The focal species of the surveys in the project. + */ + focal_species: number[]; + /** + * The types of the surveys in the project. + */ + types: number[]; } export interface IProjectUserRoles { diff --git a/app/src/interfaces/useSurveyApi.interface.ts b/app/src/interfaces/useSurveyApi.interface.ts index d4f7bd1d29..e775058042 100644 --- a/app/src/interfaces/useSurveyApi.interface.ts +++ b/app/src/interfaces/useSurveyApi.interface.ts @@ -166,12 +166,15 @@ export interface SurveyViewObject { export interface SurveyBasicFieldsObject { survey_id: number; + project_id: number; name: string; start_date: string; end_date: string | null; progress_id: number; focal_species: number[]; focal_species_names: string[]; + regions: string[]; + types: number[]; } export type SurveyUpdateObject = ISurveyUpdateObject & ISurveySiteSelectionUpdateObject; @@ -244,12 +247,12 @@ export interface ISurveySupplementaryData { } /** - * Get survey basic fields response object. + * Find surveys basic fields response object. * * @export - * @interface IGetSurveyListResponse + * @interface IFindSurveysResponse */ -export interface IGetSurveyListResponse { +export interface IFindSurveysResponse { surveys: SurveyBasicFieldsObject[]; pagination: ApiPaginationResponseParams; } @@ -265,14 +268,6 @@ export interface IGetSurveyForViewResponse { surveySupplementaryData: SurveySupplementaryData; } -export interface IGetSurveyDetailsResponse { - id: number; - name: string; - start_date: string; - end_date: string; - completion_status: string; -} - export interface IGetSpecies { focal_species: ITaxonomy[]; ancillary_species: ITaxonomy[]; diff --git a/app/src/interfaces/useTaxonomyApi.interface.ts b/app/src/interfaces/useTaxonomyApi.interface.ts index b80606bdae..27ce499345 100644 --- a/app/src/interfaces/useTaxonomyApi.interface.ts +++ b/app/src/interfaces/useTaxonomyApi.interface.ts @@ -9,10 +9,15 @@ export interface IItisSearchResponse { usage: string; } -export interface ITaxonomy { +export type ITaxonomy = { tsn: number; commonNames: string[]; scientificName: string; rank: string; kingdom: string; -} +}; + +// TODO: Remove and replace instances of `IPartialTaxonomy` with `ITaxonomy` once the BioHub API endpoint is updated +// to return the extra `rank` and `kingdom` fields, which are currently only available in some of the BioHub taxonomy +// endpoints. +export type IPartialTaxonomy = Partial & Pick; diff --git a/app/src/interfaces/useTelemetryApi.interface.ts b/app/src/interfaces/useTelemetryApi.interface.ts new file mode 100644 index 0000000000..c04bf5e3b5 --- /dev/null +++ b/app/src/interfaces/useTelemetryApi.interface.ts @@ -0,0 +1,26 @@ +import { ApiPaginationResponseParams } from 'types/misc'; + +export interface IFindTelementryObj { + telemetry_id: string; + acquisition_date: string | null; + latitude: number | null; + longitude: number | null; + telemetry_type: string; + device_id: number; + bctw_deployment_id: string; + critter_id: number; + deployment_id: number; + critterbase_critter_id: string; + animal_id: string | null; +} + +/** + * Response object for findTelemetry. + * + * @export + * @interface IFindTelemetryResponse + */ +export interface IFindTelemetryResponse { + telemetry: IFindTelementryObj[]; + pagination: ApiPaginationResponseParams; +} diff --git a/app/src/pages/landing/LandingActions.tsx b/app/src/pages/landing/LandingActions.tsx index eeff7e0747..0a5294071f 100644 --- a/app/src/pages/landing/LandingActions.tsx +++ b/app/src/pages/landing/LandingActions.tsx @@ -143,7 +143,7 @@ const LandingActions = () => { {mayViewProjects && ( - + setOpenImportDialog(false)} + onUpload={handleImportAnimals} + uploadButtonLabel="Import" + FileUploadProps={{ + dropZoneProps: { maxNumFiles: 1, acceptedFileExtensions: '.csv' }, + status: UploadFileStatus.STAGED }} - aria-label="header-settings" - disabled={!props.checkboxSelectedIdsLength} - onClick={props.handleHeaderMenuClick} - title="Bulk Actions"> - - - + /> + + + Animals ‌ + + ({props.animalCount}) + + + + + + + + + ); }; diff --git a/app/src/hooks/api/useSurveyApi.ts b/app/src/hooks/api/useSurveyApi.ts index 3af8055895..3187b31704 100644 --- a/app/src/hooks/api/useSurveyApi.ts +++ b/app/src/hooks/api/useSurveyApi.ts @@ -534,6 +534,29 @@ const useSurveyApi = (axios: AxiosInstance) => { return data; }; + /** + * Bulk upload Critters from CSV. + * + * @async + * @param {File} file - Critters CSV. + * @param {number} projectId + * @param {number} surveyId + * @returns {Promise} + */ + const importCrittersFromCsv = async ( + file: File, + projectId: number, + surveyId: number + ): Promise<{ survey_critter_ids: number[] }> => { + const formData = new FormData(); + + formData.append('media', file); + + const { data } = await axios.post(`/api/project/${projectId}/survey/${surveyId}/critters/import`, formData); + + return data; + }; + return { createSurvey, getSurveyForView, @@ -557,7 +580,8 @@ const useSurveyApi = (axios: AxiosInstance) => { getDeploymentsInSurvey, updateDeployment, getCritterTelemetry, - removeDeployment + removeDeployment, + importCrittersFromCsv }; }; diff --git a/app/src/hooks/api/useTaxonomyApi.test.tsx b/app/src/hooks/api/useTaxonomyApi.test.tsx index 7974050119..c891f5402a 100644 --- a/app/src/hooks/api/useTaxonomyApi.test.tsx +++ b/app/src/hooks/api/useTaxonomyApi.test.tsx @@ -44,13 +44,13 @@ describe('useTaxonomyApi', () => { searchResponse: [ { tsn: '1', - commonNames: ['something'], - scientificName: 'something' + commonNames: ['Something'], + scientificName: 'Something' }, { tsn: '2', - commonNames: ['anything'], - scientificName: 'anything' + commonNames: ['Anything'], + scientificName: 'Anything' } ] }; @@ -76,13 +76,13 @@ describe('useTaxonomyApi', () => { searchResponse: [ { tsn: '3', - commonNames: ['something'], - scientificName: 'something' + commonNames: ['Something'], + scientificName: 'Something' }, { tsn: '4', - commonNames: ['anything'], - scientificName: 'anything' + commonNames: ['Anything'], + scientificName: 'Anything' } ] }; diff --git a/app/src/hooks/api/useTaxonomyApi.ts b/app/src/hooks/api/useTaxonomyApi.ts index 84b05ee5d6..6aa8d92d52 100644 --- a/app/src/hooks/api/useTaxonomyApi.ts +++ b/app/src/hooks/api/useTaxonomyApi.ts @@ -1,5 +1,6 @@ import { useConfigContext } from 'hooks/useContext'; import { IPartialTaxonomy, ITaxonomy } from 'interfaces/useTaxonomyApi.interface'; +import { startCase } from 'lodash-es'; import qs from 'qs'; import useAxios from './useAxios'; @@ -28,7 +29,7 @@ const useTaxonomyApi = () => { } }); - return data.searchResponse; + return parseSearchResponse(data.searchResponse); }; /** @@ -50,7 +51,7 @@ const useTaxonomyApi = () => { return []; } - return data.searchResponse; + return parseSearchResponse(data.searchResponse); } catch (error) { throw new Error('Failed to fetch Taxon records.'); } @@ -62,4 +63,19 @@ const useTaxonomyApi = () => { }; }; +/** + * Parses the taxon search response into start case. + * + * @template T + * @param {T[]} searchResponse - Array of Taxonomy objects + * @returns {T[]} Correctly cased Taxonomy + */ +const parseSearchResponse = (searchResponse: T[]): T[] => { + return searchResponse.map((taxon) => ({ + ...taxon, + commonNames: taxon.commonNames.map((commonName) => startCase(commonName)), + scientificName: startCase(taxon.scientificName) + })); +}; + export default useTaxonomyApi; diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000000..ba33e3cf6c --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "biohubbc", + "lockfileVersion": 3, + "requires": true, + "packages": {} +}

}5j$$p_ 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 diff --git a/README/images/templates/successful migration.png b/README/images/templates/successful migration.png deleted file mode 100644 index ddf33d3af81d4048cbe87478abade322705689a6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 39929 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 y_?>!^hYBbUp)LZ{m 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 SjUV}gQ@}{yOb-XU8Tv2u$1?f= diff --git a/api/.docker/api/Dockerfile b/api/.docker/api/Dockerfile index 744be532ec..4aa5b2322f 100644 --- a/api/.docker/api/Dockerfile +++ b/api/.docker/api/Dockerfile @@ -1,3 +1,7 @@ +# ######################################################################################################## +# This DockerFile is used for local development (via docker-compose) only. +# ######################################################################################################## + FROM node:18 ENV HOME=/opt/app-root/src diff --git a/api/Dockerfile b/api/Dockerfile index 36102914ad..93ea2bca18 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -1,4 +1,6 @@ -# This DockerFile is used for Openshift deployments. +# ######################################################################################################## +# This DockerFile is used for Openshift deployments only. +# ######################################################################################################## FROM node:18 diff --git a/api/README.md b/api/README.md index d6185de2c9..2724f9c2de 100644 --- a/api/README.md +++ b/api/README.md @@ -11,17 +11,17 @@ # API Specification -The root API schema is defined in `./src/openapi/api.ts`. +The root API schema is defined in `./src/openapi/root-api-doc.ts`. If this project is running in docker you can view the beautified api docs at: `http://localhost:6100/api-docs/`. - The raw api-docs are available at: `http://localhost:6100/raw-api-docs/`. -This project uses npm package `express-openapi` via `./app.ts` to automatically generate the express server and its routes, based on the contents of the `./src/openapi/api.ts` and the `./src/path/` content. +This project uses npm package `express-openapi` via `./app.ts` to automatically generate the express server and its routes, based on the contents of the `./src/openapi/root-api-doc.ts` and the `./src/path/*` content. - The endpoint paths are defined based on the folder structure of the `./src/paths/` folder. - Example: `/api/activity` is handled by `./src/paths/activity.ts` - - Example: `/api/activity/{activityId} ` is handled by `./src/paths/activity/{activityId}.ts` + - Example: `/api/activity/23 ` is handled by `./src/paths/activity/{activityId}.ts` Recommend reviewing the [Open API Specification](https://swagger.io/docs/specification/about/) before making any changes to any of the API schemas. @@ -70,6 +70,12 @@ npm run lint-fix npm run format-fix ``` +For convenience, you can also both lint-fix and format-fix in one command. + +``` +npm run fix +``` +
# Logging @@ -80,7 +86,7 @@ A centralized logger has been created (see `api/utils/logger.ts`). The loggers log level can be configured via an environment variable: `LOG_LEVEL` -Set this variable to one of: `error`, `warn`, `info`, `debug` +Set this variable to one of: `silent`, `error`, `warn`, `info`, `debug`, `silly` Default value: `info` @@ -100,18 +106,8 @@ log.warn({ label: 'functionName', message: 'Used when logging soft errors. For e log.info({ label: 'functionName', message: 'General log messages about the state of the application' }); log.debug({ label: 'functionName', message: 'Useful for logging objects and other developer data', aJSONObjectToPrint, anotherObject); -or -log.debug({ label: 'functionName', message: 'Useful for logging objects and other developer data', someLabel: aJSONObjectToPrint, anotherObject }); -``` -Supported log properties: - -``` -- timestamp: overwrite the default time of `now` with your own timestamp. -- level: overwrite the default level (via log.()) with your own level string. -- label: adds an additional label to the log message. -- message: a log message. -- : any additional object properties will be JSON.stringify'd and appended to the log message. +log.debug({ label: 'functionName', message: 'Useful for logging objects and other developer data', someLabel: aJSONObjectToPrint, anotherObject }); ```
@@ -131,12 +127,6 @@ Supported log properties: npm test ``` -- Run the unit tests in watch mode (will re-run the tests on code changes). - - ``` - npm run test-watch - ``` - - Run the unit test coverage report The coverage report will be output to `./coverage` @@ -157,10 +147,3 @@ See [Mocha](https://mochajs.org) for documentation on writing tests. This project uses [Keycloak](https://www.keycloak.org/) to handle authentication. # Troubleshooting and Known Issues - -### Observations validation - -There is a known issue with `JSONPath from 'jsonpath-plus'` upgraded from 'jsonpath'. The issue is due to the difference between qoutation marks ["", ''] during the validation schema parser. The issue may be resolved and is updated to using "" in all locations. - -- `api\src\utils\media\validation\validation-schema-parser.ts` -- https://github.com/JSONPath-Plus/JSONPath/issues/160 diff --git a/app/.docker/app/Dockerfile b/app/.docker/app/Dockerfile index bf5b6e894f..ee71981b8b 100644 --- a/app/.docker/app/Dockerfile +++ b/app/.docker/app/Dockerfile @@ -1,3 +1,7 @@ +# ######################################################################################################## +# This DockerFile is used for local development (via docker-compose) only. +# ######################################################################################################## + FROM node:18 ENV HOME=/opt/app-root/src diff --git a/app/Dockerfile b/app/Dockerfile index d9af3cb6ea..fa840fa6d3 100644 --- a/app/Dockerfile +++ b/app/Dockerfile @@ -1,4 +1,6 @@ -# This DockerFile is used for Openshift deployments. +# ######################################################################################################## +# This DockerFile is used for Openshift deployments only. +# ######################################################################################################## FROM node:18 diff --git a/app/README.md b/app/README.md index 44222572b9..702edd43b8 100644 --- a/app/README.md +++ b/app/README.md @@ -27,12 +27,6 @@ React: https://reactjs.org/docs/getting-started.html npm run test ``` -- Run the unit tests in watch mode (will re-run the tests on code changes). - - ``` - npm run test-watch - ``` - - Run the unit test coverage report ``` diff --git a/containers/n8n/Dockerfile b/containers/n8n/Dockerfile deleted file mode 100644 index f4db5f6ad0..0000000000 --- a/containers/n8n/Dockerfile +++ /dev/null @@ -1,21 +0,0 @@ -FROM registry.access.redhat.com/ubi8/nodejs-16:latest -ARG N8N_VERSION -ENV HOME=/opt/app-root/src \ - TZ=America/Vancouver - -RUN yum -y update \ - && yum -y install yum-utils \ - && rpm --import http://dl.fedoraproject.org/pub/epel/RPM-GPG-KEY-EPEL-8 \ - && yum -y install https://dl.fedoraproject.org/pub/epel/epel-release-latest-8.noarch.rpm - -RUN yum install -y imagemagick -RUN mkdir -p $HOME -WORKDIR $HOME -RUN npm install -g n8n -RUN npm audit fix --force -ENV PATH ${HOME}/node_modules/.bin/:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:$PATH -VOLUME ${HOME} -VOLUME [ "/data" ] -WORKDIR /data -EXPOSE 5678/tcp -CMD ["n8n"] diff --git a/containers/n8n/README.md b/containers/n8n/README.md deleted file mode 100644 index bcc4cba582..0000000000 --- a/containers/n8n/README.md +++ /dev/null @@ -1,42 +0,0 @@ -# N8N - -This folder contains the OpenShift templates required in order to build and deploy an instance of N8N onto OpenShift. These templates were designed with the assumption that you will be building and deploying the N8N application within the same project. We will be running with the assumption that this N8N instance will be co-located in the same project as the database it is expecting to poll from. - -## Build N8N - -You are supposed to build your n8n image in your <..>-tools namespace and then when you deploy you'll re-use that image. - -While N8N does provide a Docker image [here](https://hub.docker.com/r/n8nio/n8n), it is not compatible with OpenShift due to the image assuming it has root privileges. Instead, we build a simple NodeJS image based off Redhat's ubi8/nodejs-12 image where the N8N application can execute without needing privilege escalation. In order to build a N8N image in your project, process and create the build config template using the following command (replace anything in angle brackets with the correct value): - -```sh -oc process -n $NAMESPACE -f n8n.bc.yaml -p N8N_VERSION=$N8N_VERSION N8N_IMAGE_NAMESPACE=$N8N_IMAGE_NAMESPACE -o yaml | oc apply -n $NAMESPACE -f - - -export NAMESPACE=af2668-test -export N8N_IMAGE_NAMESPACE=af2668-tools -export N8N_VERSION=0.131.0 - -oc process -n $NAMESPACE -f n8n.bc.yaml -p N8N_VERSION=$N8N_VERSION -p N8N_IMAGE_NAMESPACE=$N8N_IMAGE_NAMESPACE -o yaml | oc apply -n $NAMESPACE -f - - -``` - -This will create an ImageStream called `n8n`. This image is built on top of ubi8/nodejs-12, and will have N8N installed on it. - -## Deploy N8N - -Once your N8N image has been successfully built, you can then deploy it in your project by using the following command (replace anything in angle brackets with the correct value): - -```sh -export NAMESPACE=af2668-dev -export N8N_IMAGE_NAMESPACE=af2668-tools -oc process -n $NAMESPACE -f n8n.dc.yaml NAMESPACE=$NAMESPACE -o yaml | oc apply -n $NAMESPACE -f - -``` - -This will create a new Secret, Service, Route, Persistent Volume Claim, and Deployment Configuration. This Deployment Config has liveliness and readiness checks built in, and handles image updates via Recreation strategy. - -## Initial Setup - -Once N8N is up and functional (this will take between 3 to 5 minutes), you will have to do initial setup manually. We suggest you populate the email account and password as whatever the `n8n-secret` secret contains in the `admin-user` and `admin-password` fields respectively. You may be asked to connect to your existing Postgres (or equivalent) database during this time, so you will need to refer to your other secrets or other deployment secrets in order to ensure N8N can properly connect to it via JDBC connection. - -## Notes - -In general, N8N should generally take up very little CPU (<0.01 cores) and float between 700 to 800mb of memory usage during operation. The template has some reasonable requests and limits set for both CPU and Memory, but you may change it should your needs be different. For inspecting the official N8N documentation [here](https://docs.n8n.io/). diff --git a/containers/n8n/n8n.bc.yaml b/containers/n8n/n8n.bc.yaml deleted file mode 100644 index 59aeb58545..0000000000 --- a/containers/n8n/n8n.bc.yaml +++ /dev/null @@ -1,87 +0,0 @@ ---- -kind: Template -apiVersion: template.openshift.io/v1 -labels: - app: n8n - build: "${NAME}" - template: "${NAME}-bc-template" -metadata: - name: n8n -objects: - - kind: ImageStream - apiVersion: image.openshift.io/v1 - metadata: - name: "${NAME}" - spec: - lookupPolicy: - local: false - - kind: BuildConfig - apiVersion: build.openshift.io/v1 - metadata: - name: "${NAME}" - labels: - buildconfig: "${NAME}" - spec: - completionDeadlineSeconds: 600 - failedBuildsHistoryLimit: 3 - successfulBuildsHistoryLimit: 3 - output: - to: - kind: ImageStreamTag - name: "${NAME}:${VERSION}" - postCommit: {} - resources: - limits: - cpu: 2000m - memory: 2Gi - requests: - cpu: 1000m - memory: 1Gi - runPolicy: SerialLatestOnly - source: - dockerfile: | - FROM BuildConfig - ARG N8N_VERSION - ENV HOME=/opt/app-root/src \ - TZ=America/Vancouver - USER root - RUN rpm -ivh https://dl.fedoraproject.org/pub/epel/epel-release-latest-8.noarch.rpm - RUN yum install -y GraphicsMagick - RUN mkdir -p $HOME - WORKDIR $HOME - RUN npm install -g n8n - ENV PATH ${HOME}/node_modules/.bin/:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:$PATH - VOLUME ${HOME} - VOLUME [ "/data" ] - WORKDIR /data - EXPOSE 5678/tcp - USER 1001 - CMD ["n8n"] - type: Dockerfile - strategy: - dockerStrategy: - buildArgs: - - name: N8N_VERSION - value: "${N8N_VERSION}" - from: - kind: DockerImage - name: artifacts.developer.gov.bc.ca/redhat-docker-remote/ubi8/nodejs-16:latest - type: Docker - triggers: - - type: ConfigChange -parameters: - - name: NAME - displayName: Name - description: The name assigned to all of the objects defined in this template. - required: true - value: n8n - - name: N8N_VERSION - displayName: N8N Version - description: Version of N8N to use - required: true - value: latest - - name: VERSION - displayName: Image version tag - description: The version tag of the built image - required: true - value: latest diff --git a/containers/n8n/n8n.dc.yaml b/containers/n8n/n8n.dc.yaml deleted file mode 100644 index bdc6dd0037..0000000000 --- a/containers/n8n/n8n.dc.yaml +++ /dev/null @@ -1,585 +0,0 @@ ---- -kind: Template -apiVersion: template.openshift.io/v1 -labels: - app: "${NAME}" - template: "${NAME}-dc-template" -metadata: - name: n8n-deploy -objects: - - kind: Secret - apiVersion: v1 - metadata: - annotations: - template.openshift.io/expose-username: "{.data[username]}" - template.openshift.io/expose-password: "{.data[password]}" - template.openshift.io/expose-encryption_key: "{.data[encryption-key]}" - labels: - template: "${NAME}-template" - app.kubernetes.io/part-of: "${NAME}-app" - name: "${NAME}" - namespace: "${NAMESPACE}" - stringData: - username: "${N8N_USER}" - password: "${N8N_PASSWORD}" - encryption-key: "${N8N_ENCRYPTION_KEY}" - type: Opaque - - kind: Secret - apiVersion: v1 - metadata: - annotations: - template.openshift.io/expose-database_name: '{.data[''database-name'']}' - template.openshift.io/expose-password: '{.data[''database-password'']}' - template.openshift.io/expose-username: '{.data[''database-user'']}' - labels: - template: "${NAME}-template" - app.kubernetes.io/part-of: "${NAME}-app" - app: "${NAME}" - name: "postgresql-${NAME}" - namespace: "${NAMESPACE}" - stringData: - database-name: "${NAME}" - database-password: "${DATABASE_PASSWORD}" - database-user: "${NAME}" - type: Opaque - - kind: Secret - apiVersion: v1 - metadata: - annotations: - template.openshift.io/expose-password: '{.data[''database-password'']}' - labels: - template: "${NAME}-template" - app.kubernetes.io/part-of: "${NAME}-app" - name: "redis-${NAME}" - namespace: "${NAMESPACE}" - stringData: - database-password: "${REDIS_DATABASE_PASSWORD}" - type: Opaque - - kind: Service - apiVersion: v1 - metadata: - labels: - app: "${NAME}" - app.kubernetes.io/component: "${NAME}" - app.kubernetes.io/instance: "${NAME}" - app.kubernetes.io/part-of: "${NAME}-app" - name: "${NAME}" - namespace: "${NAMESPACE}" - spec: - ports: - - name: 5678-tcp - port: 5678 - protocol: TCP - targetPort: 5678 - selector: - name: "${NAME}" - sessionAffinity: None - type: ClusterIP - - kind: Service - apiVersion: v1 - metadata: - labels: - app: "${NAME}" - app.kubernetes.io/part-of: "${NAME}-app" - name: "postgresql-${NAME}" - namespace: "${NAMESPACE}" - spec: - ports: - - name: postgresql - port: 5432 - protocol: TCP - targetPort: 5432 - selector: - name: "postgresql-${NAME}" - sessionAffinity: None - type: ClusterIP - - kind: Service - apiVersion: v1 - metadata: - labels: - app: "${NAME}" - app.kubernetes.io/part-of: "${NAME}-app" - name: "redis-${NAME}" - namespace: "${NAMESPACE}" - spec: - ports: - - name: "redis-${NAME}" - port: 6379 - protocol: TCP - targetPort: 6379 - selector: - name: "redis-${NAME}" - sessionAffinity: None - type: ClusterIP - - kind: Route - apiVersion: route.openshift.io/v1 - metadata: - name: "${NAME}" - labels: - app.kubernetes.io/part-of: "${NAME}-app" - spec: - host: "${NAME}-${NAMESPACE}.apps.silver.devops.gov.bc.ca" - tls: - insecureEdgeTerminationPolicy: Redirect - termination: edge - path: "/" - to: - kind: Service - name: "${NAME}" - port: - targetPort: 5678-tcp - wildcardPolicy: None - - kind: PersistentVolumeClaim - apiVersion: v1 - metadata: - annotations: - volume.beta.kubernetes.io/storage-provisioner: csi.trident.netapp.io - volume.beta.kubernetes.io/storage-class: netapp-file-standard - labels: - app.kubernetes.io/part-of: "${NAME}-app" - name: "${NAME}-data" - namespace: "${NAMESPACE}" - spec: - accessModes: - - ReadWriteMany - resources: - requests: - storage: 1Gi - storageClassName: netapp-file-standard - volumeMode: Filesystem - - kind: PersistentVolumeClaim - apiVersion: v1 - metadata: - annotations: - volume.beta.kubernetes.io/storage-provisioner: csi.trident.netapp.io - volume.beta.kubernetes.io/storage-class: netapp-file-standard - labels: - app.kubernetes.io/part-of: "${NAME}-app" - name: "redis-${NAME}" - namespace: "${NAMESPACE}" - spec: - accessModes: - - ReadWriteOnce - resources: - requests: - storage: 1Gi - storageClassName: netapp-file-standard - volumeMode: Filesystem - - kind: PersistentVolumeClaim - apiVersion: v1 - metadata: - annotations: - volume.beta.kubernetes.io/storage-provisioner: csi.trident.netapp.io - volume.beta.kubernetes.io/storage-class: netapp-file-standard - labels: - app.kubernetes.io/part-of: "${NAME}-app" - name: "postgresql-${NAME}" - namespace: "${NAMESPACE}" - spec: - accessModes: - - ReadWriteMany - resources: - requests: - storage: "${PVC_SIZE}" - storageClassName: netapp-file-standard - volumeMode: Filesystem - - kind: DeploymentConfig - apiVersion: apps.openshift.io/v1 - metadata: - annotations: - template.alpha.openshift.io/wait-for-ready: "true" - labels: - app.kubernetes.io/part-of: "${NAME}-app" - template: "${NAME}-template" - role: db - name: "redis-${NAME}" - namespace: "${NAMESPACE}" - spec: - replicas: 1 - revisionHistoryLimit: 10 - selector: - name: "redis-${NAME}" - strategy: - activeDeadlineSeconds: 21600 - recreateParams: - timeoutSeconds: 600 - resources: {} - type: Recreate - template: - metadata: - labels: - name: "redis-${NAME}" - spec: - containers: - - env: - - name: REDIS_PASSWORD - valueFrom: - secretKeyRef: - key: database-password - name: "redis-${NAME}" - imagePullPolicy: IfNotPresent - image: image-registry.openshift-image-registry.svc:5000/openshift/redis:latest - livenessProbe: - failureThreshold: 3 - initialDelaySeconds: 30 - periodSeconds: 10 - successThreshold: 1 - tcpSocket: - port: 6379 - timeoutSeconds: 1 - name: "redis-${NAME}" - ports: - - containerPort: 6379 - protocol: TCP - readinessProbe: - exec: - command: - - /bin/sh - - -i - - -c - - test "$(redis-cli -h 127.0.0.1 -a $REDIS_PASSWORD ping)" == "PONG" - failureThreshold: 3 - initialDelaySeconds: 5 - periodSeconds: 10 - successThreshold: 1 - timeoutSeconds: 1 - resources: - limits: - memory: 512Mi - securityContext: - capabilities: {} - privileged: false - terminationMessagePath: /dev/termination-log - terminationMessagePolicy: File - volumeMounts: - - mountPath: /var/lib/redis/data - name: "redis-${NAME}-data" - dnsPolicy: ClusterFirst - restartPolicy: Always - schedulerName: default-scheduler - securityContext: {} - terminationGracePeriodSeconds: 30 - volumes: - - name: "redis-${NAME}-data" - persistentVolumeClaim: - claimName: "redis-${NAME}" - test: false - triggers: - - type: ImageChange - imageChangeParams: - automatic: true - containerNames: - - "redis-${NAME}" - from: - kind: ImageStreamTag - name: redis:latest - namespace: openshift - - type: ConfigChange - - kind: DeploymentConfig - apiVersion: apps.openshift.io/v1 - metadata: - annotations: - template.alpha.openshift.io/wait-for-ready: "true" - labels: - app.kubernetes.io/part-of: "${NAME}-app" - template: "${NAME}-template" - role: db - name: "postgresql-${NAME}" - namespace: "${NAMESPACE}" - spec: - replicas: 1 - revisionHistoryLimit: 10 - selector: - name: "postgresql-${NAME}" - strategy: - activeDeadlineSeconds: 21600 - recreateParams: - timeoutSeconds: 600 - resources: {} - type: Recreate - template: - metadata: - labels: - name: "postgresql-${NAME}" - spec: - containers: - - env: - - name: POSTGRESQL_USER - valueFrom: - secretKeyRef: - key: database-user - name: "postgresql-${NAME}" - - name: POSTGRESQL_PASSWORD - valueFrom: - secretKeyRef: - key: database-password - name: "postgresql-${NAME}" - - name: POSTGRESQL_DATABASE - valueFrom: - secretKeyRef: - key: database-name - name: "postgresql-${NAME}" - imagePullPolicy: IfNotPresent - image: artifacts.developer.gov.bc.ca/docker-remote/postgres:13.6 - livenessProbe: - exec: - command: - - /usr/libexec/check-container - - --live - failureThreshold: 3 - initialDelaySeconds: 120 - periodSeconds: 10 - successThreshold: 1 - timeoutSeconds: 10 - name: "postgresql-${NAME}" - ports: - - containerPort: 5432 - protocol: TCP - readinessProbe: - exec: - command: - - /usr/libexec/check-container - failureThreshold: 3 - initialDelaySeconds: 5 - periodSeconds: 10 - successThreshold: 1 - timeoutSeconds: 1 - resources: - limits: - memory: 512Mi - securityContext: - capabilities: {} - privileged: false - terminationMessagePath: /dev/termination-log - terminationMessagePolicy: File - volumeMounts: - - mountPath: /var/lib/pgsql/data - name: "postgresql-${NAME}-data" - dnsPolicy: ClusterFirst - restartPolicy: Always - schedulerName: default-scheduler - securityContext: {} - terminationGracePeriodSeconds: 30 - volumes: - - name: "postgresql-${NAME}-data" - persistentVolumeClaim: - claimName: "postgresql-${NAME}" - test: false - triggers: - - imageChangeParams: - automatic: true - containerNames: - - "postgresql-${NAME}" - from: - kind: ImageStreamTag - name: postgresql:latest - namespace: openshift - type: ImageChange - - type: ConfigChange - - kind: DeploymentConfig - apiVersion: apps.openshift.io/v1 - metadata: - annotations: - template.alpha.openshift.io/wait-for-ready: "true" - labels: - app.kubernetes.io/part-of: "${NAME}-app" - template: "${NAME}-template" - role: api - name: "${NAME}" - namespace: "${NAMESPACE}" - spec: - replicas: 1 - revisionHistoryLimit: 10 - selector: - name: "${NAME}" - strategy: - activeDeadlineSeconds: 21600 - resources: {} - rollingParams: - intervalSeconds: 1 - maxSurge: 25% - maxUnavailable: 25% - timeoutSeconds: 600 - updatePeriodSeconds: 1 - type: Rolling - template: - metadata: - labels: - name: "${NAME}" - spec: - containers: - - env: - - name: DB_TYPE - value: postgresdb - - name: DB_POSTGRESDB_HOST - value: "postgresql-${NAME}" - - name: DB_POSTGRESDB_USER - valueFrom: - secretKeyRef: - key: database-user - name: "postgresql-${NAME}" - - name: DB_POSTGRESDB_PASSWORD - valueFrom: - secretKeyRef: - key: database-password - name: "postgresql-${NAME}" - - name: DB_POSTGRESDB_DATABASE - valueFrom: - secretKeyRef: - key: database-name - name: "postgresql-${NAME}" - - name: DB_POSTGRESDB_PORT - value: "5432" - - name: DB_POSTGRESDB_SCHEMA - value: public - - name: N8N_BASIC_AUTH_ACTIVE - value: "true" - - name: N8N_BASIC_AUTH_USER - valueFrom: - secretKeyRef: - key: username - name: "${NAME}" - - name: N8N_BASIC_AUTH_PASSWORD - valueFrom: - secretKeyRef: - key: password - name: "${NAME}" - - name: NODE_ENV - value: production - - name: N8N_PORT - value: "5678" - - name: QUEUE_BULL_REDIS_HOST - value: "redis-${NAME}" - - name: QUEUE_BULL_REDIS_PORT - value: "6379" - - name: QUEUE_BULL_REDIS_TIMEOUT_THRESHOLD - value: "20" - - name: QUEUE_RECOVERY_INTERVAL - value: "10" - - name: QUEUE_BULL_REDIS_DB - value: "0" - - name: QUEUE_BULL_REDIS_PASSWORD - valueFrom: - secretKeyRef: - key: database-password - name: "redis-${NAME}" - - name: N8N_ENCRYPTION_KEY - valueFrom: - secretKeyRef: - key: encryption-key - name: "${NAME}" - - name: WEBHOOK_TUNNEL_URL - value: "https://${NAME}-${NAMESPACE}.apps.silver.devops.gov.bc.ca/" - - name: EXECUTIONS_MODE - value: queue - - name: POD_NAMESPACE - valueFrom: - fieldRef: - apiVersion: v1 - fieldPath: metadata.namespace - - name: GENERIC_TIMEZONE - value: America/Vancouver - image: "image-registry.openshift-image-registry.svc:5000/${N8N_IMAGE_NAMESPACE}/${NAME}:latest" - imagePullPolicy: IfNotPresent - name: n8n - resources: {} - terminationMessagePath: /dev/termination-log - terminationMessagePolicy: File - volumeMounts: - - mountPath: /data - name: "${NAME}-data" - dnsPolicy: ClusterFirst - restartPolicy: Always - schedulerName: default-scheduler - securityContext: {} - terminationGracePeriodSeconds: 30 - volumes: - - name: "${NAME}-data" - persistentVolumeClaim: - claimName: "${NAME}-data" - test: false - triggers: - - imageChangeParams: - automatic: true - containerNames: - - "${NAME}" - from: - kind: ImageStreamTag - name: "${NAME}:latest" - namespace: "${N8N_IMAGE_NAMESPACE}" - type: ImageChange - - type: ConfigChange - -parameters: - - name: NAME - displayName: Name - description: The name assigned to all of the objects defined in this template. - required: true - value: n8n - - name: NAMESPACE - description: Target namespace reference (i.e. '9f0fbe-dev') - displayName: Namespace - required: true - value: af2668-test - - name: N8N_IMAGE_NAMESPACE - description: Target namespace reference (i.e. '9f0fbe-dev') for the N8N image - displayName: Namespace - required: true - value: af2668-tools - - name: CPU_REQUEST - description: Minimal CPU needed to run - displayName: CPU Request - value: 10m - - name: CPU_LIMIT - description: Maximum CPU allowed to use - displayName: CPU Limit - value: 200m - - name: MEMORY_REQUEST - description: Minimal amount of memory needed to run - displayName: Memory Request - value: 512Mi - - name: MEMORY_LIMIT - description: Maximum amount of memory allowed to use - displayName: Memory Limit - value: 1Gi - - name: PVC_SIZE - description: Amount of disk space needed for persistence - displayName: PVC Size - required: true - value: 1Gi - - name: N8N_USER - displayName: N8N User name - description: The name associated with the n8n basic user - required: true - value: n8n - - name: N8N_PASSWORD - displayName: N8N Password - description: - The password for the n8n basic user. Requires 2 upper, 2 lower, - 1 special, 1 numeric chars and minimum 8 char length - generate: expression - from: "[a-zA-Z0-9]{16}" - required: true - - name: N8N_ENCRYPTION_KEY - displayName: N8N Encryption Key - description: - The encryption key for n8n. - generate: expression - from: "[a-zA-Z0-9]{8}!@#$%^&_[a-zA-Z0-9]{8}" - required: true - - name: DATABASE_PASSWORD - displayName: Database Password - description: - A minimum 16 character password that is generated in the target database, - and then copied over into this field. - generate: expression - from: "[a-zA-Z0-9]{16}" - required: true - - name: REDIS_DATABASE_PASSWORD - displayName: Database Password - description: - A minimum 16 character password that is generated in the target database, - and then copied over into this field. - generate: expression - from: "[a-zA-Z0-9]{16}" - required: true diff --git a/containers/postgres12-postgis31/openshift/postgresql12-postgis31-oracle-fdw.bc.yaml b/containers/postgres12-postgis31/openshift/postgresql12-postgis31-oracle-fdw.bc.yaml index 457a9152ef..bbfdc825cc 100644 --- a/containers/postgres12-postgis31/openshift/postgresql12-postgis31-oracle-fdw.bc.yaml +++ b/containers/postgres12-postgis31/openshift/postgresql12-postgis31-oracle-fdw.bc.yaml @@ -3,6 +3,9 @@ kind: Template apiVersion: template.openshift.io/v1 metadata: name: ${NAME}-build-template + annotations: + description: | + This template is used to create a build configuration that generates a PostgreSQL 12 with PostGIS 3.1 image. parameters: - name: NAME displayName: Name diff --git a/database/.docker/db/Dockerfile b/database/.docker/db/Dockerfile index 72266819d2..b0808f9bfa 100644 --- a/database/.docker/db/Dockerfile +++ b/database/.docker/db/Dockerfile @@ -1,4 +1,6 @@ -# This dockerfile is used for both local development and Openshift deployments. +# ######################################################################################################## +# This DockerFile is used for local development (via docker-compose) only. +# ######################################################################################################## ARG POSTGRES_VERSION=12.5 diff --git a/database/.docker/db/Dockerfile.setup b/database/.docker/db/Dockerfile.setup index 47570b16a4..2c564f66c6 100644 --- a/database/.docker/db/Dockerfile.setup +++ b/database/.docker/db/Dockerfile.setup @@ -1,4 +1,6 @@ -# This dockerfile is used for both local development and Openshift deployments. +# ######################################################################################################## +# This DockerFile is used for both Openshift deployments and local development (via docker-compose). +# ######################################################################################################## FROM node:18 diff --git a/env_config/env.docker b/env_config/env.docker index 8d12854dec..b855ad13eb 100644 --- a/env_config/env.docker +++ b/env_config/env.docker @@ -1,7 +1,14 @@ # ------------------------------------------------------------------------------ -# Notes +# These environment variables are only used for local development. # -# - Exposed Ports/URLs +# For more information on environment variables in general, see the root README.md. +# +# These env vars are automatically read by the makefile (when running make commands). +# +# Newly added environment variables need to be added to the docker-compose file, +# under whichever service needs them (api, app, etc) +# +# Exposed Ports/URLs # - Certain ports/urls are exposed in docker-compose and may conflict with other # docker-containers if they are exposing the same ports/urls. # diff --git a/n8n/.docker/n8n/Dockerfile.export b/n8n/.docker/n8n/Dockerfile.export deleted file mode 100644 index 1ac8731fd1..0000000000 --- a/n8n/.docker/n8n/Dockerfile.export +++ /dev/null @@ -1,21 +0,0 @@ -FROM node:14 - -# set variables -ENV HOME_ROOT=/opt/app-root -ENV HOME=/opt/app-root/src - -RUN mkdir -p $HOME - -WORKDIR $HOME_ROOT - -WORKDIR $HOME - -COPY . . - -# If you are building your code for production -# RUN npm install --only=production -# RUN npm set progress=false -RUN npm install - -# run the database migrations and seeding -CMD [ "npm", "run", "export" ] diff --git a/n8n/.docker/n8n/Dockerfile.setup b/n8n/.docker/n8n/Dockerfile.setup deleted file mode 100644 index c47b2a7f3c..0000000000 --- a/n8n/.docker/n8n/Dockerfile.setup +++ /dev/null @@ -1,21 +0,0 @@ -FROM node:14 - -# set variables -ENV HOME_ROOT=/opt/app-root -ENV HOME=/opt/app-root/src - -RUN mkdir -p $HOME - -WORKDIR $HOME_ROOT - -WORKDIR $HOME - -COPY . . - -# If you are building your code for production -# RUN npm install --only=production -# RUN npm set progress=false -RUN npm install - -# run the database migrations and seeding -CMD [ "npm", "run", "import" ] diff --git a/n8n/README.md b/n8n/README.md deleted file mode 100644 index 988a803cc4..0000000000 --- a/n8n/README.md +++ /dev/null @@ -1,20 +0,0 @@ -# bcgov/biohubbc/n8n - -Webhook and workflow automation tool used within our application. - -## Documenation - -N8N: https://docs.n8n.io/ - -## Running Locally - -Within the n8n directory, there is a folder `credentials` which contains the encrypted credential details for webhook HTTP requests to use. -There is also the `workflows` directory which contains the workflow file(s). When a `make clean web` is run, these credentials and workflows are imported into n8n and can be called from within our app. - -If a change needs to be made to the existing credentials/workflows or a new one needs to be added, this can be done from the browser in the local instance of n8n which is running at `http://localhost:5100`. When the changes have been made, make sure to save and then run the following command from the terminal/cmd line: - -``` -make n8n-export -``` - -This will export your changes and store them in the folders mentioned above so that next time a `clean web` command is issued, the appropriate latest credentials and workflows will be imported into n8n and the webhooks will work as expected. diff --git a/n8n/credentials/1.json b/n8n/credentials/1.json deleted file mode 100644 index d8f02998db..0000000000 --- a/n8n/credentials/1.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "id": 1, - "name": "Bearer Token", - "data": "U2FsdGVkX18whvbJbn2QV+Moka6ZoWGPDoNWsPOmiLv29gUx1Sto42LunpldVpXAMaVwknaGs1rsg94dTznYtp7vr/yBSGkbYd3Mh+YvmmJ0FNAukzSNmrn8yI2+XmChVqI9aoEVW0DxEl7VjF9lSA==", - "type": "httpHeaderAuth", - "nodesAccess": [], - "createdAt": "2021-08-26T12:19:20.919Z", - "updatedAt": "2021-08-26T21:05:59.317Z" -} \ No newline at end of file diff --git a/n8n/package-lock.json b/n8n/package-lock.json deleted file mode 100644 index 87fb61f4d9..0000000000 --- a/n8n/package-lock.json +++ /dev/null @@ -1,8932 +0,0 @@ -{ - "name": "sims-n8n", - "version": "0.0.0", - "lockfileVersion": 1, - "requires": true, - "dependencies": { - "@azure/abort-controller": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-1.0.4.tgz", - "integrity": "sha512-lNUmDRVGpanCsiUN3NWxFTdwmdFI53xwhkTFfHDGTYk46ca7Ind3nanJc+U6Zj9Tv+9nTCWRBscWEW1DyKOpTw==", - "dev": true, - "requires": { - "tslib": "^2.0.0" - }, - "dependencies": { - "tslib": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz", - "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw==", - "dev": true - } - } - }, - "@azure/core-asynciterator-polyfill": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@azure/core-asynciterator-polyfill/-/core-asynciterator-polyfill-1.0.0.tgz", - "integrity": "sha512-kmv8CGrPfN9SwMwrkiBK9VTQYxdFQEGe0BmQk+M8io56P9KNzpAxcWE/1fxJj7uouwN4kXF0BHW8DNlgx+wtCg==", - "dev": true - }, - "@azure/core-auth": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@azure/core-auth/-/core-auth-1.3.2.tgz", - "integrity": "sha512-7CU6DmCHIZp5ZPiZ9r3J17lTKMmYsm/zGvNkjArQwPkrLlZ1TZ+EUYfGgh2X31OLMVAQCTJZW4cXHJi02EbJnA==", - "dev": true, - "requires": { - "@azure/abort-controller": "^1.0.0", - "tslib": "^2.2.0" - }, - "dependencies": { - "tslib": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz", - "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw==", - "dev": true - } - } - }, - "@azure/core-http": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/@azure/core-http/-/core-http-2.2.1.tgz", - "integrity": "sha512-7ATnV3OGzCO2K9kMrh3NKUM8b4v+xasmlUhkNZz6uMbm+8XH/AexLkhRGsoo0GyKNlEGvyGEfytqTk0nUY2I4A==", - "dev": true, - "requires": { - "@azure/abort-controller": "^1.0.0", - "@azure/core-asynciterator-polyfill": "^1.0.0", - "@azure/core-auth": "^1.3.0", - "@azure/core-tracing": "1.0.0-preview.13", - "@azure/logger": "^1.0.0", - "@types/node-fetch": "^2.5.0", - "@types/tunnel": "^0.0.3", - "form-data": "^4.0.0", - "node-fetch": "^2.6.0", - "process": "^0.11.10", - "tough-cookie": "^4.0.0", - "tslib": "^2.2.0", - "tunnel": "^0.0.6", - "uuid": "^8.3.0", - "xml2js": "^0.4.19" - }, - "dependencies": { - "tough-cookie": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.0.0.tgz", - "integrity": "sha512-tHdtEpQCMrc1YLrMaqXXcj6AxhYi/xgit6mZu1+EDWUn+qhUf8wMQoFIy9NXuq23zAwtcB0t/MjACGR18pcRbg==", - "dev": true, - "requires": { - "psl": "^1.1.33", - "punycode": "^2.1.1", - "universalify": "^0.1.2" - } - }, - "tslib": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz", - "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw==", - "dev": true - } - } - }, - "@azure/core-lro": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/@azure/core-lro/-/core-lro-2.2.1.tgz", - "integrity": "sha512-HE6PBl+mlKa0eBsLwusHqAqjLc5n9ByxeDo3Hz4kF3B1hqHvRkBr4oMgoT6tX7Hc3q97KfDctDUon7EhvoeHPA==", - "dev": true, - "requires": { - "@azure/abort-controller": "^1.0.0", - "@azure/core-tracing": "1.0.0-preview.13", - "@azure/logger": "^1.0.0", - "tslib": "^2.2.0" - }, - "dependencies": { - "tslib": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz", - "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw==", - "dev": true - } - } - }, - "@azure/core-paging": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@azure/core-paging/-/core-paging-1.2.0.tgz", - "integrity": "sha512-ZX1bCjm/MjKPCN6kQD/9GJErYSoKA8YWp6YWoo5EIzcTWlSBLXu3gNaBTUl8usGl+UShiKo7b4Gdy1NSTIlpZg==", - "dev": true, - "requires": { - "@azure/core-asynciterator-polyfill": "^1.0.0", - "tslib": "^2.2.0" - }, - "dependencies": { - "tslib": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz", - "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw==", - "dev": true - } - } - }, - "@azure/core-tracing": { - "version": "1.0.0-preview.13", - "resolved": "https://registry.npmjs.org/@azure/core-tracing/-/core-tracing-1.0.0-preview.13.tgz", - "integrity": "sha512-KxDlhXyMlh2Jhj2ykX6vNEU0Vou4nHr025KoSEiz7cS3BNiHNaZcdECk/DmLkEB0as5T7b/TpRcehJ5yV6NeXQ==", - "dev": true, - "requires": { - "@opentelemetry/api": "^1.0.1", - "tslib": "^2.2.0" - }, - "dependencies": { - "tslib": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz", - "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw==", - "dev": true - } - } - }, - "@azure/logger": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@azure/logger/-/logger-1.0.3.tgz", - "integrity": "sha512-aK4s3Xxjrx3daZr3VylxejK3vG5ExXck5WOHDJ8in/k9AqlfIyFMMT1uG7u8mNjX+QRILTIn0/Xgschfh/dQ9g==", - "dev": true, - "requires": { - "tslib": "^2.2.0" - }, - "dependencies": { - "tslib": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz", - "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw==", - "dev": true - } - } - }, - "@azure/ms-rest-azure-env": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@azure/ms-rest-azure-env/-/ms-rest-azure-env-2.0.0.tgz", - "integrity": "sha512-dG76W7ElfLi+fbTjnZVGj+M9e0BIEJmRxU6fHaUQ12bZBe8EJKYb2GV50YWNaP2uJiVQ5+7nXEVj1VN1UQtaEw==", - "dev": true - }, - "@azure/ms-rest-js": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@azure/ms-rest-js/-/ms-rest-js-2.6.0.tgz", - "integrity": "sha512-4C5FCtvEzWudblB+h92/TYYPiq7tuElX8icVYToxOdggnYqeec4Se14mjse5miInKtZahiFHdl8lZA/jziEc5g==", - "dev": true, - "requires": { - "@azure/core-auth": "^1.1.4", - "abort-controller": "^3.0.0", - "form-data": "^2.5.0", - "node-fetch": "^2.6.0", - "tough-cookie": "^3.0.1", - "tslib": "^1.10.0", - "tunnel": "0.0.6", - "uuid": "^8.3.2", - "xml2js": "^0.4.19" - }, - "dependencies": { - "form-data": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.1.tgz", - "integrity": "sha512-m21N3WOmEEURgk6B9GLOE4RuWOFf28Lhh9qGYeNlGq4VDXUlJy2th2slBNU8Gp8EzloYZOibZJ7t5ecIrFSjVA==", - "dev": true, - "requires": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.6", - "mime-types": "^2.1.12" - } - } - } - }, - "@azure/ms-rest-nodeauth": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@azure/ms-rest-nodeauth/-/ms-rest-nodeauth-3.1.0.tgz", - "integrity": "sha512-F4NKrbkZg0qD3+rUM8fvJHOFRkXFoEiptYTZtLBruN3VwBFIqbTFW0fmgRyBW9seZl+mX2OexQA5GzWenSA3Kw==", - "dev": true, - "requires": { - "@azure/ms-rest-azure-env": "^2.0.0", - "@azure/ms-rest-js": "^2.0.4", - "adal-node": "^0.2.2" - } - }, - "@azure/storage-blob": { - "version": "12.8.0", - "resolved": "https://registry.npmjs.org/@azure/storage-blob/-/storage-blob-12.8.0.tgz", - "integrity": "sha512-c8+Wz19xauW0bGkTCoqZH4dYfbtBniPiGiRQOn1ca6G5jsjr4azwaTk9gwjVY8r3vY2Taf95eivLzipfIfiS4A==", - "dev": true, - "requires": { - "@azure/abort-controller": "^1.0.0", - "@azure/core-http": "^2.0.0", - "@azure/core-lro": "^2.2.0", - "@azure/core-paging": "^1.1.1", - "@azure/core-tracing": "1.0.0-preview.13", - "@azure/logger": "^1.0.0", - "events": "^3.0.0", - "tslib": "^2.2.0" - }, - "dependencies": { - "tslib": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz", - "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw==", - "dev": true - } - } - }, - "@babel/runtime": { - "version": "7.15.4", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.15.4.tgz", - "integrity": "sha512-99catp6bHCaxr4sJ/DbTGgHS4+Rs2RVd2g7iOap6SLGPDknRK9ztKNsE/Fg6QhSeh1FGE5f6gHGQmvvn3I3xhw==", - "dev": true, - "requires": { - "regenerator-runtime": "^0.13.4" - } - }, - "@cspotcode/source-map-consumer": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/@cspotcode/source-map-consumer/-/source-map-consumer-0.8.0.tgz", - "integrity": "sha512-41qniHzTU8yAGbCp04ohlmSrZf8bkf/iJsl3V0dRGsQN/5GFfx+LbCSsCpp2gqrqjTVg/K6O8ycoV35JIwAzAg==", - "dev": true - }, - "@cspotcode/source-map-support": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.7.0.tgz", - "integrity": "sha512-X4xqRHqN8ACt2aHVe51OxeA2HjbcL4MqFqXkrmQszJ1NOUuUu5u6Vqx/0lZSVNku7velL5FC/s5uEAj1lsBMhA==", - "dev": true, - "requires": { - "@cspotcode/source-map-consumer": "0.8.0" - } - }, - "@dabh/diagnostics": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@dabh/diagnostics/-/diagnostics-2.0.2.tgz", - "integrity": "sha512-+A1YivoVDNNVCdfozHSR8v/jyuuLTMXwjWuxPFlFlUapXoGc+Gj9mDlTDDfrwl7rXCl2tNZ0kE8sIBO6YOn96Q==", - "dev": true, - "requires": { - "colorspace": "1.1.x", - "enabled": "2.0.x", - "kuler": "^2.0.0" - } - }, - "@fontsource/open-sans": { - "version": "4.5.1", - "resolved": "https://registry.npmjs.org/@fontsource/open-sans/-/open-sans-4.5.1.tgz", - "integrity": "sha512-Zvse9PG3XU+gfa44i4ENXvZi28X0RTtuc/b1v4AaqrSpSf2OALN2QrBf91BOC5gXU1Y6xW/S0To5wz8EraLPlQ==", - "dev": true - }, - "@gar/promisify": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz", - "integrity": "sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==", - "dev": true, - "optional": true - }, - "@icetee/ftp": { - "version": "0.3.15", - "resolved": "https://registry.npmjs.org/@icetee/ftp/-/ftp-0.3.15.tgz", - "integrity": "sha512-RxSa9VjcDWgWCYsaLdZItdCnJj7p4LxggaEk+Y3MP0dHKoxez8ioG07DVekVbZZqccsrL+oPB/N9AzVPxj4blg==", - "dev": true, - "requires": { - "readable-stream": "1.1.x", - "xregexp": "2.0.0" - } - }, - "@kwsites/file-exists": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@kwsites/file-exists/-/file-exists-1.1.1.tgz", - "integrity": "sha512-m9/5YGR18lIwxSFDwfE3oA7bWuq9kdau6ugN4H2rJeyhFQZcG9AgSHkQtSD15a8WvTgfz9aikZMrKPHvbpqFiw==", - "dev": true, - "requires": { - "debug": "^4.1.1" - } - }, - "@kwsites/promise-deferred": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@kwsites/promise-deferred/-/promise-deferred-1.1.1.tgz", - "integrity": "sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw==", - "dev": true - }, - "@mapbox/node-pre-gyp": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.9.tgz", - "integrity": "sha512-aDF3S3rK9Q2gey/WAttUlISduDItz5BU3306M9Eyv6/oS40aMprnopshtlKTykxRNIBEZuRMaZAnbrQ4QtKGyw==", - "dev": true, - "requires": { - "detect-libc": "^2.0.0", - "https-proxy-agent": "^5.0.0", - "make-dir": "^3.1.0", - "node-fetch": "^2.6.7", - "nopt": "^5.0.0", - "npmlog": "^5.0.1", - "rimraf": "^3.0.2", - "semver": "^7.3.5", - "tar": "^6.1.11" - }, - "dependencies": { - "are-we-there-yet": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-2.0.0.tgz", - "integrity": "sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw==", - "dev": true, - "requires": { - "delegates": "^1.0.0", - "readable-stream": "^3.6.0" - } - }, - "chownr": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", - "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", - "dev": true - }, - "detect-libc": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.1.tgz", - "integrity": "sha512-463v3ZeIrcWtdgIg6vI6XUncguvr2TnGl4SzDXinkt9mSLpBJKXT3mW6xT3VQdDN11+WVs29pgvivTc4Lp8v+w==", - "dev": true - }, - "fs-minipass": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", - "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", - "dev": true, - "requires": { - "minipass": "^3.0.0" - } - }, - "gauge": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/gauge/-/gauge-3.0.2.tgz", - "integrity": "sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q==", - "dev": true, - "requires": { - "aproba": "^1.0.3 || ^2.0.0", - "color-support": "^1.1.2", - "console-control-strings": "^1.0.0", - "has-unicode": "^2.0.1", - "object-assign": "^4.1.1", - "signal-exit": "^3.0.0", - "string-width": "^4.2.3", - "strip-ansi": "^6.0.1", - "wide-align": "^1.1.2" - } - }, - "minipass": { - "version": "3.1.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.1.6.tgz", - "integrity": "sha512-rty5kpw9/z8SX9dmxblFA6edItUmwJgMeYDZRrwlIVN27i8gysGbznJwUggw2V/FVqFSDdWy040ZPS811DYAqQ==", - "dev": true, - "requires": { - "yallist": "^4.0.0" - } - }, - "minizlib": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", - "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", - "dev": true, - "requires": { - "minipass": "^3.0.0", - "yallist": "^4.0.0" - } - }, - "nopt": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", - "integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==", - "dev": true, - "requires": { - "abbrev": "1" - } - }, - "npmlog": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-5.0.1.tgz", - "integrity": "sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw==", - "dev": true, - "requires": { - "are-we-there-yet": "^2.0.0", - "console-control-strings": "^1.1.0", - "gauge": "^3.0.0", - "set-blocking": "^2.0.0" - } - }, - "readable-stream": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz", - "integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==", - "dev": true, - "requires": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - } - }, - "safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "dev": true - }, - "string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "dev": true, - "requires": { - "safe-buffer": "~5.2.0" - } - }, - "tar": { - "version": "6.1.11", - "resolved": "https://registry.npmjs.org/tar/-/tar-6.1.11.tgz", - "integrity": "sha512-an/KZQzQUkZCkuoAA64hM92X0Urb6VpRhAFllDzz44U2mcD5scmT3zBc4VgVpkugF580+DQn8eAFSyoQt0tznA==", - "dev": true, - "requires": { - "chownr": "^2.0.0", - "fs-minipass": "^2.0.0", - "minipass": "^3.0.0", - "minizlib": "^2.1.1", - "mkdirp": "^1.0.3", - "yallist": "^4.0.0" - } - } - } - }, - "@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "dev": true, - "requires": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" - } - }, - "@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "dev": true - }, - "@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dev": true, - "requires": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - } - }, - "@npmcli/fs": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-1.1.1.tgz", - "integrity": "sha512-8KG5RD0GVP4ydEzRn/I4BNDuxDtqVbOdm8675T49OIG/NGhaK0pjPX7ZcDlvKYbA+ulvVK3ztfcF4uBdOxuJbQ==", - "dev": true, - "optional": true, - "requires": { - "@gar/promisify": "^1.0.1", - "semver": "^7.3.5" - } - }, - "@npmcli/move-file": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@npmcli/move-file/-/move-file-1.1.2.tgz", - "integrity": "sha512-1SUf/Cg2GzGDyaf15aR9St9TWlb+XvbZXWpDx8YKs7MLzMH/BCeopv+y9vzrzgkfykCGuWOlSu3mZhj2+FQcrg==", - "dev": true, - "optional": true, - "requires": { - "mkdirp": "^1.0.4", - "rimraf": "^3.0.2" - } - }, - "@oclif/command": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/@oclif/command/-/command-1.8.0.tgz", - "integrity": "sha512-5vwpq6kbvwkQwKqAoOU3L72GZ3Ta8RRrewKj9OJRolx28KLJJ8Dg9Rf7obRwt5jQA9bkYd8gqzMTrI7H3xLfaw==", - "dev": true, - "requires": { - "@oclif/config": "^1.15.1", - "@oclif/errors": "^1.3.3", - "@oclif/parser": "^3.8.3", - "@oclif/plugin-help": "^3", - "debug": "^4.1.1", - "semver": "^7.3.2" - } - }, - "@oclif/config": { - "version": "1.17.0", - "resolved": "https://registry.npmjs.org/@oclif/config/-/config-1.17.0.tgz", - "integrity": "sha512-Lmfuf6ubjQ4ifC/9bz1fSCHc6F6E653oyaRXxg+lgT4+bYf9bk+nqrUpAbrXyABkCqgIBiFr3J4zR/kiFdE1PA==", - "dev": true, - "requires": { - "@oclif/errors": "^1.3.3", - "@oclif/parser": "^3.8.0", - "debug": "^4.1.1", - "globby": "^11.0.1", - "is-wsl": "^2.1.1", - "tslib": "^2.0.0" - }, - "dependencies": { - "tslib": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz", - "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw==", - "dev": true - } - } - }, - "@oclif/errors": { - "version": "1.3.5", - "resolved": "https://registry.npmjs.org/@oclif/errors/-/errors-1.3.5.tgz", - "integrity": "sha512-OivucXPH/eLLlOT7FkCMoZXiaVYf8I/w1eTAM1+gKzfhALwWTusxEx7wBmW0uzvkSg/9ovWLycPaBgJbM3LOCQ==", - "dev": true, - "requires": { - "clean-stack": "^3.0.0", - "fs-extra": "^8.1", - "indent-string": "^4.0.0", - "strip-ansi": "^6.0.0", - "wrap-ansi": "^7.0.0" - } - }, - "@oclif/linewrap": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@oclif/linewrap/-/linewrap-1.0.0.tgz", - "integrity": "sha512-Ups2dShK52xXa8w6iBWLgcjPJWjais6KPJQq3gQ/88AY6BXoTX+MIGFPrWQO1KLMiQfoTpcLnUwloN4brrVUHw==", - "dev": true - }, - "@oclif/parser": { - "version": "3.8.5", - "resolved": "https://registry.npmjs.org/@oclif/parser/-/parser-3.8.5.tgz", - "integrity": "sha512-yojzeEfmSxjjkAvMRj0KzspXlMjCfBzNRPkWw8ZwOSoNWoJn+OCS/m/S+yfV6BvAM4u2lTzX9Y5rCbrFIgkJLg==", - "dev": true, - "requires": { - "@oclif/errors": "^1.2.2", - "@oclif/linewrap": "^1.0.0", - "chalk": "^2.4.2", - "tslib": "^1.9.3" - } - }, - "@oclif/plugin-help": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/@oclif/plugin-help/-/plugin-help-3.2.3.tgz", - "integrity": "sha512-l2Pd0lbOMq4u/7xsl9hqISFqyR9gWEz/8+05xmrXFr67jXyS6EUCQB+mFBa0wepltrmJu0sAFg9AvA2mLaMMqQ==", - "dev": true, - "requires": { - "@oclif/command": "^1.5.20", - "@oclif/config": "^1.15.1", - "@oclif/errors": "^1.2.2", - "chalk": "^4.1.0", - "indent-string": "^4.0.0", - "lodash.template": "^4.4.0", - "string-width": "^4.2.0", - "strip-ansi": "^6.0.0", - "widest-line": "^3.1.0", - "wrap-ansi": "^4.0.0" - }, - "dependencies": { - "ansi-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.0.tgz", - "integrity": "sha1-7QMXwyIGT3lGbAKWa922Bas32Zg=", - "dev": true - }, - "ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "requires": { - "color-convert": "^2.0.1" - } - }, - "chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "requires": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - } - }, - "color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "requires": { - "color-name": "~1.1.4" - } - }, - "color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true - }, - "is-fullwidth-code-point": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", - "integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8=", - "dev": true - }, - "supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "requires": { - "has-flag": "^4.0.0" - } - }, - "wrap-ansi": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-4.0.0.tgz", - "integrity": "sha512-uMTsj9rDb0/7kk1PbcbCcwvHUxp60fGDB/NNXpVa0Q+ic/e7y5+BwTxKfQ33VYgDppSwi/FBzpetYzo8s6tfbg==", - "dev": true, - "requires": { - "ansi-styles": "^3.2.0", - "string-width": "^2.1.1", - "strip-ansi": "^4.0.0" - }, - "dependencies": { - "ansi-styles": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", - "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", - "dev": true, - "requires": { - "color-convert": "^1.9.0" - } - }, - "color-convert": { - "version": "1.9.3", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", - "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", - "dev": true, - "requires": { - "color-name": "1.1.3" - } - }, - "color-name": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=", - "dev": true - }, - "string-width": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-2.1.1.tgz", - "integrity": "sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw==", - "dev": true, - "requires": { - "is-fullwidth-code-point": "^2.0.0", - "strip-ansi": "^4.0.0" - } - }, - "strip-ansi": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-4.0.0.tgz", - "integrity": "sha1-qEeQIusaw2iocTibY1JixQXuNo8=", - "dev": true, - "requires": { - "ansi-regex": "^3.0.0" - } - } - } - } - } - }, - "@opentelemetry/api": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.0.3.tgz", - "integrity": "sha512-puWxACExDe9nxbBB3lOymQFrLYml2dVOrd7USiVRnSbgXE+KwBu+HxFvxrzfqsiSda9IWsXJG1ef7C1O2/GmKQ==", - "dev": true - }, - "@selderee/plugin-htmlparser2": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/@selderee/plugin-htmlparser2/-/plugin-htmlparser2-0.6.0.tgz", - "integrity": "sha512-J3jpy002TyBjd4N/p6s+s90eX42H2eRhK3SbsZuvTDv977/E8p2U3zikdiehyJja66do7FlxLomZLPlvl2/xaA==", - "dev": true, - "requires": { - "domhandler": "^4.2.0", - "selderee": "^0.6.0" - } - }, - "@servie/events": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@servie/events/-/events-1.0.0.tgz", - "integrity": "sha512-sBSO19KzdrJCM3gdx6eIxV8M9Gxfgg6iDQmH5TIAGaUu+X9VDdsINXJOnoiZ1Kx3TrHdH4bt5UVglkjsEGBcvw==", - "dev": true - }, - "@sqltools/formatter": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@sqltools/formatter/-/formatter-1.2.3.tgz", - "integrity": "sha512-O3uyB/JbkAEMZaP3YqyHH7TMnex7tWyCbCI4EfJdOCoN6HIhqdJBWTM6aCCiWQ/5f5wxjgU735QAIpJbjDvmzg==", - "dev": true - }, - "@tokenizer/token": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz", - "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==", - "dev": true - }, - "@tootallnate/once": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz", - "integrity": "sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==", - "dev": true - }, - "@tsconfig/node10": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.8.tgz", - "integrity": "sha512-6XFfSQmMgq0CFLY1MslA/CPUfhIL919M1rMsa5lP2P097N2Wd1sSX0tx1u4olM16fLNhtHZpRhedZJphNJqmZg==", - "dev": true - }, - "@tsconfig/node12": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.9.tgz", - "integrity": "sha512-/yBMcem+fbvhSREH+s14YJi18sp7J9jpuhYByADT2rypfajMZZN4WQ6zBGgBKp53NKmqI36wFYDb3yaMPurITw==", - "dev": true - }, - "@tsconfig/node14": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.1.tgz", - "integrity": "sha512-509r2+yARFfHHE7T6Puu2jjkoycftovhXRqW328PDXTVGKihlb1P8Z9mMZH04ebyajfRY7dedfGynlrFHJUQCg==", - "dev": true - }, - "@tsconfig/node16": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.2.tgz", - "integrity": "sha512-eZxlbI8GZscaGS7kkc/trHTT5xgrjH3/1n2JDwusC9iahPKWMRvRjJSAN5mCXviuTGQ/lHnhvv8Q1YTpnfz9gA==", - "dev": true - }, - "@types/bluebird": { - "version": "3.5.36", - "resolved": "https://registry.npmjs.org/@types/bluebird/-/bluebird-3.5.36.tgz", - "integrity": "sha512-HBNx4lhkxN7bx6P0++W8E289foSu8kO8GCk2unhuVggO+cE7rh9DhZUyPhUxNRG9m+5B5BTKxZQ5ZP92x/mx9Q==", - "dev": true - }, - "@types/body-parser": { - "version": "1.19.1", - "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.1.tgz", - "integrity": "sha512-a6bTJ21vFOGIkwM0kzh9Yr89ziVxq4vYH2fQ6N8AeipEzai/cFK6aGMArIkUeIdRIgpwQa+2bXiLuUJCpSf2Cg==", - "dev": true, - "requires": { - "@types/connect": "*", - "@types/node": "*" - } - }, - "@types/connect": { - "version": "3.4.35", - "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.35.tgz", - "integrity": "sha512-cdeYyv4KWoEgpBISTxWvqYsVy444DOqehiF3fM3ne10AmJ62RSyNkUnxMJXHQWRQQX2eR94m5y1IZyDwBjV9FQ==", - "dev": true, - "requires": { - "@types/node": "*" - } - }, - "@types/express": { - "version": "4.17.13", - "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.13.tgz", - "integrity": "sha512-6bSZTPaTIACxn48l50SR+axgrqm6qXFIxrdAKaG6PaJk3+zuUr35hBlgT7vOmJcum+OEaIBLtHV/qloEAFITeA==", - "dev": true, - "requires": { - "@types/body-parser": "*", - "@types/express-serve-static-core": "^4.17.18", - "@types/qs": "*", - "@types/serve-static": "*" - } - }, - "@types/express-jwt": { - "version": "0.0.42", - "resolved": "https://registry.npmjs.org/@types/express-jwt/-/express-jwt-0.0.42.tgz", - "integrity": "sha512-WszgUddvM1t5dPpJ3LhWNH8kfNN8GPIBrAGxgIYXVCEGx6Bx4A036aAuf/r5WH9DIEdlmp7gHOYvSM6U87B0ag==", - "dev": true, - "requires": { - "@types/express": "*", - "@types/express-unless": "*" - } - }, - "@types/express-serve-static-core": { - "version": "4.17.24", - "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.17.24.tgz", - "integrity": "sha512-3UJuW+Qxhzwjq3xhwXm2onQcFHn76frIYVbTu+kn24LFxI+dEhdfISDFovPB8VpEgW8oQCTpRuCe+0zJxB7NEA==", - "dev": true, - "requires": { - "@types/node": "*", - "@types/qs": "*", - "@types/range-parser": "*" - } - }, - "@types/express-unless": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/@types/express-unless/-/express-unless-0.5.2.tgz", - "integrity": "sha512-Q74UyYRX/zIgl1HSp9tUX2PlG8glkVm+59r7aK4KGKzC5jqKIOX6rrVLRQrzpZUQ84VukHtRoeAuon2nIssHPQ==", - "dev": true, - "requires": { - "@types/express": "*" - } - }, - "@types/ftp": { - "version": "0.3.32", - "resolved": "https://registry.npmjs.org/@types/ftp/-/ftp-0.3.32.tgz", - "integrity": "sha512-8wvwM/FAb62qf/3Xco8qYtZGN3VatgFw02c9FzgbD6mn+x1PdMDOrgwIEDZNXB8YfiPGFal0ZKXWdbSJ+TrgOQ==", - "dev": true, - "requires": { - "@types/node": "*" - } - }, - "@types/json-diff": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/@types/json-diff/-/json-diff-0.5.2.tgz", - "integrity": "sha512-2oqXStJYYLDHCciNAClY277Ti3kXT+JLvPD7lLm/490i+B7g0GR6M4qiW+bd2V5vpB+yMKY8IelbsHMAYX1D0A==", - "dev": true - }, - "@types/jsonwebtoken": { - "version": "8.5.5", - "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-8.5.5.tgz", - "integrity": "sha512-OGqtHQ7N5/Ap/TUwO6IgHDuLiAoTmHhGpNvgkCm/F4N6pKzx/RBSfr2OXZSwC6vkfnsEdb6+7DNZVtiXiwdwFw==", - "dev": true, - "requires": { - "@types/node": "*" - } - }, - "@types/lodash": { - "version": "4.14.175", - "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.175.tgz", - "integrity": "sha512-XmdEOrKQ8a1Y/yxQFOMbC47G/V2VDO1GvMRnl4O75M4GW/abC5tnfzadQYkqEveqRM1dEJGFFegfPNA2vvx2iw==", - "dev": true - }, - "@types/lossless-json": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@types/lossless-json/-/lossless-json-1.0.1.tgz", - "integrity": "sha512-zPE8kmpeL5/6L5gtTQHSOkAW/OSYYNTDRt6/2oEgLO1Zd3Rj5WVDoMloTtLJxQJhZGLGbL4pktKSh3NbzdaWdw==", - "dev": true - }, - "@types/mime": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.2.tgz", - "integrity": "sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw==", - "dev": true - }, - "@types/node": { - "version": "14.14.45", - "resolved": "https://registry.npmjs.org/@types/node/-/node-14.14.45.tgz", - "integrity": "sha512-DssMqTV9UnnoxDWu959sDLZzfvqCF0qDNRjaWeYSui9xkFe61kKo4l1TWNTQONpuXEm+gLMRvdlzvNHBamzmEw==", - "dev": true - }, - "@types/node-fetch": { - "version": "2.5.12", - "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.5.12.tgz", - "integrity": "sha512-MKgC4dlq4kKNa/mYrwpKfzQMB5X3ee5U6fSprkKpToBqBmX4nFZL9cW5jl6sWn+xpRJ7ypWh2yyqqr8UUCstSw==", - "dev": true, - "requires": { - "@types/node": "*", - "form-data": "^3.0.0" - }, - "dependencies": { - "form-data": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.1.tgz", - "integrity": "sha512-RHkBKtLWUVwd7SqRIvCZMEvAMoGUp0XU+seQiZejj0COz3RI3hWP4sCv3gZWWLjJTd7rGwcsF5eKZGii0r/hbg==", - "dev": true, - "requires": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "mime-types": "^2.1.12" - } - } - } - }, - "@types/pg": { - "version": "7.14.11", - "resolved": "https://registry.npmjs.org/@types/pg/-/pg-7.14.11.tgz", - "integrity": "sha512-EnZkZ1OMw9DvNfQkn2MTJrwKmhJYDEs5ujWrPfvseWNoI95N8B4HzU/Ltrq5ZfYxDX/Zg8mTzwr6UAyTjjFvXA==", - "dev": true, - "requires": { - "@types/node": "*", - "pg-protocol": "^1.2.0", - "pg-types": "^2.2.0" - } - }, - "@types/promise-ftp": { - "version": "1.3.4", - "resolved": "https://registry.npmjs.org/@types/promise-ftp/-/promise-ftp-1.3.4.tgz", - "integrity": "sha512-fCIX7I84e25RX6bZ+qiIv0Puu5axWhCj9+El+4Kz1gZZyO/NvwdGTNQ33y6jdrPuTn3Df3kg7nMi1HohjNQLog==", - "dev": true, - "requires": { - "@types/bluebird": "*", - "@types/ftp": "*", - "@types/node": "*", - "@types/promise-ftp-common": "*" - } - }, - "@types/promise-ftp-common": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@types/promise-ftp-common/-/promise-ftp-common-1.1.0.tgz", - "integrity": "sha512-mqo6D4qdiJdzeqlzFwEIchQQZk2hZacjssmjoAX7nClcREmRUUsnmgbWXEfA2qK986rwOPqepfRoSu7rsjAKag==", - "dev": true - }, - "@types/qs": { - "version": "6.9.7", - "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.7.tgz", - "integrity": "sha512-FGa1F62FT09qcrueBA6qYTrJPVDzah9a+493+o2PCXsesWHIn27G98TsSMs3WPNbZIEj4+VJf6saSFpvD+3Zsw==", - "dev": true - }, - "@types/range-parser": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.4.tgz", - "integrity": "sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw==", - "dev": true - }, - "@types/readable-stream": { - "version": "2.3.11", - "resolved": "https://registry.npmjs.org/@types/readable-stream/-/readable-stream-2.3.11.tgz", - "integrity": "sha512-0z+/apYJwKFz/RHp6mOMxz/y7xOvWPYPevuCEyAY3gXsjtaac02E26RvxA+I96rfvmVH/dEMGXNvyJfViR1FSQ==", - "dev": true, - "requires": { - "@types/node": "*", - "safe-buffer": "*" - } - }, - "@types/serve-static": { - "version": "1.13.10", - "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.13.10.tgz", - "integrity": "sha512-nCkHGI4w7ZgAdNkrEu0bv+4xNV/XDqW+DydknebMOQwkpDGx8G+HTlj7R7ABI8i8nKxVw0wtKPi1D+lPOkh4YQ==", - "dev": true, - "requires": { - "@types/mime": "^1", - "@types/node": "*" - } - }, - "@types/snowflake-sdk": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/@types/snowflake-sdk/-/snowflake-sdk-1.6.1.tgz", - "integrity": "sha512-eftM0eWFiphyrbQFLNOacx7CjYGlwEQXDQxixhn8m/hi0DSdPmlSEv272OInVFLnju80ZVE6JOnNfiX8gyHS1Q==", - "dev": true, - "requires": { - "@types/node": "*" - } - }, - "@types/tough-cookie": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-2.3.8.tgz", - "integrity": "sha512-7axfYN8SW9pWg78NgenHasSproWQee5rzyPVLC9HpaQSDgNArsnKJD88EaMfi4Pl48AyciO3agYCFqpHS1gLpg==", - "dev": true - }, - "@types/tunnel": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/@types/tunnel/-/tunnel-0.0.3.tgz", - "integrity": "sha512-sOUTGn6h1SfQ+gbgqC364jLFBw2lnFqkgF3q0WovEHRLMrVD1sd5aufqi/aJObLekJO+Aq5z646U4Oxy6shXMA==", - "dev": true, - "requires": { - "@types/node": "*" - } - }, - "@types/validator": { - "version": "13.6.3", - "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.6.3.tgz", - "integrity": "sha512-fWG42pMJOL4jKsDDZZREnXLjc3UE0R8LOJfARWYg6U966rxDT7TYejYzLnUF5cvSObGg34nd0+H2wHHU5Omdfw==", - "dev": true - }, - "@types/zen-observable": { - "version": "0.8.3", - "resolved": "https://registry.npmjs.org/@types/zen-observable/-/zen-observable-0.8.3.tgz", - "integrity": "sha512-fbF6oTd4sGGy0xjHPKAt+eS2CrxJ3+6gQ3FGcBoIJR2TLAyCkCyI8JqZNy+FeON0AhVgNJoUumVoZQjBFUqHkw==", - "dev": true - }, - "@xmldom/xmldom": { - "version": "0.7.5", - "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.7.5.tgz", - "integrity": "sha512-V3BIhmY36fXZ1OtVcI9W+FxQqxVLsPKcNjWigIaa81dLC9IolJl5Mt4Cvhmr0flUnjSpTdrbMTSbXqYqV5dT6A==", - "dev": true - }, - "abbrev": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", - "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", - "dev": true - }, - "abort-controller": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", - "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", - "dev": true, - "requires": { - "event-target-shim": "^5.0.0" - } - }, - "accepts": { - "version": "1.3.7", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.7.tgz", - "integrity": "sha512-Il80Qs2WjYlJIBNzNkK6KYqlVMTbZLXgHx2oT0pU/fjRHyEp+PEfEPY0R3WCwAGVOtauxh1hOxNgIf5bv7dQpA==", - "dev": true, - "requires": { - "mime-types": "~2.1.24", - "negotiator": "0.6.2" - } - }, - "access-control": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/access-control/-/access-control-1.0.1.tgz", - "integrity": "sha512-H5aqjkogmFxfaOrfn/e42vyspHVXuJ8er63KuljJXpOyJ1ZO/U5CrHfO8BLKIy2w7mBM02L5quL0vbfQqrGQbA==", - "dev": true, - "requires": { - "millisecond": "~0.1.2", - "setheader": "~1.0.0", - "vary": "~1.1.0" - } - }, - "acorn": { - "version": "8.6.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.6.0.tgz", - "integrity": "sha512-U1riIR+lBSNi3IbxtaHOIKdH8sLFv3NYfNv8sg7ZsNhcfl4HF2++BfqqrNAxoCLQW1iiylOj76ecnaUxz+z9yw==", - "dev": true - }, - "acorn-walk": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.2.0.tgz", - "integrity": "sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA==", - "dev": true - }, - "adal-node": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/adal-node/-/adal-node-0.2.3.tgz", - "integrity": "sha512-gMKr8RuYEYvsj7jyfCv/4BfKToQThz20SP71N3AtFn3ia3yAR8Qt2T3aVQhuJzunWs2b38ZsQV0qsZPdwZr7VQ==", - "dev": true, - "requires": { - "@xmldom/xmldom": "^0.7.0", - "async": "^2.6.3", - "axios": "^0.21.1", - "date-utils": "*", - "jws": "3.x.x", - "underscore": ">= 1.3.1", - "uuid": "^3.1.0", - "xpath.js": "~1.1.0" - }, - "dependencies": { - "uuid": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", - "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==", - "dev": true - } - } - }, - "adler-32": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.2.0.tgz", - "integrity": "sha1-aj5r8KY5ALoVZSgIyxXGgT0aXyU=", - "dev": true, - "requires": { - "exit-on-epipe": "~1.0.1", - "printj": "~1.1.0" - } - }, - "agent-base": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", - "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", - "dev": true, - "requires": { - "debug": "4" - } - }, - "agentkeepalive": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.2.1.tgz", - "integrity": "sha512-Zn4cw2NEqd+9fiSVWMscnjyQ1a8Yfoc5oBajLeo5w+YBHgDUcEBY2hS4YpTz6iN5f/2zQiktcuM6tS8x1p9dpA==", - "dev": true, - "optional": true, - "requires": { - "debug": "^4.1.0", - "depd": "^1.1.2", - "humanize-ms": "^1.2.1" - } - }, - "aggregate-error": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", - "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", - "dev": true, - "optional": true, - "requires": { - "clean-stack": "^2.0.0", - "indent-string": "^4.0.0" - }, - "dependencies": { - "clean-stack": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", - "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", - "dev": true, - "optional": true - } - } - }, - "ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "dev": true, - "requires": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - } - }, - "amqplib": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/amqplib/-/amqplib-0.8.0.tgz", - "integrity": "sha512-icU+a4kkq4Y1PS4NNi+YPDMwdlbFcZ1EZTQT2nigW3fvOb6AOgUQ9+Mk4ue0Zu5cBg/XpDzB40oH10ysrk2dmA==", - "dev": true, - "requires": { - "bitsyntax": "~0.1.0", - "bluebird": "^3.7.2", - "buffer-more-ints": "~1.0.0", - "readable-stream": "1.x >=1.1.9", - "safe-buffer": "~5.2.1", - "url-parse": "~1.5.1" - }, - "dependencies": { - "safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "dev": true - } - } - }, - "ansi-escapes": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", - "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", - "dev": true, - "requires": { - "type-fest": "^0.21.3" - } - }, - "ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true - }, - "ansi-styles": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", - "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", - "dev": true, - "requires": { - "color-convert": "^1.9.0" - } - }, - "any-promise": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", - "integrity": "sha1-q8av7tzqUugJzcA3au0845Y10X8=", - "dev": true - }, - "app-root-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/app-root-path/-/app-root-path-3.0.0.tgz", - "integrity": "sha512-qMcx+Gy2UZynHjOHOIXPNvpf+9cjvk3cWrBBK7zg4gH9+clobJRb9NGzcT7mQTcV/6Gm/1WelUtqxVXnNlrwcw==", - "dev": true - }, - "aproba": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/aproba/-/aproba-1.2.0.tgz", - "integrity": "sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw==", - "dev": true - }, - "arg": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", - "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", - "dev": true - }, - "argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true - }, - "array-flatten": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", - "integrity": "sha1-ml9pkFGx5wczKPKgCJaLZOopVdI=", - "dev": true - }, - "array-parallel": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/array-parallel/-/array-parallel-0.1.3.tgz", - "integrity": "sha1-j3hTCJJu1apHjEfmTRszS2wMlH0=", - "dev": true - }, - "array-series": { - "version": "0.1.5", - "resolved": "https://registry.npmjs.org/array-series/-/array-series-0.1.5.tgz", - "integrity": "sha1-3103v8XC7wdV4qpPkv6ufUtaly8=", - "dev": true - }, - "array-union": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", - "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", - "dev": true - }, - "asn1": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.4.tgz", - "integrity": "sha512-jxwzQpLQjSmWXgwaCZE9Nz+glAG01yF1QnWgbhGwHI5A6FRIEY6IVqtHhIepHqI7/kyEyQEagBC5mBEFlIYvdg==", - "dev": true, - "requires": { - "safer-buffer": "~2.1.0" - } - }, - "asn1.js": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz", - "integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==", - "dev": true, - "requires": { - "bn.js": "^4.0.0", - "inherits": "^2.0.1", - "minimalistic-assert": "^1.0.0", - "safer-buffer": "^2.1.0" - } - }, - "asn1.js-rfc2560": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/asn1.js-rfc2560/-/asn1.js-rfc2560-5.0.1.tgz", - "integrity": "sha512-1PrVg6kuBziDN3PGFmRk3QrjpKvP9h/Hv5yMrFZvC1kpzP6dQRzf5BpKstANqHBkaOUmTpakJWhicTATOA/SbA==", - "dev": true, - "requires": { - "asn1.js-rfc5280": "^3.0.0" - } - }, - "asn1.js-rfc5280": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/asn1.js-rfc5280/-/asn1.js-rfc5280-3.0.0.tgz", - "integrity": "sha512-Y2LZPOWeZ6qehv698ZgOGGCZXBQShObWnGthTrIFlIQjuV1gg2B8QOhWFRExq/MR1VnPpIIe7P9vX2vElxv+Pg==", - "dev": true, - "requires": { - "asn1.js": "^5.0.0" - } - }, - "assert-options": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/assert-options/-/assert-options-0.7.0.tgz", - "integrity": "sha512-7q9uNH/Dh8gFgpIIb9ja8PJEWA5AQy3xnBC8jtKs8K/gNVCr1K6kIvlm59HUyYgvM7oEDoLzGgPcGd9FqhtXEQ==", - "dev": true - }, - "assert-plus": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", - "integrity": "sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU=", - "dev": true - }, - "async": { - "version": "2.6.3", - "resolved": "https://registry.npmjs.org/async/-/async-2.6.3.tgz", - "integrity": "sha512-zflvls11DCy+dQWzTW2dzuilv8Z5X/pjfmZOWba6TNIVDm+2UDaJmXSOXlasHKfNBs8oo3M0aT50fDEWfKZjXg==", - "dev": true, - "requires": { - "lodash": "^4.17.14" - } - }, - "asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=", - "dev": true - }, - "aws-sdk": { - "version": "2.1009.0", - "resolved": "https://registry.npmjs.org/aws-sdk/-/aws-sdk-2.1009.0.tgz", - "integrity": "sha512-qKbmt+vzQ7ZSnfEvA+u6d7CkV09AcAGnxZAiNgOAEn8GFFEtERy6C39VoAuWfON/B2avJDYvtRocjVmAxWpgjQ==", - "dev": true, - "requires": { - "buffer": "4.9.2", - "events": "1.1.1", - "ieee754": "1.1.13", - "jmespath": "0.15.0", - "querystring": "0.2.0", - "sax": "1.2.1", - "url": "0.10.3", - "uuid": "3.3.2", - "xml2js": "0.4.19" - }, - "dependencies": { - "buffer": { - "version": "4.9.2", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-4.9.2.tgz", - "integrity": "sha512-xq+q3SRMOxGivLhBNaUdC64hDTQwejJ+H0T/NB1XMtTVEwNTrfFF3gAxiyW0Bu/xWEGhjVKgUcMhCrUy2+uCWg==", - "dev": true, - "requires": { - "base64-js": "^1.0.2", - "ieee754": "^1.1.4", - "isarray": "^1.0.0" - } - }, - "events": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/events/-/events-1.1.1.tgz", - "integrity": "sha1-nr23Y1rQmccNzEwqH1AEKI6L2SQ=", - "dev": true - }, - "ieee754": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.1.13.tgz", - "integrity": "sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg==", - "dev": true - }, - "isarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=", - "dev": true - }, - "sax": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.1.tgz", - "integrity": "sha1-e45lYZCyKOgaZq6nSEgNgozS03o=", - "dev": true - }, - "uuid": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.3.2.tgz", - "integrity": "sha512-yXJmeNaw3DnnKAOKJE51sL/ZaYfWJRl1pK9dr19YFCu0ObS231AB1/LbqTKRAQ5kw8A90rA6fr4riOUpTZvQZA==", - "dev": true - }, - "xml2js": { - "version": "0.4.19", - "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.19.tgz", - "integrity": "sha512-esZnJZJOiJR9wWKMyuvSE1y6Dq5LCuJanqhxslH2bxM6duahNZ+HMpCLhBQGZkbX6xRf8x1Y2eJlgt2q3qo49Q==", - "dev": true, - "requires": { - "sax": ">=0.6.0", - "xmlbuilder": "~9.0.1" - } - }, - "xmlbuilder": { - "version": "9.0.7", - "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-9.0.7.tgz", - "integrity": "sha1-Ey7mPS7FVlxVfiD0wi35rKaGsQ0=", - "dev": true - } - } - }, - "aws-sign2": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz", - "integrity": "sha1-tG6JCTSpWR8tL2+G1+ap8bP+dqg=", - "dev": true - }, - "aws4": { - "version": "1.11.0", - "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.11.0.tgz", - "integrity": "sha512-xh1Rl34h6Fi1DC2WWKfxUTVqRsNnr6LsKz2+hfwDxQJWmrx8+c7ylaqBMcHfl1U1r2dsifOvKX3LQuLNZ+XSvA==", - "dev": true - }, - "axios": { - "version": "0.21.4", - "resolved": "https://registry.npmjs.org/axios/-/axios-0.21.4.tgz", - "integrity": "sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg==", - "dev": true, - "requires": { - "follow-redirects": "^1.14.0" - } - }, - "balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true - }, - "base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "dev": true - }, - "basic-auth": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz", - "integrity": "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==", - "dev": true, - "requires": { - "safe-buffer": "5.1.2" - } - }, - "bcrypt-pbkdf": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", - "integrity": "sha1-pDAdOJtqQ/m2f/PKEaP2Y342Dp4=", - "dev": true, - "requires": { - "tweetnacl": "^0.14.3" - } - }, - "bcryptjs": { - "version": "2.4.3", - "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz", - "integrity": "sha1-mrVie5PmBiH/fNrF2pczAn3x0Ms=", - "dev": true - }, - "big-integer": { - "version": "1.6.50", - "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.50.tgz", - "integrity": "sha512-+O2uoQWFRo8ysZNo/rjtri2jIwjr3XfeAgRjAUADRqGG+ZITvyn8J1kvXLTaKVr3hhGXk+f23tKfdzmklVM9vQ==", - "dev": true - }, - "bignumber.js": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-2.4.0.tgz", - "integrity": "sha1-g4qZLan51zfg9LLbC+YrsJ3Qxeg=", - "dev": true - }, - "binascii": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/binascii/-/binascii-0.0.2.tgz", - "integrity": "sha1-p/iogB28z4sXVrdD2qD+6eLZ4O4=", - "dev": true - }, - "bintrees": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/bintrees/-/bintrees-1.0.1.tgz", - "integrity": "sha1-DmVcm5wkNeqraL9AJyJtK1WjRSQ=", - "dev": true - }, - "bitsyntax": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/bitsyntax/-/bitsyntax-0.1.0.tgz", - "integrity": "sha512-ikAdCnrloKmFOugAfxWws89/fPc+nw0OOG1IzIE72uSOg/A3cYptKCjSUhDTuj7fhsJtzkzlv7l3b8PzRHLN0Q==", - "dev": true, - "requires": { - "buffer-more-ints": "~1.0.0", - "debug": "~2.6.9", - "safe-buffer": "~5.1.2" - }, - "dependencies": { - "debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dev": true, - "requires": { - "ms": "2.0.0" - } - }, - "ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", - "dev": true - } - } - }, - "bl": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/bl/-/bl-2.2.1.tgz", - "integrity": "sha512-6Pesp1w0DEX1N550i/uGV/TqucVL4AM/pgThFSN/Qq9si1/DF9aIHs1BxD8V/QU0HoeHO6cQRTAuYnLPKq1e4g==", - "dev": true, - "requires": { - "readable-stream": "^2.3.5", - "safe-buffer": "^5.1.1" - }, - "dependencies": { - "isarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=", - "dev": true - }, - "readable-stream": { - "version": "2.3.7", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", - "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", - "dev": true, - "requires": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" - } - }, - "string_decoder": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", - "dev": true, - "requires": { - "safe-buffer": "~5.1.0" - } - } - } - }, - "bluebird": { - "version": "3.7.2", - "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", - "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", - "dev": true - }, - "bn.js": { - "version": "4.12.0", - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.0.tgz", - "integrity": "sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA==", - "dev": true - }, - "body-parser": { - "version": "1.19.0", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.19.0.tgz", - "integrity": "sha512-dhEPs72UPbDnAQJ9ZKMNTP6ptJaionhP5cBb541nXPlW60Jepo9RV/a4fX4XWW9CuFNK22krhrj1+rgzifNCsw==", - "dev": true, - "requires": { - "bytes": "3.1.0", - "content-type": "~1.0.4", - "debug": "2.6.9", - "depd": "~1.1.2", - "http-errors": "1.7.2", - "iconv-lite": "0.4.24", - "on-finished": "~2.3.0", - "qs": "6.7.0", - "raw-body": "2.4.0", - "type-is": "~1.6.17" - }, - "dependencies": { - "debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dev": true, - "requires": { - "ms": "2.0.0" - } - }, - "ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", - "dev": true - } - } - }, - "body-parser-xml": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/body-parser-xml/-/body-parser-xml-2.0.3.tgz", - "integrity": "sha512-tWvcAbh8QPd/lj+yfGZBMY/roof/e2iSXrJbYXYjxVhHQ88D2CF3AxDTdwhb9wcNdHVNbCttaWipchJPEs5r0g==", - "dev": true, - "requires": { - "xml2js": "^0.4.23" - } - }, - "boolbase": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", - "integrity": "sha1-aN/1++YMUes3cl6p4+0xDcwed24=", - "dev": true - }, - "brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, - "requires": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "braces": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", - "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", - "dev": true, - "requires": { - "fill-range": "^7.0.1" - } - }, - "browser-request": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/browser-request/-/browser-request-0.3.3.tgz", - "integrity": "sha1-ns5bWsqJopkyJC4Yv5M975h2zBc=", - "dev": true - }, - "bson": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/bson/-/bson-1.1.6.tgz", - "integrity": "sha512-EvVNVeGo4tHxwi8L6bPj3y3itEvStdwvvlojVxxbyYfoaxJ6keLgrTuKdyfEAszFK+H3olzBuafE0yoh0D1gdg==", - "dev": true - }, - "buffer": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "dev": true, - "requires": { - "base64-js": "^1.3.1", - "ieee754": "^1.1.13" - } - }, - "buffer-equal-constant-time": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", - "integrity": "sha1-+OcRMvf/5uAaXJaXpMbz5I1cyBk=", - "dev": true - }, - "buffer-from": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", - "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "dev": true - }, - "buffer-more-ints": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/buffer-more-ints/-/buffer-more-ints-1.0.0.tgz", - "integrity": "sha512-EMetuGFz5SLsT0QTnXzINh4Ksr+oo4i+UGTXEshiGCQWnsgSs7ZhJ8fzlwQ+OzEMs0MpDAMr1hxnblp5a4vcHg==", - "dev": true - }, - "buffer-writer": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/buffer-writer/-/buffer-writer-2.0.0.tgz", - "integrity": "sha512-a7ZpuTZU1TRtnwyCNW3I5dc0wWNC3VR9S++Ewyk2HHZdrO3CQJqSpd+95Us590V6AL7JqUAH2IwZ/398PmNFgw==" - }, - "bull": { - "version": "3.29.3", - "resolved": "https://registry.npmjs.org/bull/-/bull-3.29.3.tgz", - "integrity": "sha512-MOqV1dKLy1YQgP9m3lFolyMxaU+1+o4afzYYf0H4wNM+x/S0I1QPQfkgGlLiH00EyFrvSmeubeCYFP47rTfpjg==", - "dev": true, - "requires": { - "cron-parser": "^2.13.0", - "debuglog": "^1.0.0", - "get-port": "^5.1.1", - "ioredis": "^4.27.0", - "lodash": "^4.17.21", - "p-timeout": "^3.2.0", - "promise.prototype.finally": "^3.1.2", - "semver": "^7.3.2", - "util.promisify": "^1.0.1", - "uuid": "^8.3.0" - } - }, - "byte-length": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/byte-length/-/byte-length-1.0.2.tgz", - "integrity": "sha512-ovBpjmsgd/teRmgcPh23d4gJvxDoXtAzEL9xTfMU8Yc2kqCDb7L9jAG0XHl1nzuGl+h3ebCIF1i62UFyA9V/2Q==", - "dev": true - }, - "bytes": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.0.tgz", - "integrity": "sha512-zauLjrfCG+xvoyaqLoV8bLVXXNGC4JqlxFCutSDWA6fJrTo2ZuvLYTqZ7aHBLZSMOopbzwv8f+wZcVzfVTI2Dg==", - "dev": true - }, - "cacache": { - "version": "15.3.0", - "resolved": "https://registry.npmjs.org/cacache/-/cacache-15.3.0.tgz", - "integrity": "sha512-VVdYzXEn+cnbXpFgWs5hTT7OScegHVmLhJIR8Ufqk3iFD6A6j5iSX1KuBTfNEv4tdJWE2PzA6IVFtcLC7fN9wQ==", - "dev": true, - "optional": true, - "requires": { - "@npmcli/fs": "^1.0.0", - "@npmcli/move-file": "^1.0.1", - "chownr": "^2.0.0", - "fs-minipass": "^2.0.0", - "glob": "^7.1.4", - "infer-owner": "^1.0.4", - "lru-cache": "^6.0.0", - "minipass": "^3.1.1", - "minipass-collect": "^1.0.2", - "minipass-flush": "^1.0.5", - "minipass-pipeline": "^1.2.2", - "mkdirp": "^1.0.3", - "p-map": "^4.0.0", - "promise-inflight": "^1.0.1", - "rimraf": "^3.0.2", - "ssri": "^8.0.1", - "tar": "^6.0.2", - "unique-filename": "^1.1.1" - }, - "dependencies": { - "chownr": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", - "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", - "dev": true, - "optional": true - }, - "fs-minipass": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", - "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", - "dev": true, - "optional": true, - "requires": { - "minipass": "^3.0.0" - } - }, - "minipass": { - "version": "3.1.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.1.6.tgz", - "integrity": "sha512-rty5kpw9/z8SX9dmxblFA6edItUmwJgMeYDZRrwlIVN27i8gysGbznJwUggw2V/FVqFSDdWy040ZPS811DYAqQ==", - "dev": true, - "optional": true, - "requires": { - "yallist": "^4.0.0" - } - }, - "minizlib": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", - "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", - "dev": true, - "optional": true, - "requires": { - "minipass": "^3.0.0", - "yallist": "^4.0.0" - } - }, - "p-map": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz", - "integrity": "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==", - "dev": true, - "optional": true, - "requires": { - "aggregate-error": "^3.0.0" - } - }, - "tar": { - "version": "6.1.11", - "resolved": "https://registry.npmjs.org/tar/-/tar-6.1.11.tgz", - "integrity": "sha512-an/KZQzQUkZCkuoAA64hM92X0Urb6VpRhAFllDzz44U2mcD5scmT3zBc4VgVpkugF580+DQn8eAFSyoQt0tznA==", - "dev": true, - "optional": true, - "requires": { - "chownr": "^2.0.0", - "fs-minipass": "^2.0.0", - "minipass": "^3.0.0", - "minizlib": "^2.1.1", - "mkdirp": "^1.0.3", - "yallist": "^4.0.0" - } - } - } - }, - "call-bind": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz", - "integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==", - "dev": true, - "requires": { - "function-bind": "^1.1.1", - "get-intrinsic": "^1.0.2" - } - }, - "callback-stream": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/callback-stream/-/callback-stream-1.1.0.tgz", - "integrity": "sha1-RwGlEmbwbgbqpx/BcjOCLYdfSQg=", - "dev": true, - "requires": { - "inherits": "^2.0.1", - "readable-stream": "> 1.0.0 < 3.0.0" - } - }, - "callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true - }, - "camel-case": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/camel-case/-/camel-case-4.1.2.tgz", - "integrity": "sha512-gxGWBrTT1JuMx6R+o5PTXMmUnhnVzLQ9SNutD4YqKtI6ap897t3tKECYla6gCWEkplXnlNybEkZg9GEGxKFCgw==", - "dev": true, - "requires": { - "pascal-case": "^3.1.2", - "tslib": "^2.0.3" - }, - "dependencies": { - "tslib": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz", - "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw==", - "dev": true - } - } - }, - "capital-case": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/capital-case/-/capital-case-1.0.4.tgz", - "integrity": "sha512-ds37W8CytHgwnhGGTi88pcPyR15qoNkOpYwmMMfnWqqWgESapLqvDx6huFjQ5vqWSn2Z06173XNA7LtMOeUh1A==", - "dev": true, - "requires": { - "no-case": "^3.0.4", - "tslib": "^2.0.3", - "upper-case-first": "^2.0.2" - }, - "dependencies": { - "tslib": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz", - "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw==", - "dev": true - } - } - }, - "caseless": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", - "integrity": "sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw=", - "dev": true - }, - "cfb": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.1.tgz", - "integrity": "sha512-wT2ScPAFGSVy7CY+aauMezZBnNrfnaLSrxHUHdea+Td/86vrk6ZquggV+ssBR88zNs0OnBkL2+lf9q0K+zVGzQ==", - "dev": true, - "requires": { - "adler-32": "~1.3.0", - "crc-32": "~1.2.0", - "printj": "~1.3.0" - }, - "dependencies": { - "adler-32": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.0.tgz", - "integrity": "sha512-f5nltvjl+PRUh6YNfUstRaXwJxtfnKEWhAWWlmKvh+Y3J2+98a0KKVYDEhz6NdKGqswLhjNGznxfSsZGOvOd9g==", - "dev": true, - "requires": { - "printj": "~1.2.2" - }, - "dependencies": { - "printj": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/printj/-/printj-1.2.3.tgz", - "integrity": "sha512-sanczS6xOJOg7IKDvi4sGOUOe7c1tsEzjwlLFH/zgwx/uyImVM9/rgBkc8AfiQa/Vg54nRd8mkm9yI7WV/O+WA==", - "dev": true - } - } - }, - "printj": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/printj/-/printj-1.3.0.tgz", - "integrity": "sha512-017o8YIaz8gLhaNxRB9eBv2mWXI2CtzhPJALnQTP+OPpuUfP0RMWqr/mHCzqVeu1AQxfzSfAtAq66vKB8y7Lzg==", - "dev": true - } - } - }, - "chalk": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", - "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", - "dev": true, - "requires": { - "ansi-styles": "^3.2.1", - "escape-string-regexp": "^1.0.5", - "supports-color": "^5.3.0" - } - }, - "change-case": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/change-case/-/change-case-4.1.2.tgz", - "integrity": "sha512-bSxY2ws9OtviILG1EiY5K7NNxkqg/JnRnFxLtKQ96JaviiIxi7djMrSd0ECT9AC+lttClmYwKw53BWpOMblo7A==", - "dev": true, - "requires": { - "camel-case": "^4.1.2", - "capital-case": "^1.0.4", - "constant-case": "^3.0.4", - "dot-case": "^3.0.4", - "header-case": "^2.0.4", - "no-case": "^3.0.4", - "param-case": "^3.0.4", - "pascal-case": "^3.1.2", - "path-case": "^3.0.4", - "sentence-case": "^3.0.4", - "snake-case": "^3.0.4", - "tslib": "^2.0.3" - }, - "dependencies": { - "tslib": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz", - "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw==", - "dev": true - } - } - }, - "chardet": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/chardet/-/chardet-0.7.0.tgz", - "integrity": "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==", - "dev": true - }, - "cheerio": { - "version": "1.0.0-rc.6", - "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.0.0-rc.6.tgz", - "integrity": "sha512-hjx1XE1M/D5pAtMgvWwE21QClmAEeGHOIDfycgmndisdNgI6PE1cGRQkMGBcsbUbmEQyWu5PJLUcAOjtQS8DWw==", - "dev": true, - "requires": { - "cheerio-select": "^1.3.0", - "dom-serializer": "^1.3.1", - "domhandler": "^4.1.0", - "htmlparser2": "^6.1.0", - "parse5": "^6.0.1", - "parse5-htmlparser2-tree-adapter": "^6.0.1" - } - }, - "cheerio-select": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-1.5.0.tgz", - "integrity": "sha512-qocaHPv5ypefh6YNxvnbABM07KMxExbtbfuJoIie3iZXX1ERwYmJcIiRrr9H05ucQP1k28dav8rpdDgjQd8drg==", - "dev": true, - "requires": { - "css-select": "^4.1.3", - "css-what": "^5.0.1", - "domelementtype": "^2.2.0", - "domhandler": "^4.2.0", - "domutils": "^2.7.0" - } - }, - "class-validator": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.13.1.tgz", - "integrity": "sha512-zWIeYFhUitvAHBwNhDdCRK09hWx+P0HUwFE8US8/CxFpMVzkUK8RJl7yOIE+BVu2lxyPNgeOaFv78tLE47jBIg==", - "dev": true, - "requires": { - "@types/validator": "^13.1.3", - "libphonenumber-js": "^1.9.7", - "validator": "^13.5.2" - } - }, - "clean-stack": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-3.0.1.tgz", - "integrity": "sha512-lR9wNiMRcVQjSB3a7xXGLuz4cr4wJuuXlaAEbRutGowQTmlp7R72/DOgN21e8jdwblMWl9UOJMJXarX94pzKdg==", - "dev": true, - "requires": { - "escape-string-regexp": "4.0.0" - }, - "dependencies": { - "escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true - } - } - }, - "cli-color": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/cli-color/-/cli-color-0.1.7.tgz", - "integrity": "sha1-rcMgD6RxzCEbDaf1ZrcemLnWc0c=", - "dev": true, - "requires": { - "es5-ext": "0.8.x" - } - }, - "cli-cursor": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", - "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", - "dev": true, - "requires": { - "restore-cursor": "^3.1.0" - } - }, - "cli-highlight": { - "version": "2.1.11", - "resolved": "https://registry.npmjs.org/cli-highlight/-/cli-highlight-2.1.11.tgz", - "integrity": "sha512-9KDcoEVwyUXrjcJNvHD0NFc/hiwe/WPVYIleQh2O1N2Zro5gWJZ/K+3DGn8w8P/F6FxOgzyC5bxDyHIgCSPhGg==", - "dev": true, - "requires": { - "chalk": "^4.0.0", - "highlight.js": "^10.7.1", - "mz": "^2.4.0", - "parse5": "^5.1.1", - "parse5-htmlparser2-tree-adapter": "^6.0.0", - "yargs": "^16.0.0" - }, - "dependencies": { - "ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "requires": { - "color-convert": "^2.0.1" - } - }, - "chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "requires": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - } - }, - "color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "requires": { - "color-name": "~1.1.4" - } - }, - "color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true - }, - "parse5": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-5.1.1.tgz", - "integrity": "sha512-ugq4DFI0Ptb+WWjAdOK16+u/nHfiIrcE+sh8kZMaM0WllQKLI9rOUq6c2b7cwPkXdzfQESqvoqK6ug7U/Yyzug==", - "dev": true - }, - "supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "requires": { - "has-flag": "^4.0.0" - } - }, - "yargs": { - "version": "16.2.0", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", - "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", - "dev": true, - "requires": { - "cliui": "^7.0.2", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.0", - "y18n": "^5.0.5", - "yargs-parser": "^20.2.2" - } - } - } - }, - "cli-width": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-3.0.0.tgz", - "integrity": "sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw==", - "dev": true - }, - "client-oauth2": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/client-oauth2/-/client-oauth2-4.3.3.tgz", - "integrity": "sha512-k8AvUYJon0vv75ufoVo4nALYb/qwFFicO3I0+39C6xEdflqVtr+f9cy+0ZxAduoVSTfhP5DX2tY2XICAd5hy6Q==", - "dev": true, - "requires": { - "popsicle": "^12.0.5", - "safe-buffer": "^5.2.0" - }, - "dependencies": { - "safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "dev": true - } - } - }, - "cliui": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", - "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", - "dev": true, - "requires": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.0", - "wrap-ansi": "^7.0.0" - } - }, - "cluster-key-slot": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.0.tgz", - "integrity": "sha512-2Nii8p3RwAPiFwsnZvukotvow2rIHM+yQ6ZcBXGHdniadkYGZYiGmkHJIbZPIV9nfv7m/U1IPMVVcAhoWFeklw==", - "dev": true - }, - "codepage": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz", - "integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==", - "dev": true - }, - "color": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/color/-/color-3.0.0.tgz", - "integrity": "sha512-jCpd5+s0s0t7p3pHQKpnJ0TpQKKdleP71LWcA0aqiljpiuAkOSUFN/dyH8ZwF0hRmFlrIuRhufds1QyEP9EB+w==", - "dev": true, - "requires": { - "color-convert": "^1.9.1", - "color-string": "^1.5.2" - } - }, - "color-convert": { - "version": "1.9.3", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", - "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", - "dev": true, - "requires": { - "color-name": "1.1.3" - } - }, - "color-name": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=", - "dev": true - }, - "color-string": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.6.0.tgz", - "integrity": "sha512-c/hGS+kRWJutUBEngKKmk4iH3sD59MBkoxVapS/0wgpCz2u7XsNloxknyvBhzwEs1IbV36D9PwqLPJ2DTu3vMA==", - "dev": true, - "requires": { - "color-name": "^1.0.0", - "simple-swizzle": "^0.2.2" - } - }, - "color-support": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", - "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", - "dev": true - }, - "colornames": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/colornames/-/colornames-1.1.1.tgz", - "integrity": "sha1-+IiQMGhcfE/54qVZ9Qd+t2qBb5Y=", - "dev": true - }, - "colors": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/colors/-/colors-1.4.0.tgz", - "integrity": "sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA==", - "dev": true - }, - "colorspace": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/colorspace/-/colorspace-1.1.2.tgz", - "integrity": "sha512-vt+OoIP2d76xLhjwbBaucYlNSpPsrJWPlBTtwCpQKIu6/CSMutyzX93O/Do0qzpH3YoHEes8YEFXyZ797rEhzQ==", - "dev": true, - "requires": { - "color": "3.0.x", - "text-hex": "1.0.x" - } - }, - "combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dev": true, - "requires": { - "delayed-stream": "~1.0.0" - } - }, - "commander": { - "version": "2.20.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", - "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", - "dev": true - }, - "commist": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/commist/-/commist-1.1.0.tgz", - "integrity": "sha512-rraC8NXWOEjhADbZe9QBNzLAN5Q3fsTPQtBV+fEVj6xKIgDgNiEVE6ZNfHpZOqfQ21YUzfVNUXLOEZquYvQPPg==", - "dev": true, - "requires": { - "leven": "^2.1.0", - "minimist": "^1.1.0" - } - }, - "compressible": { - "version": "2.0.18", - "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", - "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", - "dev": true, - "requires": { - "mime-db": ">= 1.43.0 < 2" - } - }, - "compression": { - "version": "1.7.4", - "resolved": "https://registry.npmjs.org/compression/-/compression-1.7.4.tgz", - "integrity": "sha512-jaSIDzP9pZVS4ZfQ+TzvtiWhdpFhE2RDHz8QJkpX9SIpLq88VueF5jJw6t+6CUQcAoA6t+x89MLrWAqpfDE8iQ==", - "dev": true, - "requires": { - "accepts": "~1.3.5", - "bytes": "3.0.0", - "compressible": "~2.0.16", - "debug": "2.6.9", - "on-headers": "~1.0.2", - "safe-buffer": "5.1.2", - "vary": "~1.1.2" - }, - "dependencies": { - "bytes": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.0.0.tgz", - "integrity": "sha1-0ygVQE1olpn4Wk6k+odV3ROpYEg=", - "dev": true - }, - "debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dev": true, - "requires": { - "ms": "2.0.0" - } - }, - "ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", - "dev": true - } - } - }, - "concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", - "dev": true - }, - "concat-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", - "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", - "dev": true, - "requires": { - "buffer-from": "^1.0.0", - "inherits": "^2.0.3", - "readable-stream": "^3.0.2", - "typedarray": "^0.0.6" - }, - "dependencies": { - "readable-stream": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz", - "integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==", - "dev": true, - "requires": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - } - }, - "safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "dev": true - }, - "string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "dev": true, - "requires": { - "safe-buffer": "~5.2.0" - } - } - } - }, - "connect-history-api-fallback": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/connect-history-api-fallback/-/connect-history-api-fallback-1.6.0.tgz", - "integrity": "sha512-e54B99q/OUoH64zYYRf3HBP5z24G38h5D3qXu23JGRoigpX5Ss4r9ZnDk3g0Z8uQC2x2lPaJ+UlWBc1ZWBWdLg==", - "dev": true - }, - "console-control-strings": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", - "integrity": "sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4=", - "dev": true - }, - "constant-case": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/constant-case/-/constant-case-3.0.4.tgz", - "integrity": "sha512-I2hSBi7Vvs7BEuJDr5dDHfzb/Ruj3FyvFyh7KLilAjNQw3Be+xgqUBA2W6scVEcL0hL1dwPRtIqEPVUCKkSsyQ==", - "dev": true, - "requires": { - "no-case": "^3.0.4", - "tslib": "^2.0.3", - "upper-case": "^2.0.2" - }, - "dependencies": { - "tslib": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz", - "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw==", - "dev": true - } - } - }, - "content-disposition": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.3.tgz", - "integrity": "sha512-ExO0774ikEObIAEV9kDo50o+79VCUdEB6n6lzKgGwupcVeRlhrj3qGAfwq8G6uBJjkqLrhT0qEYFcWng8z1z0g==", - "dev": true, - "requires": { - "safe-buffer": "5.1.2" - } - }, - "content-type": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.4.tgz", - "integrity": "sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA==", - "dev": true - }, - "convict": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/convict/-/convict-6.2.3.tgz", - "integrity": "sha512-mTY04Qr7WrqiXifdeUYXr4/+Te4hPFWDvz6J2FVIKCLc2XBhq63VOSSYAKJ+unhZAYOAjmEdNswTOeHt7s++pQ==", - "dev": true, - "requires": { - "lodash.clonedeep": "^4.5.0", - "yargs-parser": "^20.2.7" - } - }, - "cookie": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.0.tgz", - "integrity": "sha512-+Hp8fLp57wnUSt0tY0tHEXh4voZRDnoIrZPqlo3DPiI4y9lwg/jqx+1Om94/W6ZaPDOUbnjOt/99w66zk+l1Xg==", - "dev": true - }, - "cookie-signature": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", - "integrity": "sha1-4wOogrNCzD7oylE6eZmXNNqzriw=", - "dev": true - }, - "core-util-is": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", - "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=", - "dev": true - }, - "cpu-features": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.2.tgz", - "integrity": "sha512-/2yieBqvMcRj8McNzkycjW2v3OIUOibBfd2dLEJ0nWts8NobAxwiyw9phVNS6oDL8x8tz9F7uNVFEVpJncQpeA==", - "dev": true, - "optional": true, - "requires": { - "nan": "^2.14.1" - } - }, - "crc-32": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.0.tgz", - "integrity": "sha512-1uBwHxF+Y/4yF5G48fwnKq6QsIXheor3ZLPT80yGBV1oEUwpPojlEhQbWKVw1VwcTQyMGHK1/XMmTjmlsmTTGA==", - "dev": true, - "requires": { - "exit-on-epipe": "~1.0.1", - "printj": "~1.1.0" - } - }, - "create-require": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", - "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", - "dev": true - }, - "cron": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/cron/-/cron-1.7.2.tgz", - "integrity": "sha512-+SaJ2OfeRvfQqwXQ2kgr0Y5pzBR/lijf5OpnnaruwWnmI799JfWr2jN2ItOV9s3A/+TFOt6mxvKzQq5F0Jp6VQ==", - "dev": true, - "requires": { - "moment-timezone": "^0.5.x" - } - }, - "cron-parser": { - "version": "2.18.0", - "resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-2.18.0.tgz", - "integrity": "sha512-s4odpheTyydAbTBQepsqd2rNWGa2iV3cyo8g7zbI2QQYGLVsfbhmwukayS1XHppe02Oy1fg7mg6xoaraVJeEcg==", - "dev": true, - "requires": { - "is-nan": "^1.3.0", - "moment-timezone": "^0.5.31" - } - }, - "cross-spawn": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-4.0.2.tgz", - "integrity": "sha1-e5JHYhwjrf3ThWAEqCPL45dCTUE=", - "dev": true, - "requires": { - "lru-cache": "^4.0.1", - "which": "^1.2.9" - }, - "dependencies": { - "lru-cache": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-4.1.5.tgz", - "integrity": "sha512-sWZlbEP2OsHNkXrMl5GYk/jKk70MBng6UU4YI/qGDYbgf6YbP4EvmqISbXCoJiRKs+1bSpFHVgQxvJ17F2li5g==", - "dev": true, - "requires": { - "pseudomap": "^1.0.2", - "yallist": "^2.1.2" - } - }, - "yallist": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-2.1.2.tgz", - "integrity": "sha1-HBH5IY8HYImkfdUS+TxmmaaoHVI=", - "dev": true - } - } - }, - "crypto-js": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.1.1.tgz", - "integrity": "sha512-o2JlM7ydqd3Qk9CA0L4NL6mTzU2sdx96a+oOfPu8Mkl/PK51vSyoi8/rQ8NknZtk44vq15lmhAj9CIAGwgeWKw==", - "dev": true - }, - "csrf": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/csrf/-/csrf-3.1.0.tgz", - "integrity": "sha512-uTqEnCvWRk042asU6JtapDTcJeeailFy4ydOQS28bj1hcLnYRiqi8SsD2jS412AY1I/4qdOwWZun774iqywf9w==", - "dev": true, - "requires": { - "rndm": "1.2.0", - "tsscmp": "1.0.6", - "uid-safe": "2.1.5" - } - }, - "css-select": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/css-select/-/css-select-4.1.3.tgz", - "integrity": "sha512-gT3wBNd9Nj49rAbmtFHj1cljIAOLYSX1nZ8CB7TBO3INYckygm5B7LISU/szY//YmdiSLbJvDLOx9VnMVpMBxA==", - "dev": true, - "requires": { - "boolbase": "^1.0.0", - "css-what": "^5.0.0", - "domhandler": "^4.2.0", - "domutils": "^2.6.0", - "nth-check": "^2.0.0" - } - }, - "css-what": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/css-what/-/css-what-5.1.0.tgz", - "integrity": "sha512-arSMRWIIFY0hV8pIxZMEfmMI47Wj3R/aWpZDDxWYCPEiOMv6tfOrnpDtgxBYPEQD4V0Y/958+1TdC3iWTFcUPw==", - "dev": true - }, - "dashdash": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", - "integrity": "sha1-hTz6D3y+L+1d4gMmuN1YEDX24vA=", - "dev": true, - "requires": { - "assert-plus": "^1.0.0" - } - }, - "date-utils": { - "version": "1.2.21", - "resolved": "https://registry.npmjs.org/date-utils/-/date-utils-1.2.21.tgz", - "integrity": "sha1-YfsWzcEnSzyayq/+n8ad+HIKK2Q=", - "dev": true - }, - "debug": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.2.tgz", - "integrity": "sha512-mOp8wKcvj7XxC78zLgw/ZA+6TSgkoE2C/ienthhRD298T7UNwAg9diBpLRxC0mOezLl4B0xV7M0cCO6P/O0Xhw==", - "dev": true, - "requires": { - "ms": "2.1.2" - } - }, - "debuglog": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/debuglog/-/debuglog-1.0.1.tgz", - "integrity": "sha1-qiT/uaw9+aI1GDfPstJ5NgzXhJI=", - "dev": true - }, - "deepmerge": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.2.2.tgz", - "integrity": "sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg==", - "dev": true - }, - "define-properties": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.1.3.tgz", - "integrity": "sha512-3MqfYKj2lLzdMSf8ZIZE/V+Zuy+BgD6f164e8K2w7dgnpKArBDerGYpM46IYYcjnkdPNMjPk9A6VFB8+3SKlXQ==", - "dev": true, - "requires": { - "object-keys": "^1.0.12" - } - }, - "delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=", - "dev": true - }, - "delegates": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", - "integrity": "sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o=", - "dev": true - }, - "denque": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/denque/-/denque-1.5.1.tgz", - "integrity": "sha512-XwE+iZ4D6ZUB7mfYRMb5wByE8L74HCn30FBN7sWnXksWc1LO1bPDl67pBR9o/kC4z/xSNAwkMYcGgqDV3BE3Hw==", - "dev": true - }, - "depd": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", - "integrity": "sha1-m81S4UwJd2PnSbJ0xDRu0uVgtak=", - "dev": true - }, - "destroy": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.0.4.tgz", - "integrity": "sha1-l4hXRCxEdJ5CBmE+N5RiBYJqvYA=", - "dev": true - }, - "diagnostics": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/diagnostics/-/diagnostics-1.1.1.tgz", - "integrity": "sha512-8wn1PmdunLJ9Tqbx+Fx/ZEuHfJf4NKSN2ZBj7SJC/OWRWha843+WsTjqMe1B5E3p28jqBlp+mJ2fPVxPyNgYKQ==", - "dev": true, - "requires": { - "colorspace": "1.1.x", - "enabled": "1.0.x", - "kuler": "1.0.x" - }, - "dependencies": { - "enabled": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/enabled/-/enabled-1.0.2.tgz", - "integrity": "sha1-ll9lE9LC0cX0ZStkouM5ZGf8L5M=", - "dev": true, - "requires": { - "env-variable": "0.0.x" - } - }, - "kuler": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/kuler/-/kuler-1.0.1.tgz", - "integrity": "sha512-J9nVUucG1p/skKul6DU3PUZrhs0LPulNaeUOox0IyXDi8S4CztTHs1gQphhuZmzXG7VOQSf6NJfKuzteQLv9gQ==", - "dev": true, - "requires": { - "colornames": "^1.1.1" - } - } - } - }, - "diff": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", - "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", - "dev": true - }, - "difflib": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/difflib/-/difflib-0.2.4.tgz", - "integrity": "sha1-teMDYabbAjF21WKJLbhZQKcY9H4=", - "dev": true, - "requires": { - "heap": ">= 0.2.0" - } - }, - "dir-glob": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", - "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", - "dev": true, - "requires": { - "path-type": "^4.0.0" - } - }, - "discontinuous-range": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/discontinuous-range/-/discontinuous-range-1.0.0.tgz", - "integrity": "sha1-44Mx8IRLukm5qctxx3FYWqsbxlo=", - "dev": true - }, - "dom-serializer": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.3.2.tgz", - "integrity": "sha512-5c54Bk5Dw4qAxNOI1pFEizPSjVsx5+bpJKmL2kPn8JhBUq2q09tTCa3mjijun2NfK78NMouDYNMBkOrPZiS+ig==", - "dev": true, - "requires": { - "domelementtype": "^2.0.1", - "domhandler": "^4.2.0", - "entities": "^2.0.0" - } - }, - "domelementtype": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.2.0.tgz", - "integrity": "sha512-DtBMo82pv1dFtUmHyr48beiuq792Sxohr+8Hm9zoxklYPfa6n0Z3Byjj2IV7bmr2IyqClnqEQhfgHJJ5QF0R5A==", - "dev": true - }, - "domhandler": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-4.2.2.tgz", - "integrity": "sha512-PzE9aBMsdZO8TK4BnuJwH0QT41wgMbRzuZrHUcpYncEjmQazq8QEaBWgLG7ZyC/DAZKEgglpIA6j4Qn/HmxS3w==", - "dev": true, - "requires": { - "domelementtype": "^2.2.0" - } - }, - "domutils": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz", - "integrity": "sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==", - "dev": true, - "requires": { - "dom-serializer": "^1.0.1", - "domelementtype": "^2.2.0", - "domhandler": "^4.2.0" - } - }, - "dot-case": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/dot-case/-/dot-case-3.0.4.tgz", - "integrity": "sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==", - "dev": true, - "requires": { - "no-case": "^3.0.4", - "tslib": "^2.0.3" - }, - "dependencies": { - "tslib": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz", - "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw==", - "dev": true - } - } - }, - "dotenv": { - "version": "8.6.0", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-8.6.0.tgz", - "integrity": "sha512-IrPdXQsk2BbzvCBGBOTmmSH5SodmqZNt4ERAZDmW4CT+tL8VtvinqywuANaFu4bOMWki16nqf0e4oC0QIaDr/g==", - "dev": true - }, - "dreamopt": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/dreamopt/-/dreamopt-0.6.0.tgz", - "integrity": "sha1-2BPM2sjTnYrVJndVFKE92mZNa0s=", - "dev": true, - "requires": { - "wordwrap": ">=0.0.2" - } - }, - "duplexify": { - "version": "3.7.1", - "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-3.7.1.tgz", - "integrity": "sha512-07z8uv2wMyS51kKhD1KsdXJg5WQ6t93RneqRxUHnskXVtlYYkLqM0gqStQZ3pj073g687jPCHrqNfCzawLYh5g==", - "dev": true, - "requires": { - "end-of-stream": "^1.0.0", - "inherits": "^2.0.1", - "readable-stream": "^2.0.0", - "stream-shift": "^1.0.0" - }, - "dependencies": { - "isarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=", - "dev": true - }, - "readable-stream": { - "version": "2.3.7", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", - "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", - "dev": true, - "requires": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" - } - }, - "string_decoder": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", - "dev": true, - "requires": { - "safe-buffer": "~5.1.0" - } - } - } - }, - "ecc-jsbn": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz", - "integrity": "sha1-OoOpBOVDUyh4dMVkt1SThoSamMk=", - "dev": true, - "requires": { - "jsbn": "~0.1.0", - "safer-buffer": "^2.1.0" - } - }, - "ecdsa-sig-formatter": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", - "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", - "dev": true, - "requires": { - "safe-buffer": "^5.0.1" - } - }, - "ee-first": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=", - "dev": true - }, - "emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true - }, - "enabled": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/enabled/-/enabled-2.0.0.tgz", - "integrity": "sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==", - "dev": true - }, - "encodeurl": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", - "integrity": "sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k=", - "dev": true - }, - "encoding": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", - "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", - "dev": true, - "optional": true, - "requires": { - "iconv-lite": "^0.6.2" - }, - "dependencies": { - "iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "dev": true, - "optional": true, - "requires": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - } - } - } - }, - "encoding-japanese": { - "version": "1.0.30", - "resolved": "https://registry.npmjs.org/encoding-japanese/-/encoding-japanese-1.0.30.tgz", - "integrity": "sha512-bd/DFLAoJetvv7ar/KIpE3CNO8wEuyrt9Xuw6nSMiZ+Vrz/Q21BPsMHvARL2Wz6IKHKXgb+DWZqtRg1vql9cBg==", - "dev": true - }, - "end-of-stream": { - "version": "1.4.4", - "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", - "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", - "dev": true, - "requires": { - "once": "^1.4.0" - } - }, - "entities": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz", - "integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==", - "dev": true - }, - "env-paths": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", - "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", - "dev": true, - "optional": true - }, - "env-variable": { - "version": "0.0.6", - "resolved": "https://registry.npmjs.org/env-variable/-/env-variable-0.0.6.tgz", - "integrity": "sha512-bHz59NlBbtS0NhftmR8+ExBEekE7br0e01jw+kk0NDro7TtZzBYZ5ScGPs3OmwnpyfHTHOtr1Y6uedCdrIldtg==", - "dev": true - }, - "err-code": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", - "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", - "dev": true - }, - "error-ex": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", - "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", - "dev": true, - "requires": { - "is-arrayish": "^0.2.1" - }, - "dependencies": { - "is-arrayish": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", - "integrity": "sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0=", - "dev": true - } - } - }, - "es-abstract": { - "version": "1.18.5", - "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.18.5.tgz", - "integrity": "sha512-DDggyJLoS91CkJjgauM5c0yZMjiD1uK3KcaCeAmffGwZ+ODWzOkPN4QwRbsK5DOFf06fywmyLci3ZD8jLGhVYA==", - "dev": true, - "requires": { - "call-bind": "^1.0.2", - "es-to-primitive": "^1.2.1", - "function-bind": "^1.1.1", - "get-intrinsic": "^1.1.1", - "has": "^1.0.3", - "has-symbols": "^1.0.2", - "internal-slot": "^1.0.3", - "is-callable": "^1.2.3", - "is-negative-zero": "^2.0.1", - "is-regex": "^1.1.3", - "is-string": "^1.0.6", - "object-inspect": "^1.11.0", - "object-keys": "^1.1.1", - "object.assign": "^4.1.2", - "string.prototype.trimend": "^1.0.4", - "string.prototype.trimstart": "^1.0.4", - "unbox-primitive": "^1.0.1" - } - }, - "es-to-primitive": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.1.tgz", - "integrity": "sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==", - "dev": true, - "requires": { - "is-callable": "^1.1.4", - "is-date-object": "^1.0.1", - "is-symbol": "^1.0.2" - } - }, - "es5-ext": { - "version": "0.8.2", - "resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.8.2.tgz", - "integrity": "sha1-q6jZ4ZQ6iVrJaDemKjmz9V7NlKs=", - "dev": true - }, - "es6-promise": { - "version": "4.2.8", - "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-4.2.8.tgz", - "integrity": "sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w==", - "dev": true - }, - "es6-promisify": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/es6-promisify/-/es6-promisify-5.0.0.tgz", - "integrity": "sha1-UQnWLz5W6pZ8S2NQWu8IKRyKUgM=", - "dev": true, - "requires": { - "es6-promise": "^4.0.3" - } - }, - "escalade": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", - "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==", - "dev": true - }, - "escape-html": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg=", - "dev": true - }, - "escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=", - "dev": true - }, - "eslint-config-riot": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/eslint-config-riot/-/eslint-config-riot-1.0.0.tgz", - "integrity": "sha1-+9ZThpgLMPvNDhMF1MP7hhTvIRk=", - "dev": true - }, - "etag": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", - "integrity": "sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc=", - "dev": true - }, - "event-target-shim": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", - "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", - "dev": true - }, - "events": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", - "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", - "dev": true - }, - "eventsource": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-1.1.0.tgz", - "integrity": "sha512-VSJjT5oCNrFvCS6igjzPAt5hBzQ2qPBFIbJ03zLI9SE0mxwZpMw6BfJrbFHm1a141AavMEB8JHmBhWAd66PfCg==", - "dev": true, - "requires": { - "original": "^1.0.0" - } - }, - "exit-on-epipe": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/exit-on-epipe/-/exit-on-epipe-1.0.1.tgz", - "integrity": "sha512-h2z5mrROTxce56S+pnvAV890uu7ls7f1kEvVGJbw1OlFH3/mlJ5bkXu0KRyW94v37zzHPiUd55iLn3DA7TjWpw==", - "dev": true - }, - "expand-tilde": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/expand-tilde/-/expand-tilde-2.0.2.tgz", - "integrity": "sha1-l+gBqgUt8CRU3kawK/YhZCzchQI=", - "dev": true, - "requires": { - "homedir-polyfill": "^1.0.1" - } - }, - "express": { - "version": "4.17.1", - "resolved": "https://registry.npmjs.org/express/-/express-4.17.1.tgz", - "integrity": "sha512-mHJ9O79RqluphRrcw2X/GTh3k9tVv8YcoyY4Kkh4WDMUYKRZUq0h1o0w2rrrxBqM7VoeUVqgb27xlEMXTnYt4g==", - "dev": true, - "requires": { - "accepts": "~1.3.7", - "array-flatten": "1.1.1", - "body-parser": "1.19.0", - "content-disposition": "0.5.3", - "content-type": "~1.0.4", - "cookie": "0.4.0", - "cookie-signature": "1.0.6", - "debug": "2.6.9", - "depd": "~1.1.2", - "encodeurl": "~1.0.2", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "finalhandler": "~1.1.2", - "fresh": "0.5.2", - "merge-descriptors": "1.0.1", - "methods": "~1.1.2", - "on-finished": "~2.3.0", - "parseurl": "~1.3.3", - "path-to-regexp": "0.1.7", - "proxy-addr": "~2.0.5", - "qs": "6.7.0", - "range-parser": "~1.2.1", - "safe-buffer": "5.1.2", - "send": "0.17.1", - "serve-static": "1.14.1", - "setprototypeof": "1.1.1", - "statuses": "~1.5.0", - "type-is": "~1.6.18", - "utils-merge": "1.0.1", - "vary": "~1.1.2" - }, - "dependencies": { - "debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dev": true, - "requires": { - "ms": "2.0.0" - } - }, - "ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", - "dev": true - } - } - }, - "extend": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", - "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", - "dev": true - }, - "external-editor": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/external-editor/-/external-editor-3.1.0.tgz", - "integrity": "sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==", - "dev": true, - "requires": { - "chardet": "^0.7.0", - "iconv-lite": "^0.4.24", - "tmp": "^0.0.33" - } - }, - "extsprintf": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz", - "integrity": "sha1-lpGEQOMEGnpBT4xS48V06zw+HgU=", - "dev": true - }, - "fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true - }, - "fast-glob": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.7.tgz", - "integrity": "sha512-rYGMRwip6lUMvYD3BTScMwT1HtAs2d71SMv66Vrxs0IekGZEjhM0pcMfjQPnknBt2zeCwQMEupiN02ZP4DiT1Q==", - "dev": true, - "requires": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.4" - } - }, - "fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true - }, - "fastq": { - "version": "1.13.0", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.13.0.tgz", - "integrity": "sha512-YpkpUnK8od0o1hmeSc7UUs/eB/vIPWJYjKck2QKIzAf71Vm1AAQ3EbuZB3g2JIy+pg+ERD0vqI79KyZiB2e2Nw==", - "dev": true, - "requires": { - "reusify": "^1.0.4" - } - }, - "fecha": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/fecha/-/fecha-4.2.1.tgz", - "integrity": "sha512-MMMQ0ludy/nBs1/o0zVOiKTpG7qMbonKUzjJgQFEuvq6INZ1OraKPRAWkBq5vlKLOUMpmNYG1JoN3oDPUQ9m3Q==", - "dev": true - }, - "fflate": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.7.1.tgz", - "integrity": "sha512-VYM2Xy1gSA5MerKzCnmmuV2XljkpKwgJBKezW+495TTnTCh1x5HcYa1aH8wRU/MfTGhW4ziXqgwprgQUVl3Ohw==", - "dev": true - }, - "figlet": { - "version": "1.5.2", - "resolved": "https://registry.npmjs.org/figlet/-/figlet-1.5.2.tgz", - "integrity": "sha512-WOn21V8AhyE1QqVfPIVxe3tupJacq1xGkPTB4iagT6o+P2cAgEOOwIxMftr4+ZCTI6d551ij9j61DFr0nsP2uQ==", - "dev": true - }, - "figures": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz", - "integrity": "sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==", - "dev": true, - "requires": { - "escape-string-regexp": "^1.0.5" - } - }, - "file-type": { - "version": "14.7.1", - "resolved": "https://registry.npmjs.org/file-type/-/file-type-14.7.1.tgz", - "integrity": "sha512-sXAMgFk67fQLcetXustxfKX+PZgHIUFn96Xld9uH8aXPdX3xOp0/jg9OdouVTvQrf7mrn+wAa4jN/y9fUOOiRA==", - "dev": true, - "requires": { - "readable-web-to-node-stream": "^2.0.0", - "strtok3": "^6.0.3", - "token-types": "^2.0.0", - "typedarray-to-buffer": "^3.1.5" - } - }, - "fill-range": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", - "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", - "dev": true, - "requires": { - "to-regex-range": "^5.0.1" - } - }, - "finalhandler": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.2.tgz", - "integrity": "sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==", - "dev": true, - "requires": { - "debug": "2.6.9", - "encodeurl": "~1.0.2", - "escape-html": "~1.0.3", - "on-finished": "~2.3.0", - "parseurl": "~1.3.3", - "statuses": "~1.5.0", - "unpipe": "~1.0.0" - }, - "dependencies": { - "debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dev": true, - "requires": { - "ms": "2.0.0" - } - }, - "ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", - "dev": true - } - } - }, - "flatted": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-2.0.2.tgz", - "integrity": "sha512-r5wGx7YeOwNWNlCA0wQ86zKyDLMQr+/RB8xy74M4hTphfmjlijTSSXGuH8rnvKZnfT9i+75zmd8jcKdMR4O6jA==", - "dev": true - }, - "fn.name": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/fn.name/-/fn.name-1.1.0.tgz", - "integrity": "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==", - "dev": true - }, - "follow-redirects": { - "version": "1.14.8", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.8.tgz", - "integrity": "sha512-1x0S9UVJHsQprFcEC/qnNzBLcIxsjAV905f/UkQxbclCsoTWlacCNOpQa/anodLl2uaEKFhfWOvM2Qg77+15zA==", - "dev": true - }, - "for-each": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", - "integrity": "sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==", - "dev": true, - "requires": { - "is-callable": "^1.1.3" - } - }, - "forever-agent": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz", - "integrity": "sha1-+8cfDEGt6zf5bFd60e1C2P2sypE=", - "dev": true - }, - "form-data": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", - "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", - "dev": true, - "requires": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "mime-types": "^2.1.12" - } - }, - "formidable": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/formidable/-/formidable-1.2.2.tgz", - "integrity": "sha512-V8gLm+41I/8kguQ4/o1D3RIHRmhYFG4pnNyonvua+40rqcEmT4+V71yaZ3B457xbbgCsCfjSPi65u/W6vK1U5Q==", - "dev": true - }, - "forwarded": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", - "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", - "dev": true - }, - "frac": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz", - "integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==", - "dev": true - }, - "fresh": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", - "integrity": "sha1-PYyt2Q2XZWn6g1qx+OSyOhBWBac=", - "dev": true - }, - "fs-extra": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", - "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", - "dev": true, - "requires": { - "graceful-fs": "^4.2.0", - "jsonfile": "^4.0.0", - "universalify": "^0.1.0" - } - }, - "fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=", - "dev": true - }, - "function-bind": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", - "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==", - "dev": true - }, - "generate-function": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", - "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==", - "dev": true, - "requires": { - "is-property": "^1.0.2" - } - }, - "generic-pool": { - "version": "3.8.2", - "resolved": "https://registry.npmjs.org/generic-pool/-/generic-pool-3.8.2.tgz", - "integrity": "sha512-nGToKy6p3PAbYQ7p1UlWl6vSPwfwU6TMSWK7TTu+WUY4ZjyZQGniGGt2oNVvyNSpyZYSB43zMXVLcBm08MTMkg==", - "dev": true - }, - "get-caller-file": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true - }, - "get-intrinsic": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.1.1.tgz", - "integrity": "sha512-kWZrnVM42QCiEA2Ig1bG8zjoIMOgxWwYCEeNdwY6Tv/cOSeGpcoX4pXHfKUxNKVoArnrEr2e9srnAxxGIraS9Q==", - "dev": true, - "requires": { - "function-bind": "^1.1.1", - "has": "^1.0.3", - "has-symbols": "^1.0.1" - } - }, - "get-port": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/get-port/-/get-port-5.1.1.tgz", - "integrity": "sha512-g/Q1aTSDOxFpchXC4i8ZWvxA1lnPqx/JHqcpIw0/LX9T8x/GBbi6YnlN5nhaKIFkT8oFsscUKgDJYxfwfS6QsQ==", - "dev": true - }, - "get-symbol-description": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.0.0.tgz", - "integrity": "sha512-2EmdH1YvIQiZpltCNgkuiUnyukzxM/R6NDJX31Ke3BG1Nq5b0S2PhX59UKi9vZpPDQVdqn+1IcaAwnzTT5vCjw==", - "dev": true, - "requires": { - "call-bind": "^1.0.2", - "get-intrinsic": "^1.1.1" - } - }, - "get-system-fonts": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/get-system-fonts/-/get-system-fonts-2.0.2.tgz", - "integrity": "sha512-zzlgaYnHMIEgHRrfC7x0Qp0Ylhw/sHpM6MHXeVBTYIsvGf5GpbnClB+Q6rAPdn+0gd2oZZIo6Tj3EaWrt4VhDQ==", - "dev": true - }, - "getpass": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz", - "integrity": "sha1-Xv+OPmhNVprkyysSgmBOi6YhSfo=", - "dev": true, - "requires": { - "assert-plus": "^1.0.0" - } - }, - "glob": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.0.tgz", - "integrity": "sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q==", - "dev": true, - "requires": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.0.4", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - } - }, - "glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "requires": { - "is-glob": "^4.0.1" - } - }, - "glob-stream": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/glob-stream/-/glob-stream-6.1.0.tgz", - "integrity": "sha1-cEXJlBOz65SIjYOrRtC0BMx73eQ=", - "dev": true, - "requires": { - "extend": "^3.0.0", - "glob": "^7.1.1", - "glob-parent": "^3.1.0", - "is-negated-glob": "^1.0.0", - "ordered-read-streams": "^1.0.0", - "pumpify": "^1.3.5", - "readable-stream": "^2.1.5", - "remove-trailing-separator": "^1.0.1", - "to-absolute-glob": "^2.0.0", - "unique-stream": "^2.0.2" - }, - "dependencies": { - "glob-parent": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-3.1.0.tgz", - "integrity": "sha1-nmr2KZ2NO9K9QEMIMr0RPfkGxa4=", - "dev": true, - "requires": { - "is-glob": "^3.1.0", - "path-dirname": "^1.0.0" - } - }, - "is-glob": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-3.1.0.tgz", - "integrity": "sha1-e6WuJCF4BKxwcHuWkiVnSGzD6Eo=", - "dev": true, - "requires": { - "is-extglob": "^2.1.0" - } - }, - "isarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=", - "dev": true - }, - "readable-stream": { - "version": "2.3.7", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", - "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", - "dev": true, - "requires": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" - } - }, - "string_decoder": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", - "dev": true, - "requires": { - "safe-buffer": "~5.1.0" - } - } - } - }, - "globby": { - "version": "11.0.4", - "resolved": "https://registry.npmjs.org/globby/-/globby-11.0.4.tgz", - "integrity": "sha512-9O4MVG9ioZJ08ffbcyVYyLOJLk5JQ688pJ4eMGLpdWLHq/Wr1D9BlriLQyL0E+jbkuePVZXYFj47QM/v093wHg==", - "dev": true, - "requires": { - "array-union": "^2.1.0", - "dir-glob": "^3.0.1", - "fast-glob": "^3.1.1", - "ignore": "^5.1.4", - "merge2": "^1.3.0", - "slash": "^3.0.0" - } - }, - "gm": { - "version": "1.23.1", - "resolved": "https://registry.npmjs.org/gm/-/gm-1.23.1.tgz", - "integrity": "sha1-Lt7rlYCE0PjqeYjl2ZWxx9/BR3c=", - "dev": true, - "requires": { - "array-parallel": "~0.1.3", - "array-series": "~0.1.5", - "cross-spawn": "^4.0.0", - "debug": "^3.1.0" - }, - "dependencies": { - "debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", - "dev": true, - "requires": { - "ms": "^2.1.1" - } - } - } - }, - "google-timezones-json": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/google-timezones-json/-/google-timezones-json-1.0.2.tgz", - "integrity": "sha512-UWXQ7BpSCW8erDespU2I4cri22xsKgwOCyhsJal0OJhi2tFpwJpsYNJt4vCiFPL1p2HzCGiS713LKpNR25n9Kg==", - "dev": true - }, - "graceful-fs": { - "version": "4.2.8", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.8.tgz", - "integrity": "sha512-qkIilPUYcNhJpd33n0GBXTB1MMPp14TxEsEs0pTrsSVucApsYzW5V+Q8Qxhik6KU3evy+qkAAowTByymK0avdg==", - "dev": true - }, - "har-schema": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz", - "integrity": "sha1-qUwiJOvKwEeCoNkDVSHyRzW37JI=", - "dev": true - }, - "har-validator": { - "version": "5.1.5", - "resolved": "https://registry.npmjs.org/har-validator/-/har-validator-5.1.5.tgz", - "integrity": "sha512-nmT2T0lljbxdQZfspsno9hgrG3Uir6Ks5afism62poxqBM6sDnMEuPmzTq8XN0OEwqKLLdh1jQI3qyE66Nzb3w==", - "dev": true, - "requires": { - "ajv": "^6.12.3", - "har-schema": "^2.0.0" - } - }, - "has": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", - "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", - "dev": true, - "requires": { - "function-bind": "^1.1.1" - } - }, - "has-ansi": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/has-ansi/-/has-ansi-2.0.0.tgz", - "integrity": "sha1-NPUEnOHs3ysGSa8+8k5F7TVBbZE=", - "dev": true, - "requires": { - "ansi-regex": "^2.0.0" - }, - "dependencies": { - "ansi-regex": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", - "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=", - "dev": true - } - } - }, - "has-bigints": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.0.1.tgz", - "integrity": "sha512-LSBS2LjbNBTf6287JEbEzvJgftkF5qFkmCo9hDRpAzKhUOlJ+hx8dd4USs00SgsUNwc4617J9ki5YtEClM2ffA==", - "dev": true - }, - "has-flag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=", - "dev": true - }, - "has-symbols": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.2.tgz", - "integrity": "sha512-chXa79rL/UC2KlX17jo3vRGz0azaWEx5tGqZg5pO3NUyEJVB17dMruQlzCCOfUvElghKcm5194+BCRvi2Rv/Gw==", - "dev": true - }, - "has-tostringtag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.0.tgz", - "integrity": "sha512-kFjcSNhnlGV1kyoGk7OXKSawH5JOb/LzUc5w9B02hOTO0dfFRjbHQKvg1d6cf3HbeUmtU9VbbV3qzZ2Teh97WQ==", - "dev": true, - "requires": { - "has-symbols": "^1.0.2" - } - }, - "has-unicode": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", - "integrity": "sha1-4Ob+aijPUROIVeCG0Wkedx3iqLk=", - "dev": true - }, - "he": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", - "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", - "dev": true - }, - "header-case": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/header-case/-/header-case-2.0.4.tgz", - "integrity": "sha512-H/vuk5TEEVZwrR0lp2zed9OCo1uAILMlx0JEMgC26rzyJJ3N1v6XkwHHXJQdR2doSjcGPM6OKPYoJgf0plJ11Q==", - "dev": true, - "requires": { - "capital-case": "^1.0.4", - "tslib": "^2.0.3" - }, - "dependencies": { - "tslib": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz", - "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw==", - "dev": true - } - } - }, - "heap": { - "version": "0.2.6", - "resolved": "https://registry.npmjs.org/heap/-/heap-0.2.6.tgz", - "integrity": "sha1-CH4fELBGky/IWU3Z5tN4r8nR5aw=", - "dev": true - }, - "help-me": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/help-me/-/help-me-1.1.0.tgz", - "integrity": "sha1-jy1QjQYAtKRW2i8IZVbn5cBWo8Y=", - "dev": true, - "requires": { - "callback-stream": "^1.0.2", - "glob-stream": "^6.1.0", - "through2": "^2.0.1", - "xtend": "^4.0.0" - } - }, - "highlight.js": { - "version": "10.7.3", - "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz", - "integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==", - "dev": true - }, - "homedir-polyfill": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/homedir-polyfill/-/homedir-polyfill-1.0.3.tgz", - "integrity": "sha512-eSmmWE5bZTK2Nou4g0AI3zZ9rswp7GRKoKXS1BLUkvPviOqs4YTN1djQIqrXy9k5gEtdLPy86JjRwsNM9tnDcA==", - "dev": true, - "requires": { - "parse-passwd": "^1.0.0" - } - }, - "hosted-git-info": { - "version": "2.8.9", - "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz", - "integrity": "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==", - "dev": true - }, - "html-to-text": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-8.0.0.tgz", - "integrity": "sha512-fEtul1OerF2aMEV+Wpy+Ue20tug134jOY1GIudtdqZi7D0uTudB2tVJBKfVhTL03dtqeJoF8gk8EPX9SyMEvLg==", - "dev": true, - "requires": { - "@selderee/plugin-htmlparser2": "^0.6.0", - "deepmerge": "^4.2.2", - "he": "^1.2.0", - "htmlparser2": "^6.1.0", - "minimist": "^1.2.5", - "selderee": "^0.6.0" - } - }, - "htmlparser2": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-6.1.0.tgz", - "integrity": "sha512-gyyPk6rgonLFEDGoeRgQNaEUvdJ4ktTmmUh/h2t7s+M8oPpIPxgNACWa+6ESR57kXstwqPiCut0V8NRpcwgU7A==", - "dev": true, - "requires": { - "domelementtype": "^2.0.1", - "domhandler": "^4.0.0", - "domutils": "^2.5.2", - "entities": "^2.0.0" - } - }, - "http-cache-semantics": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.0.tgz", - "integrity": "sha512-carPklcUh7ROWRK7Cv27RPtdhYhUsela/ue5/jKzjegVvXDqM2ILE9Q2BGn9JZJh1g87cp56su/FgQSzcWS8cQ==", - "dev": true, - "optional": true - }, - "http-errors": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.7.2.tgz", - "integrity": "sha512-uUQBt3H/cSIVfch6i1EuPNy/YsRSOUBXTVfZ+yR7Zjez3qjBz6i9+i4zjNaoqcoFVI4lQJ5plg63TvGfRSDCRg==", - "dev": true, - "requires": { - "depd": "~1.1.2", - "inherits": "2.0.3", - "setprototypeof": "1.1.1", - "statuses": ">= 1.5.0 < 2", - "toidentifier": "1.0.0" - }, - "dependencies": { - "inherits": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", - "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=", - "dev": true - } - } - }, - "http-proxy-agent": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-4.0.1.tgz", - "integrity": "sha512-k0zdNgqWTGA6aeIRVpvfVob4fL52dTfaehylg0Y4UvSySvOq/Y+BOyPrgpUrA7HylqvU8vIZGsRuXmspskV0Tg==", - "dev": true, - "requires": { - "@tootallnate/once": "1", - "agent-base": "6", - "debug": "4" - } - }, - "http-signature": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.2.0.tgz", - "integrity": "sha1-muzZJRFHcvPZW2WmCruPfBj7rOE=", - "dev": true, - "requires": { - "assert-plus": "^1.0.0", - "jsprim": "^1.2.2", - "sshpk": "^1.7.0" - } - }, - "https-proxy-agent": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.0.tgz", - "integrity": "sha512-EkYm5BcKUGiduxzSt3Eppko+PiNWNEpa4ySk9vTC6wDsQJW9rHSa+UhGNJoRYp7bz6Ht1eaRIa6QaJqO5rCFbA==", - "dev": true, - "requires": { - "agent-base": "6", - "debug": "4" - } - }, - "humanize-ms": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", - "integrity": "sha1-xG4xWaKT9riW2ikxbYtv6Lt5u+0=", - "dev": true, - "optional": true, - "requires": { - "ms": "^2.0.0" - } - }, - "iconv-lite": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", - "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", - "dev": true, - "requires": { - "safer-buffer": ">= 2.1.2 < 3" - } - }, - "ics": { - "version": "2.31.0", - "resolved": "https://registry.npmjs.org/ics/-/ics-2.31.0.tgz", - "integrity": "sha512-3pW62uD097nl6LfFXIt92eBZtbwDESXsaRcgZPn3NO01zpUUM+L2G6fjf6qXhiyFcGIrJjsGuNB/y3AV58CvFg==", - "dev": true, - "requires": { - "nanoid": "^3.1.23", - "yup": "^0.32.9" - } - }, - "ieee754": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "dev": true - }, - "ignore": { - "version": "5.1.8", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.1.8.tgz", - "integrity": "sha512-BMpfD7PpiETpBl/A6S498BaIJ6Y/ABT93ETbby2fP00v4EbvPBXWEoaR1UBPKs3iR53pJY7EtZk5KACI57i1Uw==", - "dev": true - }, - "imap": { - "version": "0.8.19", - "resolved": "https://registry.npmjs.org/imap/-/imap-0.8.19.tgz", - "integrity": "sha1-NniHOTSrCc6mukh0HyhNoq9Z2NU=", - "dev": true, - "requires": { - "readable-stream": "1.1.x", - "utf7": ">=1.0.2" - } - }, - "imap-simple": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/imap-simple/-/imap-simple-4.3.0.tgz", - "integrity": "sha512-SW3LtfEJFjlJKS/h2CmpX2IKpya2RXobR3ENJJW4iMQ3QYPxWxf5oeaz1K3P4eGUwfGEndkqt7uVDKnEyG9zeQ==", - "dev": true, - "requires": { - "iconv-lite": "~0.4.13", - "imap": "^0.8.18", - "nodeify": "^1.0.0", - "quoted-printable": "^1.0.0", - "utf8": "^2.1.1", - "uuencode": "0.0.4" - } - }, - "imurmurhash": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha1-khi5srkoojixPcT7a21XbyMUU+o=", - "dev": true, - "optional": true - }, - "indent-string": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", - "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", - "dev": true - }, - "infer-owner": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/infer-owner/-/infer-owner-1.0.4.tgz", - "integrity": "sha512-IClj+Xz94+d7irH5qRyfJonOdfTzuDaifE6ZPWfx0N0+/ATZCbuTPq2prFl526urkQd90WyUKIh1DfBQ2hMz9A==", - "dev": true, - "optional": true - }, - "inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", - "dev": true, - "requires": { - "once": "^1.3.0", - "wrappy": "1" - } - }, - "inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" - }, - "inquirer": { - "version": "7.3.3", - "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-7.3.3.tgz", - "integrity": "sha512-JG3eIAj5V9CwcGvuOmoo6LB9kbAYT8HXffUl6memuszlwDC/qvFAJw49XJ5NROSFNPxp3iQg1GqkFhaY/CR0IA==", - "dev": true, - "requires": { - "ansi-escapes": "^4.2.1", - "chalk": "^4.1.0", - "cli-cursor": "^3.1.0", - "cli-width": "^3.0.0", - "external-editor": "^3.0.3", - "figures": "^3.0.0", - "lodash": "^4.17.19", - "mute-stream": "0.0.8", - "run-async": "^2.4.0", - "rxjs": "^6.6.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0", - "through": "^2.3.6" - }, - "dependencies": { - "ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "requires": { - "color-convert": "^2.0.1" - } - }, - "chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "requires": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - } - }, - "color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "requires": { - "color-name": "~1.1.4" - } - }, - "color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true - }, - "supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "requires": { - "has-flag": "^4.0.0" - } - } - } - }, - "internal-slot": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.3.tgz", - "integrity": "sha512-O0DB1JC/sPyZl7cIo78n5dR7eUSwwpYPiXRhTzNxZVAMUuB8vlnRFyLxdrVToks6XPLVnFfbzaVd5WLjhgg+vA==", - "dev": true, - "requires": { - "get-intrinsic": "^1.1.0", - "has": "^1.0.3", - "side-channel": "^1.0.4" - } - }, - "ioredis": { - "version": "4.28.0", - "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-4.28.0.tgz", - "integrity": "sha512-I+zkeeWp3XFgPT2CtJKxvaF5FjGBGt4yGYljRjQecdQKteThuAsKqffeF1lgHVlYnuNeozRbPOCDNZ7tDWPeig==", - "dev": true, - "requires": { - "cluster-key-slot": "^1.1.0", - "debug": "^4.3.1", - "denque": "^1.1.0", - "lodash.defaults": "^4.2.0", - "lodash.flatten": "^4.4.0", - "lodash.isarguments": "^3.1.0", - "p-map": "^2.1.0", - "redis-commands": "1.7.0", - "redis-errors": "^1.2.0", - "redis-parser": "^3.0.0", - "standard-as-callback": "^2.1.0" - } - }, - "ip": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/ip/-/ip-1.1.5.tgz", - "integrity": "sha1-vd7XARQpCCjAoDnnLvJfWq7ENUo=", - "dev": true, - "optional": true - }, - "ip-regex": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/ip-regex/-/ip-regex-2.1.0.tgz", - "integrity": "sha1-+ni/XS5pE8kRzp+BnuUUa7bYROk=", - "dev": true - }, - "ipaddr.js": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", - "dev": true - }, - "is-absolute": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-absolute/-/is-absolute-1.0.0.tgz", - "integrity": "sha512-dOWoqflvcydARa360Gvv18DZ/gRuHKi2NU/wU5X1ZFzdYfH29nkiNZsF3mp4OJ3H4yo9Mx8A/uAGNzpzPN3yBA==", - "dev": true, - "requires": { - "is-relative": "^1.0.0", - "is-windows": "^1.0.1" - } - }, - "is-arrayish": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz", - "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==", - "dev": true - }, - "is-bigint": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.0.4.tgz", - "integrity": "sha512-zB9CruMamjym81i2JZ3UMn54PKGsQzsJeo6xvN3HJJ4CAsQNB6iRutp2To77OfCNuoxspsIhzaPoO1zyCEhFOg==", - "dev": true, - "requires": { - "has-bigints": "^1.0.1" - } - }, - "is-boolean-object": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.1.2.tgz", - "integrity": "sha512-gDYaKHJmnj4aWxyj6YHyXVpdQawtVLHU5cb+eztPGczf6cjuTdwve5ZIEfgXqH4e57An1D1AKf8CZ3kYrQRqYA==", - "dev": true, - "requires": { - "call-bind": "^1.0.2", - "has-tostringtag": "^1.0.0" - } - }, - "is-callable": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.4.tgz", - "integrity": "sha512-nsuwtxZfMX67Oryl9LCQ+upnC0Z0BgpwntpS89m1H/TLF0zNfzfLMV/9Wa/6MZsj0acpEjAO0KF1xT6ZdLl95w==", - "dev": true - }, - "is-core-module": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.6.0.tgz", - "integrity": "sha512-wShG8vs60jKfPWpF2KZRaAtvt3a20OAn7+IJ6hLPECpSABLcKtFKTTI4ZtH5QcBruBHlq+WsdHWyz0BCZW7svQ==", - "dev": true, - "requires": { - "has": "^1.0.3" - } - }, - "is-date-object": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.5.tgz", - "integrity": "sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ==", - "dev": true, - "requires": { - "has-tostringtag": "^1.0.0" - } - }, - "is-docker": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", - "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", - "dev": true - }, - "is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=", - "dev": true - }, - "is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true - }, - "is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, - "requires": { - "is-extglob": "^2.1.1" - } - }, - "is-lambda": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-lambda/-/is-lambda-1.0.1.tgz", - "integrity": "sha1-PZh3iZ5qU+/AFgUEzeFfgubwYdU=", - "dev": true, - "optional": true - }, - "is-nan": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/is-nan/-/is-nan-1.3.2.tgz", - "integrity": "sha512-E+zBKpQ2t6MEo1VsonYmluk9NxGrbzpeeLC2xIViuO2EjU2xsXsBPwTr3Ykv9l08UYEVEdWeRZNouaZqF6RN0w==", - "dev": true, - "requires": { - "call-bind": "^1.0.0", - "define-properties": "^1.1.3" - } - }, - "is-negated-glob": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-negated-glob/-/is-negated-glob-1.0.0.tgz", - "integrity": "sha1-aRC8pdqMleeEtXUbl2z1oQ/uNtI=", - "dev": true - }, - "is-negative-zero": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.1.tgz", - "integrity": "sha512-2z6JzQvZRa9A2Y7xC6dQQm4FSTSTNWjKIYYTt4246eMTJmIo0Q+ZyOsU66X8lxK1AbB92dFeglPLrhwpeRKO6w==", - "dev": true - }, - "is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true - }, - "is-number-object": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.0.6.tgz", - "integrity": "sha512-bEVOqiRcvo3zO1+G2lVMy+gkkEm9Yh7cDMRusKKu5ZJKPUYSJwICTKZrNKHA2EbSP0Tu0+6B/emsYNHZyn6K8g==", - "dev": true, - "requires": { - "has-tostringtag": "^1.0.0" - } - }, - "is-promise": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-1.0.1.tgz", - "integrity": "sha1-MVc3YcBX4zwukaq56W2gjO++duU=", - "dev": true - }, - "is-property": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", - "integrity": "sha1-V/4cTkhHTt1lsJkR8msc1Ald2oQ=", - "dev": true - }, - "is-regex": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz", - "integrity": "sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==", - "dev": true, - "requires": { - "call-bind": "^1.0.2", - "has-tostringtag": "^1.0.0" - } - }, - "is-relative": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-relative/-/is-relative-1.0.0.tgz", - "integrity": "sha512-Kw/ReK0iqwKeu0MITLFuj0jbPAmEiOsIwyIXvvbfa6QfmN9pkD1M+8pdk7Rl/dTKbH34/XBFMbgD4iMJhLQbGA==", - "dev": true, - "requires": { - "is-unc-path": "^1.0.0" - } - }, - "is-shared-array-buffer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.1.tgz", - "integrity": "sha512-IU0NmyknYZN0rChcKhRO1X8LYz5Isj/Fsqh8NJOSf+N/hCOTwy29F32Ik7a+QszE63IdvmwdTPDd6cZ5pg4cwA==", - "dev": true - }, - "is-stream": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", - "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", - "dev": true - }, - "is-string": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.7.tgz", - "integrity": "sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg==", - "dev": true, - "requires": { - "has-tostringtag": "^1.0.0" - } - }, - "is-symbol": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.4.tgz", - "integrity": "sha512-C/CPBqKWnvdcxqIARxyOh4v1UUEOCHpgDa0WYgpKDFMszcrPcffg5uhwSgPCLD2WWxmq6isisz87tzT01tuGhg==", - "dev": true, - "requires": { - "has-symbols": "^1.0.2" - } - }, - "is-typedarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", - "integrity": "sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=", - "dev": true - }, - "is-unc-path": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-unc-path/-/is-unc-path-1.0.0.tgz", - "integrity": "sha512-mrGpVd0fs7WWLfVsStvgF6iEJnbjDFZh9/emhRDcGWTduTfNHd9CHeUwH3gYIjdbwo4On6hunkztwOaAw0yllQ==", - "dev": true, - "requires": { - "unc-path-regex": "^0.1.2" - } - }, - "is-weakref": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.0.1.tgz", - "integrity": "sha512-b2jKc2pQZjaeFYWEf7ScFj+Be1I+PXmlu572Q8coTXZ+LD/QQZ7ShPMst8h16riVgyXTQwUsFEl74mDvc/3MHQ==", - "dev": true, - "requires": { - "call-bind": "^1.0.0" - } - }, - "is-windows": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-windows/-/is-windows-1.0.2.tgz", - "integrity": "sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==", - "dev": true - }, - "is-wsl": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", - "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", - "dev": true, - "requires": { - "is-docker": "^2.0.0" - } - }, - "isarray": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", - "integrity": "sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8=", - "dev": true - }, - "isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=", - "dev": true - }, - "iso-639-1": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/iso-639-1/-/iso-639-1-2.1.9.tgz", - "integrity": "sha512-owRu9up+Cpx/hwSzm83j6G8PtC7U99UCtPVItsafefNfEgMl+pi8KBwhXwJkJfp6IouyYWFxj8n24SvCWpKZEQ==", - "dev": true - }, - "isstream": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", - "integrity": "sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo=", - "dev": true - }, - "jmespath": { - "version": "0.15.0", - "resolved": "https://registry.npmjs.org/jmespath/-/jmespath-0.15.0.tgz", - "integrity": "sha1-o/Iiqarp+Wb10nx5ZRDigJF2Qhc=", - "dev": true - }, - "js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dev": true, - "requires": { - "argparse": "^2.0.1" - } - }, - "jsbi": { - "version": "3.2.5", - "resolved": "https://registry.npmjs.org/jsbi/-/jsbi-3.2.5.tgz", - "integrity": "sha512-aBE4n43IPvjaddScbvWRA2YlTzKEynHzu7MqOyTipdHucf/VxS63ViCjxYRg86M8Rxwbt/GfzHl1kKERkt45fQ==", - "dev": true - }, - "jsbn": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz", - "integrity": "sha1-peZUwuWi3rXyAdls77yoDA7y9RM=", - "dev": true - }, - "json-diff": { - "version": "0.5.4", - "resolved": "https://registry.npmjs.org/json-diff/-/json-diff-0.5.4.tgz", - "integrity": "sha512-q5Xmx9QXNOzOzIlMoYtLrLiu4Jl/Ce2bn0CNcv54PhyH89CI4GWlGVDye8ei2Ijt9R3U+vsWPsXpLUNob8bs8Q==", - "dev": true, - "requires": { - "cli-color": "~0.1.6", - "difflib": "~0.2.1", - "dreamopt": "~0.6.0" - } - }, - "json-parse-better-errors": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz", - "integrity": "sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==", - "dev": true - }, - "json-schema": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.2.3.tgz", - "integrity": "sha1-tIDIkuWaLwWVTOcnvT8qTogvnhM=", - "dev": true - }, - "json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true - }, - "json-stable-stringify-without-jsonify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha1-nbe1lJatPzz+8wp1FC0tkwrXJlE=", - "dev": true - }, - "json-stringify-safe": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", - "integrity": "sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus=", - "dev": true - }, - "jsonfile": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", - "integrity": "sha1-h3Gq4HmbZAdrdmQPygWPnBDjPss=", - "dev": true, - "requires": { - "graceful-fs": "^4.1.6" - } - }, - "jsonwebtoken": { - "version": "8.5.1", - "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-8.5.1.tgz", - "integrity": "sha512-XjwVfRS6jTMsqYs0EsuJ4LGxXV14zQybNd4L2r0UvbVnSF9Af8x7p5MzbJ90Ioz/9TI41/hTCvznF/loiSzn8w==", - "dev": true, - "requires": { - "jws": "^3.2.2", - "lodash.includes": "^4.3.0", - "lodash.isboolean": "^3.0.3", - "lodash.isinteger": "^4.0.4", - "lodash.isnumber": "^3.0.3", - "lodash.isplainobject": "^4.0.6", - "lodash.isstring": "^4.0.1", - "lodash.once": "^4.0.0", - "ms": "^2.1.1", - "semver": "^5.6.0" - }, - "dependencies": { - "semver": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", - "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", - "dev": true - } - } - }, - "jsprim": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.1.tgz", - "integrity": "sha1-MT5mvB5cwG5Di8G3SZwuXFastqI=", - "dev": true, - "requires": { - "assert-plus": "1.0.0", - "extsprintf": "1.3.0", - "json-schema": "0.2.3", - "verror": "1.10.0" - } - }, - "jwa": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz", - "integrity": "sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==", - "dev": true, - "requires": { - "buffer-equal-constant-time": "1.0.1", - "ecdsa-sig-formatter": "1.0.11", - "safe-buffer": "^5.0.1" - } - }, - "jwks-rsa": { - "version": "1.12.3", - "resolved": "https://registry.npmjs.org/jwks-rsa/-/jwks-rsa-1.12.3.tgz", - "integrity": "sha512-cFipFDeYYaO9FhhYJcZWX/IyZgc0+g316rcHnDpT2dNRNIE/lMOmWKKqp09TkJoYlNFzrEVODsR4GgXJMgWhnA==", - "dev": true, - "requires": { - "@types/express-jwt": "0.0.42", - "axios": "^0.21.1", - "debug": "^4.1.0", - "http-proxy-agent": "^4.0.1", - "https-proxy-agent": "^5.0.0", - "jsonwebtoken": "^8.5.1", - "limiter": "^1.1.5", - "lru-memoizer": "^2.1.2", - "ms": "^2.1.2", - "proxy-from-env": "^1.1.0" - } - }, - "jws": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", - "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", - "dev": true, - "requires": { - "jwa": "^1.4.1", - "safe-buffer": "^5.0.1" - } - }, - "kafkajs": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/kafkajs/-/kafkajs-1.15.0.tgz", - "integrity": "sha512-yjPyEnQCkPxAuQLIJnY5dI+xnmmgXmhuOQ1GVxClG5KTOV/rJcW1qA3UfvyEJKTp/RTSqQnUR3HJsKFvHyTpNg==", - "dev": true - }, - "kuler": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/kuler/-/kuler-2.0.0.tgz", - "integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==", - "dev": true - }, - "leven": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/leven/-/leven-2.1.0.tgz", - "integrity": "sha1-wuep93IJTe6dNCAq6KzORoeHVYA=", - "dev": true - }, - "libbase64": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/libbase64/-/libbase64-1.2.1.tgz", - "integrity": "sha512-l+nePcPbIG1fNlqMzrh68MLkX/gTxk/+vdvAb388Ssi7UuUN31MI44w4Yf33mM3Cm4xDfw48mdf3rkdHszLNew==", - "dev": true - }, - "libmime": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/libmime/-/libmime-5.0.0.tgz", - "integrity": "sha512-2Bm96d5ktnE217Ib1FldvUaPAaOst6GtZrsxJCwnJgi9lnsoAKIHyU0sae8rNx6DNYbjdqqh8lv5/b9poD8qOg==", - "dev": true, - "requires": { - "encoding-japanese": "1.0.30", - "iconv-lite": "0.6.2", - "libbase64": "1.2.1", - "libqp": "1.1.0" - }, - "dependencies": { - "iconv-lite": { - "version": "0.6.2", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.2.tgz", - "integrity": "sha512-2y91h5OpQlolefMPmUlivelittSWy0rP+oYVpn6A7GwVHNE8AWzoYOBNmlwks3LobaJxgHCYZAnyNo2GgpNRNQ==", - "dev": true, - "requires": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - } - } - } - }, - "libphonenumber-js": { - "version": "1.9.37", - "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.9.37.tgz", - "integrity": "sha512-RnUR4XwiVhMLnT7uFSdnmLeprspquuDtaShAgKTA+g/ms9/S4hQU3/QpFdh3iXPHtxD52QscXLm2W2+QBmvYAg==", - "dev": true - }, - "libqp": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/libqp/-/libqp-1.1.0.tgz", - "integrity": "sha1-9ebgatdLeU+1tbZpiL9yjvHe2+g=", - "dev": true - }, - "limiter": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/limiter/-/limiter-1.1.5.tgz", - "integrity": "sha512-FWWMIEOxz3GwUI4Ts/IvgVy6LPvoMPgjMdQ185nN6psJyBJ4yOpzqm695/h5umdLJg2vW3GR5iG11MAkR2AzJA==", - "dev": true - }, - "linkify-it": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-3.0.3.tgz", - "integrity": "sha512-ynTsyrFSdE5oZ/O9GEf00kPngmOfVwazR5GKDq6EYfhlpFug3J2zybX56a2PRRpc9P+FuSoGNAwjlbDs9jJBPQ==", - "dev": true, - "requires": { - "uc.micro": "^1.0.1" - } - }, - "load-json-file": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-4.0.0.tgz", - "integrity": "sha1-L19Fq5HjMhYjT9U62rZo607AmTs=", - "dev": true, - "requires": { - "graceful-fs": "^4.1.2", - "parse-json": "^4.0.0", - "pify": "^3.0.0", - "strip-bom": "^3.0.0" - } - }, - "localtunnel": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/localtunnel/-/localtunnel-2.0.2.tgz", - "integrity": "sha512-n418Cn5ynvJd7m/N1d9WVJISLJF/ellZnfsLnx8WBWGzxv/ntNcFkJ1o6se5quUhCplfLGBNL5tYHiq5WF3Nug==", - "dev": true, - "requires": { - "axios": "0.21.4", - "debug": "4.3.2", - "openurl": "1.1.1", - "yargs": "17.1.1" - } - }, - "lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "dev": true - }, - "lodash-es": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", - "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==", - "dev": true - }, - "lodash._reinterpolate": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/lodash._reinterpolate/-/lodash._reinterpolate-3.0.0.tgz", - "integrity": "sha1-DM8tiRZq8Ds2Y8eWU4t1rG4RTZ0=", - "dev": true - }, - "lodash.clonedeep": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", - "integrity": "sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8=", - "dev": true - }, - "lodash.defaults": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", - "integrity": "sha1-0JF4cW/+pN3p5ft7N/bwgCJ0WAw=", - "dev": true - }, - "lodash.flatten": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz", - "integrity": "sha1-8xwiIlqWMtK7+OSt2+8kCqdlph8=", - "dev": true - }, - "lodash.get": { - "version": "4.4.2", - "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", - "integrity": "sha1-LRd/ZS+jHpObRDjVNBSZ36OCXpk=", - "dev": true - }, - "lodash.includes": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", - "integrity": "sha1-YLuYqHy5I8aMoeUTJUgzFISfVT8=", - "dev": true - }, - "lodash.isarguments": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", - "integrity": "sha1-L1c9hcaiQon/AGY7SRwdM4/zRYo=", - "dev": true - }, - "lodash.isboolean": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", - "integrity": "sha1-bC4XHbKiV82WgC/UOwGyDV9YcPY=", - "dev": true - }, - "lodash.isequal": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", - "integrity": "sha1-QVxEePK8wwEgwizhDtMib30+GOA=", - "dev": true - }, - "lodash.isinteger": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", - "integrity": "sha1-YZwK89A/iwTDH1iChAt3sRzWg0M=", - "dev": true - }, - "lodash.isnumber": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", - "integrity": "sha1-POdoEMWSjQM1IwGsKHMX8RwLH/w=", - "dev": true - }, - "lodash.isplainobject": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", - "integrity": "sha1-fFJqUtibRcRcxpC4gWO+BJf1UMs=", - "dev": true - }, - "lodash.isstring": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", - "integrity": "sha1-1SfftUVuynzJu5XV2ur4i6VKVFE=", - "dev": true - }, - "lodash.once": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", - "integrity": "sha1-DdOXEhPHxW34gJd9UEyI+0cal6w=", - "dev": true - }, - "lodash.set": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/lodash.set/-/lodash.set-4.3.2.tgz", - "integrity": "sha1-2HV7HagH3eJIFrDWqEvqGnYjCyM=", - "dev": true - }, - "lodash.template": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/lodash.template/-/lodash.template-4.5.0.tgz", - "integrity": "sha512-84vYFxIkmidUiFxidA/KjjH9pAycqW+h980j7Fuz5qxRtO9pgB7MDFTdys1N7A5mcucRiDyEq4fusljItR1T/A==", - "dev": true, - "requires": { - "lodash._reinterpolate": "^3.0.0", - "lodash.templatesettings": "^4.0.0" - } - }, - "lodash.templatesettings": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/lodash.templatesettings/-/lodash.templatesettings-4.2.0.tgz", - "integrity": "sha512-stgLz+i3Aa9mZgnjr/O+v9ruKZsPsndy7qPZOchbqk2cnTU1ZaldKK+v7m54WoKIyxiuMZTKT2H81F8BeAc3ZQ==", - "dev": true, - "requires": { - "lodash._reinterpolate": "^3.0.0" - } - }, - "lodash.unset": { - "version": "4.5.2", - "resolved": "https://registry.npmjs.org/lodash.unset/-/lodash.unset-4.5.2.tgz", - "integrity": "sha1-Nw0dPoW3Kn4bDN8tJyEhMG8j5O0=", - "dev": true - }, - "logform": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/logform/-/logform-2.3.0.tgz", - "integrity": "sha512-graeoWUH2knKbGthMtuG1EfaSPMZFZBIrhuJHhkS5ZseFBrc7DupCzihOQAzsK/qIKPQaPJ/lFQFctILUY5ARQ==", - "dev": true, - "requires": { - "colors": "^1.2.1", - "fecha": "^4.2.0", - "ms": "^2.1.1", - "safe-stable-stringify": "^1.1.0", - "triple-beam": "^1.3.0" - } - }, - "long": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz", - "integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==", - "dev": true - }, - "lossless-json": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/lossless-json/-/lossless-json-1.0.5.tgz", - "integrity": "sha512-RicKUuLwZVNZ6ZdJHgIZnSeA05p8qWc5NW0uR96mpPIjN9WDLUg9+kj1esQU1GkPn9iLZVKatSQK5gyiaFHgJA==", - "dev": true - }, - "lower-case": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz", - "integrity": "sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==", - "dev": true, - "requires": { - "tslib": "^2.0.3" - }, - "dependencies": { - "tslib": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz", - "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw==", - "dev": true - } - } - }, - "lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, - "requires": { - "yallist": "^4.0.0" - } - }, - "lru-memoizer": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/lru-memoizer/-/lru-memoizer-2.1.4.tgz", - "integrity": "sha512-IXAq50s4qwrOBrXJklY+KhgZF+5y98PDaNo0gi/v2KQBFLyWr+JyFvijZXkGKjQj/h9c0OwoE+JZbwUXce76hQ==", - "dev": true, - "requires": { - "lodash.clonedeep": "^4.5.0", - "lru-cache": "~4.0.0" - }, - "dependencies": { - "lru-cache": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-4.0.2.tgz", - "integrity": "sha1-HRdnnAac2l0ECZGgnbwsDbN35V4=", - "dev": true, - "requires": { - "pseudomap": "^1.0.1", - "yallist": "^2.0.0" - } - }, - "yallist": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-2.1.2.tgz", - "integrity": "sha1-HBH5IY8HYImkfdUS+TxmmaaoHVI=", - "dev": true - } - } - }, - "mailparser": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/mailparser/-/mailparser-3.4.0.tgz", - "integrity": "sha512-u2pfpLg+xr7m2FKDl+ohQhy2gMok1QZ+S9E5umS9ez5DSJWttrqSmBGswyj9F68pZMVTwbhLpBt7Kd04q/W4Vw==", - "dev": true, - "requires": { - "encoding-japanese": "1.0.30", - "he": "1.2.0", - "html-to-text": "8.0.0", - "iconv-lite": "0.6.3", - "libmime": "5.0.0", - "linkify-it": "3.0.3", - "mailsplit": "5.3.1", - "nodemailer": "6.7.0", - "tlds": "1.224.0" - }, - "dependencies": { - "iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "dev": true, - "requires": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - } - } - } - }, - "mailsplit": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/mailsplit/-/mailsplit-5.3.1.tgz", - "integrity": "sha512-o6R6HCzqWYmI2/IYlB+v2IMPgYqC2EynmagZQICAhR7zAq0CO6fPcsO6CrYmVuYT+SSwvLAEZR5WniohBELcAA==", - "dev": true, - "requires": { - "libbase64": "1.2.1", - "libmime": "5.0.0", - "libqp": "1.1.0" - } - }, - "make-dir": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", - "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", - "dev": true, - "requires": { - "semver": "^6.0.0" - }, - "dependencies": { - "semver": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", - "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", - "dev": true - } - } - }, - "make-error": { - "version": "1.3.6", - "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", - "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", - "dev": true - }, - "make-error-cause": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/make-error-cause/-/make-error-cause-2.3.0.tgz", - "integrity": "sha512-etgt+n4LlOkGSJbBTV9VROHA5R7ekIPS4vfh+bCAoJgRrJWdqJCBbpS3osRJ/HrT7R68MzMiY3L3sDJ/Fd8aBg==", - "dev": true, - "requires": { - "make-error": "^1.3.5" - } - }, - "make-fetch-happen": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-9.1.0.tgz", - "integrity": "sha512-+zopwDy7DNknmwPQplem5lAZX/eCOzSvSNNcSKm5eVwTkOBzoktEfXsa9L23J/GIRhxRsaxzkPEhrJEpE2F4Gg==", - "dev": true, - "optional": true, - "requires": { - "agentkeepalive": "^4.1.3", - "cacache": "^15.2.0", - "http-cache-semantics": "^4.1.0", - "http-proxy-agent": "^4.0.1", - "https-proxy-agent": "^5.0.0", - "is-lambda": "^1.0.1", - "lru-cache": "^6.0.0", - "minipass": "^3.1.3", - "minipass-collect": "^1.0.2", - "minipass-fetch": "^1.3.2", - "minipass-flush": "^1.0.5", - "minipass-pipeline": "^1.2.4", - "negotiator": "^0.6.2", - "promise-retry": "^2.0.1", - "socks-proxy-agent": "^6.0.0", - "ssri": "^8.0.0" - }, - "dependencies": { - "minipass": { - "version": "3.1.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.1.6.tgz", - "integrity": "sha512-rty5kpw9/z8SX9dmxblFA6edItUmwJgMeYDZRrwlIVN27i8gysGbznJwUggw2V/FVqFSDdWy040ZPS811DYAqQ==", - "dev": true, - "optional": true, - "requires": { - "yallist": "^4.0.0" - } - } - } - }, - "media-typer": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", - "integrity": "sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g=", - "dev": true - }, - "memory-pager": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz", - "integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==", - "dev": true, - "optional": true - }, - "memorystream": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/memorystream/-/memorystream-0.3.1.tgz", - "integrity": "sha1-htcJCzDORV1j+64S3aUaR93K+bI=", - "dev": true - }, - "merge-descriptors": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", - "integrity": "sha1-sAqqVW3YtEVoFQ7J0blT8/kMu2E=", - "dev": true - }, - "merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "dev": true - }, - "methods": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", - "integrity": "sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4=", - "dev": true - }, - "micromatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.4.tgz", - "integrity": "sha512-pRmzw/XUcwXGpD9aI9q/0XOwLNygjETJ8y0ao0wdqprrzDa4YnxLcz7fQRZr8voh8V10kGhABbNcHVk5wHgWwg==", - "dev": true, - "requires": { - "braces": "^3.0.1", - "picomatch": "^2.2.3" - } - }, - "millisecond": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/millisecond/-/millisecond-0.1.2.tgz", - "integrity": "sha1-bMWtOGJByrjniv+WT4cCjuyS2sU=", - "dev": true - }, - "mime": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", - "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", - "dev": true - }, - "mime-db": { - "version": "1.50.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.50.0.tgz", - "integrity": "sha512-9tMZCDlYHqeERXEHO9f/hKfNXhre5dK2eE/krIvUjZbS2KPcqGDfNShIWS1uW9XOTKQKqK6qbeOci18rbfW77A==", - "dev": true - }, - "mime-types": { - "version": "2.1.33", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.33.tgz", - "integrity": "sha512-plLElXp7pRDd0bNZHw+nMd52vRYjLwQjygaNg7ddJ2uJtTlmnTCjWuPKxVu6//AdaRuME84SvLW91sIkBqGT0g==", - "dev": true, - "requires": { - "mime-db": "1.50.0" - } - }, - "mimic-fn": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", - "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", - "dev": true - }, - "minimalistic-assert": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", - "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", - "dev": true - }, - "minimatch": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", - "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", - "dev": true, - "requires": { - "brace-expansion": "^1.1.7" - } - }, - "minimist": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.6.tgz", - "integrity": "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==", - "dev": true - }, - "minipass-collect": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-1.0.2.tgz", - "integrity": "sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA==", - "dev": true, - "optional": true, - "requires": { - "minipass": "^3.0.0" - }, - "dependencies": { - "minipass": { - "version": "3.1.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.1.6.tgz", - "integrity": "sha512-rty5kpw9/z8SX9dmxblFA6edItUmwJgMeYDZRrwlIVN27i8gysGbznJwUggw2V/FVqFSDdWy040ZPS811DYAqQ==", - "dev": true, - "optional": true, - "requires": { - "yallist": "^4.0.0" - } - } - } - }, - "minipass-fetch": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-1.4.1.tgz", - "integrity": "sha512-CGH1eblLq26Y15+Azk7ey4xh0J/XfJfrCox5LDJiKqI2Q2iwOLOKrlmIaODiSQS8d18jalF6y2K2ePUm0CmShw==", - "dev": true, - "optional": true, - "requires": { - "encoding": "^0.1.12", - "minipass": "^3.1.0", - "minipass-sized": "^1.0.3", - "minizlib": "^2.0.0" - }, - "dependencies": { - "minipass": { - "version": "3.1.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.1.6.tgz", - "integrity": "sha512-rty5kpw9/z8SX9dmxblFA6edItUmwJgMeYDZRrwlIVN27i8gysGbznJwUggw2V/FVqFSDdWy040ZPS811DYAqQ==", - "dev": true, - "optional": true, - "requires": { - "yallist": "^4.0.0" - } - }, - "minizlib": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", - "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", - "dev": true, - "optional": true, - "requires": { - "minipass": "^3.0.0", - "yallist": "^4.0.0" - } - } - } - }, - "minipass-flush": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.5.tgz", - "integrity": "sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw==", - "dev": true, - "optional": true, - "requires": { - "minipass": "^3.0.0" - }, - "dependencies": { - "minipass": { - "version": "3.1.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.1.6.tgz", - "integrity": "sha512-rty5kpw9/z8SX9dmxblFA6edItUmwJgMeYDZRrwlIVN27i8gysGbznJwUggw2V/FVqFSDdWy040ZPS811DYAqQ==", - "dev": true, - "optional": true, - "requires": { - "yallist": "^4.0.0" - } - } - } - }, - "minipass-pipeline": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz", - "integrity": "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==", - "dev": true, - "optional": true, - "requires": { - "minipass": "^3.0.0" - }, - "dependencies": { - "minipass": { - "version": "3.1.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.1.6.tgz", - "integrity": "sha512-rty5kpw9/z8SX9dmxblFA6edItUmwJgMeYDZRrwlIVN27i8gysGbznJwUggw2V/FVqFSDdWy040ZPS811DYAqQ==", - "dev": true, - "optional": true, - "requires": { - "yallist": "^4.0.0" - } - } - } - }, - "minipass-sized": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/minipass-sized/-/minipass-sized-1.0.3.tgz", - "integrity": "sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g==", - "dev": true, - "optional": true, - "requires": { - "minipass": "^3.0.0" - }, - "dependencies": { - "minipass": { - "version": "3.1.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.1.6.tgz", - "integrity": "sha512-rty5kpw9/z8SX9dmxblFA6edItUmwJgMeYDZRrwlIVN27i8gysGbznJwUggw2V/FVqFSDdWy040ZPS811DYAqQ==", - "dev": true, - "optional": true, - "requires": { - "yallist": "^4.0.0" - } - } - } - }, - "mkdirp": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", - "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", - "dev": true - }, - "mock-require": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/mock-require/-/mock-require-3.0.3.tgz", - "integrity": "sha512-lLzfLHcyc10MKQnNUCv7dMcoY/2Qxd6wJfbqCcVk3LDb8An4hF6ohk5AztrvgKhJCqj36uyzi/p5se+tvyD+Wg==", - "dev": true, - "requires": { - "get-caller-file": "^1.0.2", - "normalize-path": "^2.1.1" - }, - "dependencies": { - "get-caller-file": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-1.0.3.tgz", - "integrity": "sha512-3t6rVToeoZfYSGd8YoLFR2DJkiQrIiUrGcjvFX2mDw3bn6k2OtwHN0TNCLbBO+w8qTvimhDkv+LSscbJY1vE6w==", - "dev": true - } - } - }, - "moment": { - "version": "2.29.1", - "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.1.tgz", - "integrity": "sha512-kHmoybcPV8Sqy59DwNDY3Jefr64lK/by/da0ViFcuA4DH0vQg5Q6Ze5VimxkfQNSC+Mls/Kx53s7TjP1RhFEDQ==", - "dev": true - }, - "moment-timezone": { - "version": "0.5.33", - "resolved": "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.33.tgz", - "integrity": "sha512-PTc2vcT8K9J5/9rDEPe5czSIKgLoGsH8UNpA4qZTVw0Vd/Uz19geE9abbIOQKaAQFcnQ3v5YEXrbSc5BpshH+w==", - "dev": true, - "requires": { - "moment": ">= 2.9.0" - } - }, - "mongodb": { - "version": "3.7.2", - "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-3.7.2.tgz", - "integrity": "sha512-/Qi0LmOjzIoV66Y2JQkqmIIfFOy7ZKsXnQNlUXPFXChOw3FCdNqVD5zvci9ybm6pkMe/Nw+Rz9I0Zsk2a+05iQ==", - "dev": true, - "requires": { - "bl": "^2.2.1", - "bson": "^1.1.4", - "denque": "^1.4.1", - "optional-require": "^1.1.8", - "safe-buffer": "^5.1.2", - "saslprep": "^1.0.0" - } - }, - "moo": { - "version": "0.5.1", - "resolved": "https://registry.npmjs.org/moo/-/moo-0.5.1.tgz", - "integrity": "sha512-I1mnb5xn4fO80BH9BLcF0yLypy2UKl+Cb01Fu0hJRkJjlCRtxZMWkTdAtDd5ZqCOxtCkhmRwyI57vWT+1iZ67w==", - "dev": true - }, - "mqtt": { - "version": "4.2.6", - "resolved": "https://registry.npmjs.org/mqtt/-/mqtt-4.2.6.tgz", - "integrity": "sha512-GpxVObyOzL0CGPBqo6B04GinN8JLk12NRYAIkYvARd9ZCoJKevvOyCaWK6bdK/kFSDj3LPDnCsJbezzNlsi87Q==", - "dev": true, - "requires": { - "commist": "^1.0.0", - "concat-stream": "^2.0.0", - "debug": "^4.1.1", - "help-me": "^1.0.1", - "inherits": "^2.0.3", - "minimist": "^1.2.5", - "mqtt-packet": "^6.6.0", - "pump": "^3.0.0", - "readable-stream": "^3.6.0", - "reinterval": "^1.1.0", - "split2": "^3.1.0", - "ws": "^7.3.1", - "xtend": "^4.0.2" - }, - "dependencies": { - "readable-stream": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz", - "integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==", - "dev": true, - "requires": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - } - }, - "safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "dev": true - }, - "string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "dev": true, - "requires": { - "safe-buffer": "~5.2.0" - } - } - } - }, - "mqtt-packet": { - "version": "6.10.0", - "resolved": "https://registry.npmjs.org/mqtt-packet/-/mqtt-packet-6.10.0.tgz", - "integrity": "sha512-ja8+mFKIHdB1Tpl6vac+sktqy3gA8t9Mduom1BA75cI+R9AHnZOiaBQwpGiWnaVJLDGRdNhQmFaAqd7tkKSMGA==", - "dev": true, - "requires": { - "bl": "^4.0.2", - "debug": "^4.1.1", - "process-nextick-args": "^2.0.1" - }, - "dependencies": { - "bl": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", - "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", - "dev": true, - "requires": { - "buffer": "^5.5.0", - "inherits": "^2.0.4", - "readable-stream": "^3.4.0" - } - }, - "readable-stream": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz", - "integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==", - "dev": true, - "requires": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - } - }, - "safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "dev": true - }, - "string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "dev": true, - "requires": { - "safe-buffer": "~5.2.0" - } - } - } - }, - "ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", - "dev": true - }, - "mssql": { - "version": "6.3.2", - "resolved": "https://registry.npmjs.org/mssql/-/mssql-6.3.2.tgz", - "integrity": "sha512-84++SB86VlVm5VrUU3g1fOOjU29mqnF6In2Gz+sJCxnuG5c3J9ItaladE1NayO+D8ZuKtxbkGATVYZv2MsXMIA==", - "dev": true, - "requires": { - "debug": "^4.3.1", - "tarn": "^1.1.5", - "tedious": "^6.7.0" - } - }, - "mute-stream": { - "version": "0.0.8", - "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz", - "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==", - "dev": true - }, - "mysql2": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-2.3.1.tgz", - "integrity": "sha512-v9xTgrBHF+PCOrwVDfqWvO7BfYUVN7+yheR2kxgBeV9ChnMOqLuFxXZKVJsgPbwJE1cCh4dDx8pgnF+Iv9a7sQ==", - "dev": true, - "requires": { - "denque": "^2.0.1", - "generate-function": "^2.3.1", - "iconv-lite": "^0.6.3", - "long": "^4.0.0", - "lru-cache": "^6.0.0", - "named-placeholders": "^1.1.2", - "seq-queue": "^0.0.5", - "sqlstring": "^2.3.2" - }, - "dependencies": { - "denque": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/denque/-/denque-2.0.1.tgz", - "integrity": "sha512-tfiWc6BQLXNLpNiR5iGd0Ocu3P3VpxfzFiqubLgMfhfOw9WyvgJBd46CClNn9k3qfbjvT//0cf7AlYRX/OslMQ==", - "dev": true - }, - "iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "dev": true, - "requires": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - } - } - } - }, - "mz": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", - "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", - "dev": true, - "requires": { - "any-promise": "^1.0.0", - "object-assign": "^4.0.1", - "thenify-all": "^1.0.0" - } - }, - "n8n": { - "version": "0.144.0", - "resolved": "https://registry.npmjs.org/n8n/-/n8n-0.144.0.tgz", - "integrity": "sha512-iu7r0/WXuleFATZnvta4ZhSTWmfJ2Ss2GqYZUhGdRwC4ZC1gzESJWO4QaEWzLCgxWlqQpGmI34LhAXqVMc5wXg==", - "dev": true, - "requires": { - "@oclif/command": "^1.5.18", - "@oclif/errors": "^1.2.2", - "@types/json-diff": "^0.5.1", - "@types/jsonwebtoken": "^8.5.2", - "basic-auth": "^2.0.1", - "bcryptjs": "^2.4.3", - "body-parser": "^1.18.3", - "body-parser-xml": "^2.0.3", - "bull": "^3.19.0", - "callsites": "^3.1.0", - "class-validator": "^0.13.1", - "client-oauth2": "^4.2.5", - "compression": "^1.7.4", - "connect-history-api-fallback": "^1.6.0", - "convict": "^6.0.1", - "csrf": "^3.1.0", - "dotenv": "^8.0.0", - "express": "^4.16.4", - "fast-glob": "^3.2.5", - "flatted": "^2.0.0", - "google-timezones-json": "^1.0.2", - "inquirer": "^7.0.1", - "json-diff": "^0.5.4", - "jsonwebtoken": "^8.5.1", - "jwks-rsa": "~1.12.1", - "localtunnel": "^2.0.0", - "lodash.get": "^4.4.2", - "mysql2": "~2.3.0", - "n8n-core": "~0.89.0", - "n8n-editor-ui": "~0.112.0", - "n8n-nodes-base": "~0.141.0", - "n8n-workflow": "~0.72.0", - "oauth-1.0a": "^2.2.6", - "open": "^7.0.0", - "pg": "^8.3.0", - "prom-client": "^13.1.0", - "request-promise-native": "^1.0.7", - "sqlite3": "^5.0.1", - "sse-channel": "^3.1.1", - "tslib": "1.14.1", - "typeorm": "^0.2.30", - "winston": "^3.3.3" - } - }, - "n8n-core": { - "version": "0.89.0", - "resolved": "https://registry.npmjs.org/n8n-core/-/n8n-core-0.89.0.tgz", - "integrity": "sha512-CvcSvy8aRoXk8tdRxWrP775MCMoy1LlCspur9ouuex5zUPfdkUS0nMU7rEKbAANG6rZZJ3/SgJ9IGx2O9uBLnQ==", - "dev": true, - "requires": { - "axios": "^0.21.1", - "client-oauth2": "^4.2.5", - "cron": "~1.7.2", - "crypto-js": "~4.1.1", - "file-type": "^14.6.2", - "form-data": "^4.0.0", - "lodash.get": "^4.4.2", - "mime-types": "^2.1.27", - "n8n-workflow": "~0.72.0", - "oauth-1.0a": "^2.2.6", - "p-cancelable": "^2.0.0", - "qs": "^6.10.1", - "request": "^2.88.2", - "request-promise-native": "^1.0.7" - }, - "dependencies": { - "qs": { - "version": "6.10.1", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.10.1.tgz", - "integrity": "sha512-M528Hph6wsSVOBiYUnGf+K/7w0hNshs/duGsNXPUCLH5XAqjEtiPGwNONLV0tBH8NoGb0mvD5JubnUTrujKDTg==", - "dev": true, - "requires": { - "side-channel": "^1.0.4" - } - } - } - }, - "n8n-design-system": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/n8n-design-system/-/n8n-design-system-0.4.0.tgz", - "integrity": "sha512-l1ZpNqKnk8q4IW1JsmjbcrvVX6IZDLWbWZkZH4A8LU6T7IZuUGfPyW1b85itXuH0rP58Rj/ttKt0mxr90s9fdg==", - "dev": true - }, - "n8n-editor-ui": { - "version": "0.112.0", - "resolved": "https://registry.npmjs.org/n8n-editor-ui/-/n8n-editor-ui-0.112.0.tgz", - "integrity": "sha512-XeizKZJIMYBrEewkM0+YMHTmwu6qjZmqjoJg1qx/+FIOZkAekKKTQu6SH/iKmV+lGMF6Vd9VtZE+wAVRbJ3+6Q==", - "dev": true, - "requires": { - "@fontsource/open-sans": "^4.5.0", - "n8n-design-system": "~0.4.0", - "timeago.js": "^4.0.2", - "v-click-outside": "^3.1.2", - "vue-fragment": "^1.5.2" - } - }, - "n8n-nodes-base": { - "version": "0.141.0", - "resolved": "https://registry.npmjs.org/n8n-nodes-base/-/n8n-nodes-base-0.141.0.tgz", - "integrity": "sha512-AdKd656teyedbytwL40Gaga3ojdGhcsxYu9aejRWNbfqrxQlYHIAS9RjF8mJJ9De5W7tKishqixIfnDw/hY48w==", - "dev": true, - "requires": { - "@types/lossless-json": "^1.0.0", - "@types/promise-ftp": "^1.3.4", - "@types/snowflake-sdk": "^1.5.1", - "amqplib": "^0.8.0", - "aws4": "^1.8.0", - "basic-auth": "^2.0.1", - "change-case": "^4.1.1", - "cheerio": "1.0.0-rc.6", - "cron": "~1.7.2", - "eventsource": "^1.0.7", - "fast-glob": "^3.2.5", - "fflate": "^0.7.0", - "formidable": "^1.2.1", - "get-system-fonts": "^2.0.2", - "gm": "^1.23.1", - "iconv-lite": "^0.6.2", - "ics": "^2.27.0", - "imap-simple": "^4.3.0", - "iso-639-1": "^2.1.3", - "jsonwebtoken": "^8.5.1", - "kafkajs": "^1.14.0", - "lodash.get": "^4.4.2", - "lodash.set": "^4.3.2", - "lodash.unset": "^4.5.2", - "lossless-json": "^1.0.4", - "mailparser": "^3.2.0", - "moment": "2.29.1", - "moment-timezone": "^0.5.28", - "mongodb": "^3.6.9", - "mqtt": "4.2.6", - "mssql": "^6.2.0", - "mysql2": "~2.3.0", - "n8n-core": "~0.89.0", - "node-ssh": "^12.0.0", - "nodemailer": "^6.5.0", - "pdf-parse": "^1.1.1", - "pg": "^8.3.0", - "pg-promise": "^10.5.8", - "promise-ftp": "^1.3.5", - "redis": "^3.1.1", - "request": "^2.88.2", - "rhea": "^1.0.11", - "rss-parser": "^3.7.0", - "simple-git": "^2.36.2", - "snowflake-sdk": "^1.5.3", - "ssh2-sftp-client": "^7.0.0", - "tmp-promise": "^3.0.2", - "uuid": "^8.3.0", - "vm2": "3.9.3", - "xlsx": "^0.17.0", - "xml2js": "^0.4.23" - }, - "dependencies": { - "iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "dev": true, - "requires": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - } - }, - "vm2": { - "version": "3.9.3", - "resolved": "https://registry.npmjs.org/vm2/-/vm2-3.9.3.tgz", - "integrity": "sha512-smLS+18RjXYMl9joyJxMNI9l4w7biW8ilSDaVRvFBDwOH8P0BK1ognFQTpg0wyQ6wIKLTblHJvROW692L/E53Q==", - "dev": true - } - } - }, - "n8n-workflow": { - "version": "0.72.0", - "resolved": "https://registry.npmjs.org/n8n-workflow/-/n8n-workflow-0.72.0.tgz", - "integrity": "sha512-JuaDbwpWe0MxKFTfW1Otc3xYV4H9IbSbkqSwKT9DuJnkcNpDzgjKfBGg6YGkrAP1J5Ovbi1RR2y1WmAKsWyNzQ==", - "dev": true, - "requires": { - "lodash.get": "^4.4.2", - "lodash.isequal": "^4.5.0", - "riot-tmpl": "^3.0.8", - "xml2js": "^0.4.23" - } - }, - "named-placeholders": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.2.tgz", - "integrity": "sha512-wiFWqxoLL3PGVReSZpjLVxyJ1bRqe+KKJVbr4hGs1KWfTZTQyezHFBbuKj9hsizHyGV2ne7EMjHdxEGAybD5SA==", - "dev": true, - "requires": { - "lru-cache": "^4.1.3" - }, - "dependencies": { - "lru-cache": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-4.1.5.tgz", - "integrity": "sha512-sWZlbEP2OsHNkXrMl5GYk/jKk70MBng6UU4YI/qGDYbgf6YbP4EvmqISbXCoJiRKs+1bSpFHVgQxvJ17F2li5g==", - "dev": true, - "requires": { - "pseudomap": "^1.0.2", - "yallist": "^2.1.2" - } - }, - "yallist": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-2.1.2.tgz", - "integrity": "sha1-HBH5IY8HYImkfdUS+TxmmaaoHVI=", - "dev": true - } - } - }, - "nan": { - "version": "2.15.0", - "resolved": "https://registry.npmjs.org/nan/-/nan-2.15.0.tgz", - "integrity": "sha512-8ZtvEnA2c5aYCZYd1cvgdnU6cqwixRoYg70xPLWUws5ORTa/lnw+u4amixRS/Ac5U5mQVgp9pnlSUnbNWFaWZQ==", - "dev": true, - "optional": true - }, - "nanoclone": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/nanoclone/-/nanoclone-0.2.1.tgz", - "integrity": "sha512-wynEP02LmIbLpcYw8uBKpcfF6dmg2vcpKqxeH5UcoKEYdExslsdUA4ugFauuaeYdTB76ez6gJW8XAZ6CgkXYxA==", - "dev": true - }, - "nanoid": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.2.0.tgz", - "integrity": "sha512-fmsZYa9lpn69Ad5eDn7FMcnnSR+8R34W9qJEijxYhTbfOWzr22n1QxCMzXLK+ODyW2973V3Fux959iQoUxzUIA==", - "dev": true - }, - "native-duplexpair": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/native-duplexpair/-/native-duplexpair-1.0.0.tgz", - "integrity": "sha1-eJkHjmS/PIo9cyYBs9QP8F21j6A=", - "dev": true - }, - "nearley": { - "version": "2.20.1", - "resolved": "https://registry.npmjs.org/nearley/-/nearley-2.20.1.tgz", - "integrity": "sha512-+Mc8UaAebFzgV+KpI5n7DasuuQCHA89dmwm7JXw3TV43ukfNQ9DnBH3Mdb2g/I4Fdxc26pwimBWvjIw0UAILSQ==", - "dev": true, - "requires": { - "commander": "^2.19.0", - "moo": "^0.5.0", - "railroad-diagrams": "^1.0.0", - "randexp": "0.4.6" - } - }, - "negotiator": { - "version": "0.6.2", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.2.tgz", - "integrity": "sha512-hZXc7K2e+PgeI1eDBe/10Ard4ekbfrrqG8Ep+8Jmf4JID2bNg7NvCPOZN+kfF574pFQI7mum2AUqDidoKqcTOw==", - "dev": true - }, - "nice-try": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz", - "integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==", - "dev": true - }, - "no-case": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz", - "integrity": "sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==", - "dev": true, - "requires": { - "lower-case": "^2.0.2", - "tslib": "^2.0.3" - }, - "dependencies": { - "tslib": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz", - "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw==", - "dev": true - } - } - }, - "node-ensure": { - "version": "0.0.0", - "resolved": "https://registry.npmjs.org/node-ensure/-/node-ensure-0.0.0.tgz", - "integrity": "sha1-7K52QVDemYYexcgQ/V0Jaxg5Mqc=", - "dev": true - }, - "node-fetch": { - "version": "2.6.7", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz", - "integrity": "sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==", - "dev": true, - "requires": { - "whatwg-url": "^5.0.0" - } - }, - "node-ssh": { - "version": "12.0.0", - "resolved": "https://registry.npmjs.org/node-ssh/-/node-ssh-12.0.0.tgz", - "integrity": "sha512-kIE4pePn1ZIkce9l4Jdz+nUGkQW08Kp/6cMDr61tnsEipWmTZJxGxpXYFl5uFYRBjswWVkRA+yu8tqvKFqIA/Q==", - "dev": true, - "requires": { - "is-stream": "^2.0.0", - "make-dir": "^3.1.0", - "sb-promise-queue": "^2.1.0", - "sb-scandir": "^3.1.0", - "shell-escape": "^0.2.0", - "ssh2": "^1.1.0" - } - }, - "nodeify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/nodeify/-/nodeify-1.0.1.tgz", - "integrity": "sha1-ZKtpp7268DzhB7TwM1yHwLnpGx0=", - "dev": true, - "requires": { - "is-promise": "~1.0.0", - "promise": "~1.3.0" - } - }, - "nodemailer": { - "version": "6.7.0", - "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.7.0.tgz", - "integrity": "sha512-AtiTVUFHLiiDnMQ43zi0YgkzHOEWUkhDgPlBXrsDzJiJvB29Alo4OKxHQ0ugF3gRqRQIneCLtZU3yiUo7pItZw==", - "dev": true - }, - "normalize-package-data": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz", - "integrity": "sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==", - "dev": true, - "requires": { - "hosted-git-info": "^2.1.4", - "resolve": "^1.10.0", - "semver": "2 || 3 || 4 || 5", - "validate-npm-package-license": "^3.0.1" - }, - "dependencies": { - "semver": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", - "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", - "dev": true - } - } - }, - "normalize-path": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-2.1.1.tgz", - "integrity": "sha1-GrKLVW4Zg2Oowab35vogE3/mrtk=", - "dev": true, - "requires": { - "remove-trailing-separator": "^1.0.1" - } - }, - "npm-run-all": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/npm-run-all/-/npm-run-all-4.1.5.tgz", - "integrity": "sha512-Oo82gJDAVcaMdi3nuoKFavkIHBRVqQ1qvMb+9LHk/cF4P6B2m8aP04hGf7oL6wZ9BuGwX1onlLhpuoofSyoQDQ==", - "dev": true, - "requires": { - "ansi-styles": "^3.2.1", - "chalk": "^2.4.1", - "cross-spawn": "^6.0.5", - "memorystream": "^0.3.1", - "minimatch": "^3.0.4", - "pidtree": "^0.3.0", - "read-pkg": "^3.0.0", - "shell-quote": "^1.6.1", - "string.prototype.padend": "^3.0.0" - }, - "dependencies": { - "chalk": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", - "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", - "dev": true, - "requires": { - "ansi-styles": "^3.2.1", - "escape-string-regexp": "^1.0.5", - "supports-color": "^5.3.0" - } - }, - "cross-spawn": { - "version": "6.0.5", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz", - "integrity": "sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ==", - "dev": true, - "requires": { - "nice-try": "^1.0.4", - "path-key": "^2.0.1", - "semver": "^5.5.0", - "shebang-command": "^1.2.0", - "which": "^1.2.9" - } - }, - "path-key": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-2.0.1.tgz", - "integrity": "sha1-QRyttXTFoUDTpLGRDUDYDMn0C0A=", - "dev": true - }, - "semver": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", - "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", - "dev": true - }, - "shebang-command": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz", - "integrity": "sha1-RKrGW2lbAzmJaMOfNj/uXer98eo=", - "dev": true, - "requires": { - "shebang-regex": "^1.0.0" - } - }, - "shebang-regex": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-1.0.0.tgz", - "integrity": "sha1-2kL0l0DAtC2yypcoVxyxkMmO/qM=", - "dev": true - }, - "which": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", - "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", - "dev": true, - "requires": { - "isexe": "^2.0.0" - } - } - } - }, - "nth-check": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.0.1.tgz", - "integrity": "sha512-it1vE95zF6dTT9lBsYbxvqh0Soy4SPowchj0UBGj/V6cTPnXXtQOPUbhZ6CmGzAD/rW22LQK6E96pcdJXk4A4w==", - "dev": true, - "requires": { - "boolbase": "^1.0.0" - } - }, - "oauth-1.0a": { - "version": "2.2.6", - "resolved": "https://registry.npmjs.org/oauth-1.0a/-/oauth-1.0a-2.2.6.tgz", - "integrity": "sha512-6bkxv3N4Gu5lty4viIcIAnq5GbxECviMBeKR3WX/q87SPQ8E8aursPZUtsXDnxCs787af09WPRBLqYrf/lwoYQ==", - "dev": true - }, - "oauth-sign": { - "version": "0.9.0", - "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.9.0.tgz", - "integrity": "sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==", - "dev": true - }, - "object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=", - "dev": true - }, - "object-inspect": { - "version": "1.11.0", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.11.0.tgz", - "integrity": "sha512-jp7ikS6Sd3GxQfZJPyH3cjcbJF6GZPClgdV+EFygjFLQ5FmW/dRUnTd9PQ9k0JhoNDabWFbpF1yCdSWCC6gexg==", - "dev": true - }, - "object-keys": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", - "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", - "dev": true - }, - "object.assign": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.2.tgz", - "integrity": "sha512-ixT2L5THXsApyiUPYKmW+2EHpXXe5Ii3M+f4e+aJFAHao5amFRW6J0OO6c/LU8Be47utCx2GL89hxGB6XSmKuQ==", - "dev": true, - "requires": { - "call-bind": "^1.0.0", - "define-properties": "^1.1.3", - "has-symbols": "^1.0.1", - "object-keys": "^1.1.1" - } - }, - "object.getownpropertydescriptors": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/object.getownpropertydescriptors/-/object.getownpropertydescriptors-2.1.3.tgz", - "integrity": "sha512-VdDoCwvJI4QdC6ndjpqFmoL3/+HxffFBbcJzKi5hwLLqqx3mdbedRpfZDdK0SrOSauj8X4GzBvnDZl4vTN7dOw==", - "dev": true, - "requires": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.3", - "es-abstract": "^1.19.1" - }, - "dependencies": { - "es-abstract": { - "version": "1.19.1", - "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.19.1.tgz", - "integrity": "sha512-2vJ6tjA/UfqLm2MPs7jxVybLoB8i1t1Jd9R3kISld20sIxPcTbLuggQOUxeWeAvIUkduv/CfMjuh4WmiXr2v9w==", - "dev": true, - "requires": { - "call-bind": "^1.0.2", - "es-to-primitive": "^1.2.1", - "function-bind": "^1.1.1", - "get-intrinsic": "^1.1.1", - "get-symbol-description": "^1.0.0", - "has": "^1.0.3", - "has-symbols": "^1.0.2", - "internal-slot": "^1.0.3", - "is-callable": "^1.2.4", - "is-negative-zero": "^2.0.1", - "is-regex": "^1.1.4", - "is-shared-array-buffer": "^1.0.1", - "is-string": "^1.0.7", - "is-weakref": "^1.0.1", - "object-inspect": "^1.11.0", - "object-keys": "^1.1.1", - "object.assign": "^4.1.2", - "string.prototype.trimend": "^1.0.4", - "string.prototype.trimstart": "^1.0.4", - "unbox-primitive": "^1.0.1" - } - } - } - }, - "ocsp": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/ocsp/-/ocsp-1.2.0.tgz", - "integrity": "sha1-RpoXdrRX3uZ+sCAUCMGUa6xAdsw=", - "dev": true, - "requires": { - "asn1.js": "^4.8.0", - "asn1.js-rfc2560": "^4.0.0", - "asn1.js-rfc5280": "^2.0.0", - "async": "^1.5.2", - "simple-lru-cache": "0.0.2" - }, - "dependencies": { - "asn1.js": { - "version": "4.10.1", - "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-4.10.1.tgz", - "integrity": "sha512-p32cOF5q0Zqs9uBiONKYLm6BClCoBCM5O9JfeUSlnQLBTxYdTK+pW+nXflm8UkKd2UYlEbYz5qEi0JuZR9ckSw==", - "dev": true, - "requires": { - "bn.js": "^4.0.0", - "inherits": "^2.0.1", - "minimalistic-assert": "^1.0.0" - } - }, - "asn1.js-rfc2560": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/asn1.js-rfc2560/-/asn1.js-rfc2560-4.0.6.tgz", - "integrity": "sha512-ysf48ni+f/efNPilq4+ApbifUPcSW/xbDeQAh055I+grr2gXgNRQqHew7kkO70WSMQ2tEOURVwsK+dJqUNjIIg==", - "dev": true, - "requires": { - "asn1.js-rfc5280": "^2.0.0" - } - }, - "asn1.js-rfc5280": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/asn1.js-rfc5280/-/asn1.js-rfc5280-2.0.1.tgz", - "integrity": "sha512-1e2ypnvTbYD/GdxWK77tdLBahvo1fZUHlQJqAVUuZWdYj0rdjGcf2CWYUtbsyRYpYUMwMWLZFUtLxog8ZXTrcg==", - "dev": true, - "requires": { - "asn1.js": "^4.5.0" - } - }, - "async": { - "version": "1.5.2", - "resolved": "https://registry.npmjs.org/async/-/async-1.5.2.tgz", - "integrity": "sha1-7GphrlZIDAw8skHJVhjiCJL5Zyo=", - "dev": true - } - } - }, - "on-finished": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", - "integrity": "sha1-IPEzZIGwg811M3mSoWlxqi2QaUc=", - "dev": true, - "requires": { - "ee-first": "1.1.1" - } - }, - "on-headers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.2.tgz", - "integrity": "sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==", - "dev": true - }, - "once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", - "dev": true, - "requires": { - "wrappy": "1" - } - }, - "one-time": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/one-time/-/one-time-1.0.0.tgz", - "integrity": "sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g==", - "dev": true, - "requires": { - "fn.name": "1.x.x" - } - }, - "onetime": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", - "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", - "dev": true, - "requires": { - "mimic-fn": "^2.1.0" - } - }, - "open": { - "version": "7.4.2", - "resolved": "https://registry.npmjs.org/open/-/open-7.4.2.tgz", - "integrity": "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==", - "dev": true, - "requires": { - "is-docker": "^2.0.0", - "is-wsl": "^2.1.1" - } - }, - "openurl": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/openurl/-/openurl-1.1.1.tgz", - "integrity": "sha1-OHW0sO96UsFW8NtB1GCduw+Us4c=", - "dev": true - }, - "optional-require": { - "version": "1.1.8", - "resolved": "https://registry.npmjs.org/optional-require/-/optional-require-1.1.8.tgz", - "integrity": "sha512-jq83qaUb0wNg9Krv1c5OQ+58EK+vHde6aBPzLvPPqJm89UQWsvSuFy9X/OSNJnFeSOKo7btE0n8Nl2+nE+z5nA==", - "dev": true, - "requires": { - "require-at": "^1.0.6" - } - }, - "ordered-read-streams": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/ordered-read-streams/-/ordered-read-streams-1.0.1.tgz", - "integrity": "sha1-d8DLN8QVJdZBZtmQ/61+xqDhNj4=", - "dev": true, - "requires": { - "readable-stream": "^2.0.1" - }, - "dependencies": { - "isarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=", - "dev": true - }, - "readable-stream": { - "version": "2.3.7", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", - "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", - "dev": true, - "requires": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" - } - }, - "string_decoder": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", - "dev": true, - "requires": { - "safe-buffer": "~5.1.0" - } - } - } - }, - "original": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/original/-/original-1.0.2.tgz", - "integrity": "sha512-hyBVl6iqqUOJ8FqRe+l/gS8H+kKYjrEndd5Pm1MfBtsEKA038HkkdbAl/72EAXGyonD/PFsvmVG+EvcIpliMBg==", - "dev": true, - "requires": { - "url-parse": "^1.4.3" - } - }, - "os-tmpdir": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", - "integrity": "sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ=", - "dev": true - }, - "p-cancelable": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz", - "integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==", - "dev": true - }, - "p-finally": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz", - "integrity": "sha1-P7z7FbiZpEEjs0ttzBi3JDNqLK4=", - "dev": true - }, - "p-map": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/p-map/-/p-map-2.1.0.tgz", - "integrity": "sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw==", - "dev": true - }, - "p-timeout": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-3.2.0.tgz", - "integrity": "sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==", - "dev": true, - "requires": { - "p-finally": "^1.0.0" - } - }, - "packet-reader": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/packet-reader/-/packet-reader-1.0.0.tgz", - "integrity": "sha512-HAKu/fG3HpHFO0AA8WE8q2g+gBJaZ9MG7fcKk+IJPLTGAD6Psw4443l+9DGRbOIh3/aXr7Phy0TjilYivJo5XQ==" - }, - "param-case": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/param-case/-/param-case-3.0.4.tgz", - "integrity": "sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A==", - "dev": true, - "requires": { - "dot-case": "^3.0.4", - "tslib": "^2.0.3" - }, - "dependencies": { - "tslib": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz", - "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw==", - "dev": true - } - } - }, - "parent-require": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/parent-require/-/parent-require-1.0.0.tgz", - "integrity": "sha1-dGoWdjgIOoYLDu9nMssn7UbDKXc=", - "dev": true - }, - "parse-json": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-4.0.0.tgz", - "integrity": "sha1-vjX1Qlvh9/bHRxhPmKeIy5lHfuA=", - "dev": true, - "requires": { - "error-ex": "^1.3.1", - "json-parse-better-errors": "^1.0.1" - } - }, - "parse-passwd": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/parse-passwd/-/parse-passwd-1.0.0.tgz", - "integrity": "sha1-bVuTSkVpk7I9N/QKOC1vFmao5cY=", - "dev": true - }, - "parse5": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-6.0.1.tgz", - "integrity": "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==", - "dev": true - }, - "parse5-htmlparser2-tree-adapter": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-6.0.1.tgz", - "integrity": "sha512-qPuWvbLgvDGilKc5BoicRovlT4MtYT6JfJyBOMDsKoiT+GiuP5qyrPCnR9HcPECIJJmZh5jRndyNThnhhb/vlA==", - "dev": true, - "requires": { - "parse5": "^6.0.1" - } - }, - "parseley": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/parseley/-/parseley-0.7.0.tgz", - "integrity": "sha512-xyOytsdDu077M3/46Am+2cGXEKM9U9QclBDv7fimY7e+BBlxh2JcBp2mgNsmkyA9uvgyTjVzDi7cP1v4hcFxbw==", - "dev": true, - "requires": { - "moo": "^0.5.1", - "nearley": "^2.20.1" - } - }, - "parseurl": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", - "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", - "dev": true - }, - "pascal-case": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/pascal-case/-/pascal-case-3.1.2.tgz", - "integrity": "sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g==", - "dev": true, - "requires": { - "no-case": "^3.0.4", - "tslib": "^2.0.3" - }, - "dependencies": { - "tslib": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz", - "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw==", - "dev": true - } - } - }, - "path-case": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/path-case/-/path-case-3.0.4.tgz", - "integrity": "sha512-qO4qCFjXqVTrcbPt/hQfhTQ+VhFsqNKOPtytgNKkKxSoEp3XPUQ8ObFuePylOIok5gjn69ry8XiULxCwot3Wfg==", - "dev": true, - "requires": { - "dot-case": "^3.0.4", - "tslib": "^2.0.3" - }, - "dependencies": { - "tslib": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz", - "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw==", - "dev": true - } - } - }, - "path-dirname": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/path-dirname/-/path-dirname-1.0.2.tgz", - "integrity": "sha1-zDPSTVJeCZpTiMAzbG4yuRYGCeA=", - "dev": true - }, - "path-is-absolute": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=", - "dev": true - }, - "path-parse": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "dev": true - }, - "path-to-regexp": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", - "integrity": "sha1-32BBeABfUi8V60SQ5yR6G/qmf4w=", - "dev": true - }, - "path-type": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", - "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", - "dev": true - }, - "pdf-parse": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/pdf-parse/-/pdf-parse-1.1.1.tgz", - "integrity": "sha512-v6ZJ/efsBpGrGGknjtq9J/oC8tZWq0KWL5vQrk2GlzLEQPUDB1ex+13Rmidl1neNN358Jn9EHZw5y07FFtaC7A==", - "dev": true, - "requires": { - "debug": "^3.1.0", - "node-ensure": "^0.0.0" - }, - "dependencies": { - "debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", - "dev": true, - "requires": { - "ms": "^2.1.1" - } - } - } - }, - "peek-readable": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/peek-readable/-/peek-readable-4.0.1.tgz", - "integrity": "sha512-7qmhptnR0WMSpxT5rMHG9bW/mYSR1uqaPFj2MHvT+y/aOUu6msJijpKt5SkTDKySwg65OWG2JwTMBlgcbwMHrQ==", - "dev": true - }, - "performance-now": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", - "integrity": "sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns=", - "dev": true - }, - "pg": { - "version": "8.3.3", - "resolved": "https://registry.npmjs.org/pg/-/pg-8.3.3.tgz", - "integrity": "sha512-wmUyoQM/Xzmo62wgOdQAn5tl7u+IA1ZYK7qbuppi+3E+Gj4hlUxVHjInulieWrd0SfHi/ADriTb5ILJ/lsJrSg==", - "requires": { - "buffer-writer": "2.0.0", - "packet-reader": "1.0.0", - "pg-connection-string": "^2.3.0", - "pg-pool": "^3.2.1", - "pg-protocol": "^1.2.5", - "pg-types": "^2.1.0", - "pgpass": "1.x", - "semver": "4.3.2" - }, - "dependencies": { - "semver": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-4.3.2.tgz", - "integrity": "sha1-x6BxWKgL7dBSNVt3DYLWZA+AO+c=" - } - } - }, - "pg-connection-string": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.5.0.tgz", - "integrity": "sha512-r5o/V/ORTA6TmUnyWZR9nCj1klXCO2CEKNRlVuJptZe85QuhFayC7WeMic7ndayT5IRIR0S0xFxFi2ousartlQ==" - }, - "pg-int8": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", - "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==" - }, - "pg-minify": { - "version": "1.6.2", - "resolved": "https://registry.npmjs.org/pg-minify/-/pg-minify-1.6.2.tgz", - "integrity": "sha512-1KdmFGGTP6jplJoI8MfvRlfvMiyBivMRP7/ffh4a11RUFJ7kC2J0ZHlipoKiH/1hz+DVgceon9U2qbaHpPeyPg==", - "dev": true - }, - "pg-pool": { - "version": "3.4.1", - "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.4.1.tgz", - "integrity": "sha512-TVHxR/gf3MeJRvchgNHxsYsTCHQ+4wm3VIHSS19z8NC0+gioEhq1okDY1sm/TYbfoP6JLFx01s0ShvZ3puP/iQ==" - }, - "pg-promise": { - "version": "10.11.0", - "resolved": "https://registry.npmjs.org/pg-promise/-/pg-promise-10.11.0.tgz", - "integrity": "sha512-UntgHZNv+gpGJKhh+tzGSGHLkniKWV+ZQ8/SNdtvElsg9Aa7ZJ4Fgyl6pl2x0ZtJ7uFNy+OIq3Z+Ei6iplqTDQ==", - "dev": true, - "requires": { - "assert-options": "0.7.0", - "pg": "8.7.1", - "pg-minify": "1.6.2", - "spex": "3.2.0" - }, - "dependencies": { - "pg": { - "version": "8.7.1", - "resolved": "https://registry.npmjs.org/pg/-/pg-8.7.1.tgz", - "integrity": "sha512-7bdYcv7V6U3KAtWjpQJJBww0UEsWuh4yQ/EjNf2HeO/NnvKjpvhEIe/A/TleP6wtmSKnUnghs5A9jUoK6iDdkA==", - "dev": true, - "requires": { - "buffer-writer": "2.0.0", - "packet-reader": "1.0.0", - "pg-connection-string": "^2.5.0", - "pg-pool": "^3.4.1", - "pg-protocol": "^1.5.0", - "pg-types": "^2.1.0", - "pgpass": "1.x" - } - } - } - }, - "pg-protocol": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.5.0.tgz", - "integrity": "sha512-muRttij7H8TqRNu/DxrAJQITO4Ac7RmX3Klyr/9mJEOBeIpgnF8f9jAfRz5d3XwQZl5qBjF9gLsUtMPJE0vezQ==" - }, - "pg-types": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", - "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", - "requires": { - "pg-int8": "1.0.1", - "postgres-array": "~2.0.0", - "postgres-bytea": "~1.0.0", - "postgres-date": "~1.0.4", - "postgres-interval": "^1.1.0" - } - }, - "pgpass": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.4.tgz", - "integrity": "sha512-YmuA56alyBq7M59vxVBfPJrGSozru8QAdoNlWuW3cz8l+UX3cWge0vTvjKhsSHSJpo3Bom8/Mm6hf0TR5GY0+w==", - "requires": { - "split2": "^3.1.1" - } - }, - "picomatch": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.0.tgz", - "integrity": "sha512-lY1Q/PiJGC2zOv/z391WOTD+Z02bCgsFfvxoXXf6h7kv9o+WmsmzYqrAwY63sNgOxE4xEdq0WyUnXfKeBrSvYw==", - "dev": true - }, - "pidtree": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/pidtree/-/pidtree-0.3.1.tgz", - "integrity": "sha512-qQbW94hLHEqCg7nhby4yRC7G2+jYHY4Rguc2bjw7Uug4GIJuu1tvf2uHaZv5Q8zdt+WKJ6qK1FOI6amaWUo5FA==", - "dev": true - }, - "pify": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", - "integrity": "sha1-5aSs0sEB/fPZpNB/DbxNtJ3SgXY=", - "dev": true - }, - "popsicle": { - "version": "12.1.0", - "resolved": "https://registry.npmjs.org/popsicle/-/popsicle-12.1.0.tgz", - "integrity": "sha512-muNC/cIrWhfR6HqqhHazkxjob3eyECBe8uZYSQ/N5vixNAgssacVleerXnE8Are5fspR0a+d2qWaBR1g7RYlmw==", - "dev": true, - "requires": { - "popsicle-content-encoding": "^1.0.0", - "popsicle-cookie-jar": "^1.0.0", - "popsicle-redirects": "^1.1.0", - "popsicle-transport-http": "^1.0.8", - "popsicle-transport-xhr": "^2.0.0", - "popsicle-user-agent": "^1.0.0", - "servie": "^4.3.3", - "throwback": "^4.1.0" - } - }, - "popsicle-content-encoding": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/popsicle-content-encoding/-/popsicle-content-encoding-1.0.0.tgz", - "integrity": "sha512-4Df+vTfM8wCCJVTzPujiI6eOl3SiWQkcZg0AMrOkD1enMXsF3glIkFUZGvour1Sj7jOWCsNSEhBxpbbhclHhzw==", - "dev": true - }, - "popsicle-cookie-jar": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/popsicle-cookie-jar/-/popsicle-cookie-jar-1.0.0.tgz", - "integrity": "sha512-vrlOGvNVELko0+J8NpGC5lHWDGrk8LQJq9nwAMIVEVBfN1Lib3BLxAaLRGDTuUnvl45j5N9dT2H85PULz6IjjQ==", - "dev": true, - "requires": { - "@types/tough-cookie": "^2.3.5", - "tough-cookie": "^3.0.1" - } - }, - "popsicle-redirects": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/popsicle-redirects/-/popsicle-redirects-1.1.0.tgz", - "integrity": "sha512-XCpzVjVk7tty+IJnSdqWevmOr1n8HNDhL86v7mZ6T1JIIf2KGybxUk9mm7ZFOhWMkGB0e8XkacHip7BV8AQWQA==", - "dev": true - }, - "popsicle-transport-http": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/popsicle-transport-http/-/popsicle-transport-http-1.1.4.tgz", - "integrity": "sha512-HyFa/ZCcObP+H7T5b6d0I6ANBvrMEnjZeglopFhBi1uxEZ95qtX8GZDKU6JSMhf+iiNhxnGPZ/OJ2Q4FnH66LQ==", - "dev": true, - "requires": { - "make-error-cause": "^2.2.0" - } - }, - "popsicle-transport-xhr": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/popsicle-transport-xhr/-/popsicle-transport-xhr-2.0.0.tgz", - "integrity": "sha512-5Sbud4Widngf1dodJE5cjEYXkzEUIl8CzyYRYR57t6vpy9a9KPGQX6KBKdPjmBZlR5A06pOBXuJnVr23l27rtA==", - "dev": true - }, - "popsicle-user-agent": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/popsicle-user-agent/-/popsicle-user-agent-1.0.0.tgz", - "integrity": "sha512-epKaq3TTfTzXcxBxjpoKYMcTTcAX8Rykus6QZu77XNhJuRHSRxMd+JJrbX/3PFI0opFGSN0BabbAYCbGxbu0mA==", - "dev": true - }, - "postgres-array": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", - "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==" - }, - "postgres-bytea": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.0.tgz", - "integrity": "sha1-AntTPAqokOJtFy1Hz5zOzFIazTU=" - }, - "postgres-date": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", - "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==" - }, - "postgres-interval": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", - "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", - "requires": { - "xtend": "^4.0.0" - } - }, - "printj": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/printj/-/printj-1.1.2.tgz", - "integrity": "sha512-zA2SmoLaxZyArQTOPj5LXecR+RagfPSU5Kw1qP+jkWeNlrq+eJZyY2oS68SU1Z/7/myXM4lo9716laOFAVStCQ==", - "dev": true - }, - "process": { - "version": "0.11.10", - "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", - "integrity": "sha1-czIwDoQBYb2j5podHZGn1LwW8YI=", - "dev": true - }, - "process-nextick-args": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", - "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", - "dev": true - }, - "prom-client": { - "version": "13.2.0", - "resolved": "https://registry.npmjs.org/prom-client/-/prom-client-13.2.0.tgz", - "integrity": "sha512-wGr5mlNNdRNzEhRYXgboUU2LxHWIojxscJKmtG3R8f4/KiWqyYgXTLHs0+Ted7tG3zFT7pgHJbtomzZ1L0ARaQ==", - "dev": true, - "requires": { - "tdigest": "^0.1.1" - } - }, - "promise": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/promise/-/promise-1.3.0.tgz", - "integrity": "sha1-5cyaTIJ45GZP/twBx9qEhCsEAXU=", - "dev": true, - "requires": { - "is-promise": "~1" - } - }, - "promise-ftp": { - "version": "1.3.5", - "resolved": "https://registry.npmjs.org/promise-ftp/-/promise-ftp-1.3.5.tgz", - "integrity": "sha512-v368jPSqzmjjKDIyggulC+dRFcpAOEX7aFdEWkFYQp8Ao3P2N4Y6XnFFdKgK7PtkylwvGQkZR/65HZuzmq0V7A==", - "dev": true, - "requires": { - "@icetee/ftp": "^0.3.15", - "bluebird": "2.x", - "promise-ftp-common": "^1.1.5" - }, - "dependencies": { - "bluebird": { - "version": "2.11.0", - "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-2.11.0.tgz", - "integrity": "sha1-U0uQM8AiyVecVro7Plpcqvu2UOE=", - "dev": true - } - } - }, - "promise-ftp-common": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/promise-ftp-common/-/promise-ftp-common-1.1.5.tgz", - "integrity": "sha1-tPgIKnQDVkdwNQZ2PtsUIw2YZdo=", - "dev": true - }, - "promise-inflight": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz", - "integrity": "sha1-mEcocL8igTL8vdhoEputEsPAKeM=", - "dev": true, - "optional": true - }, - "promise-retry": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", - "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", - "dev": true, - "requires": { - "err-code": "^2.0.2", - "retry": "^0.12.0" - } - }, - "promise.prototype.finally": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/promise.prototype.finally/-/promise.prototype.finally-3.1.3.tgz", - "integrity": "sha512-EXRF3fC9/0gz4qkt/f5EP5iW4kj9oFpBICNpCNOb/52+8nlHIX07FPLbi/q4qYBQ1xZqivMzTpNQSnArVASolQ==", - "dev": true, - "requires": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.3", - "es-abstract": "^1.19.1" - }, - "dependencies": { - "es-abstract": { - "version": "1.19.1", - "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.19.1.tgz", - "integrity": "sha512-2vJ6tjA/UfqLm2MPs7jxVybLoB8i1t1Jd9R3kISld20sIxPcTbLuggQOUxeWeAvIUkduv/CfMjuh4WmiXr2v9w==", - "dev": true, - "requires": { - "call-bind": "^1.0.2", - "es-to-primitive": "^1.2.1", - "function-bind": "^1.1.1", - "get-intrinsic": "^1.1.1", - "get-symbol-description": "^1.0.0", - "has": "^1.0.3", - "has-symbols": "^1.0.2", - "internal-slot": "^1.0.3", - "is-callable": "^1.2.4", - "is-negative-zero": "^2.0.1", - "is-regex": "^1.1.4", - "is-shared-array-buffer": "^1.0.1", - "is-string": "^1.0.7", - "is-weakref": "^1.0.1", - "object-inspect": "^1.11.0", - "object-keys": "^1.1.1", - "object.assign": "^4.1.2", - "string.prototype.trimend": "^1.0.4", - "string.prototype.trimstart": "^1.0.4", - "unbox-primitive": "^1.0.1" - } - } - } - }, - "property-expr": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/property-expr/-/property-expr-2.0.4.tgz", - "integrity": "sha512-sFPkHQjVKheDNnPvotjQmm3KD3uk1fWKUN7CrpdbwmUx3CrG3QiM8QpTSimvig5vTXmTvjz7+TDvXOI9+4rkcg==", - "dev": true - }, - "proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", - "dev": true, - "requires": { - "forwarded": "0.2.0", - "ipaddr.js": "1.9.1" - } - }, - "proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "dev": true - }, - "pseudomap": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/pseudomap/-/pseudomap-1.0.2.tgz", - "integrity": "sha1-8FKijacOYYkX7wqKw0wa5aaChrM=", - "dev": true - }, - "psl": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/psl/-/psl-1.8.0.tgz", - "integrity": "sha512-RIdOzyoavK+hA18OGGWDqUTsCLhtA7IcZ/6NCs4fFJaHBDab+pDDmDIByWFRQJq2Cd7r1OoQxBGKOaztq+hjIQ==", - "dev": true - }, - "pump": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", - "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==", - "dev": true, - "requires": { - "end-of-stream": "^1.1.0", - "once": "^1.3.1" - } - }, - "pumpify": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/pumpify/-/pumpify-1.5.1.tgz", - "integrity": "sha512-oClZI37HvuUJJxSKKrC17bZ9Cu0ZYhEAGPsPUy9KlMUmv9dKX2o77RUmq7f3XjIxbwyGwYzbzQ1L2Ks8sIradQ==", - "dev": true, - "requires": { - "duplexify": "^3.6.0", - "inherits": "^2.0.3", - "pump": "^2.0.0" - }, - "dependencies": { - "pump": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/pump/-/pump-2.0.1.tgz", - "integrity": "sha512-ruPMNRkN3MHP1cWJc9OWr+T/xDP0jhXYCLfJcBuX54hhfIBnaQmAUMfDcG4DM5UMWByBbJY69QSphm3jtDKIkA==", - "dev": true, - "requires": { - "end-of-stream": "^1.1.0", - "once": "^1.3.1" - } - } - } - }, - "punycode": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", - "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==", - "dev": true - }, - "python-struct": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/python-struct/-/python-struct-1.1.3.tgz", - "integrity": "sha512-UsI/mNvk25jRpGKYI38Nfbv84z48oiIWwG67DLVvjRhy8B/0aIK+5Ju5WOHgw/o9rnEmbAS00v4rgKFQeC332Q==", - "dev": true, - "requires": { - "long": "^4.0.0" - } - }, - "qs": { - "version": "6.7.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.7.0.tgz", - "integrity": "sha512-VCdBRNFTX1fyE7Nb6FYoURo/SPe62QCaAyzJvUjwRaIsc+NePBEniHlvxFmmX56+HZphIGtV0XeCirBtpDrTyQ==", - "dev": true - }, - "querystring": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/querystring/-/querystring-0.2.0.tgz", - "integrity": "sha1-sgmEkgO7Jd+CDadW50cAWHhSFiA=", - "dev": true - }, - "querystringify": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", - "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", - "dev": true - }, - "queue-microtask": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "dev": true - }, - "quoted-printable": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/quoted-printable/-/quoted-printable-1.0.1.tgz", - "integrity": "sha1-nuv16z0R7vAismT9LStrK7O4TMM=", - "dev": true, - "requires": { - "utf8": "^2.1.0" - } - }, - "railroad-diagrams": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/railroad-diagrams/-/railroad-diagrams-1.0.0.tgz", - "integrity": "sha1-635iZ1SN3t+4mcG5Dlc3RVnN234=", - "dev": true - }, - "randexp": { - "version": "0.4.6", - "resolved": "https://registry.npmjs.org/randexp/-/randexp-0.4.6.tgz", - "integrity": "sha512-80WNmd9DA0tmZrw9qQa62GPPWfuXJknrmVmLcxvq4uZBdYqb1wYoKTmnlGUchvVWe0XiLupYkBoXVOxz3C8DYQ==", - "dev": true, - "requires": { - "discontinuous-range": "1.0.0", - "ret": "~0.1.10" - } - }, - "random-bytes": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz", - "integrity": "sha1-T2ih3Arli9P7lYSMMDJNt11kNgs=", - "dev": true - }, - "range-parser": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", - "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", - "dev": true - }, - "raw-body": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.4.0.tgz", - "integrity": "sha512-4Oz8DUIwdvoa5qMJelxipzi/iJIi40O5cGV1wNYp5hvZP8ZN0T+jiNkL0QepXs+EsQ9XJ8ipEDoiH70ySUJP3Q==", - "dev": true, - "requires": { - "bytes": "3.1.0", - "http-errors": "1.7.2", - "iconv-lite": "0.4.24", - "unpipe": "1.0.0" - } - }, - "read-pkg": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-3.0.0.tgz", - "integrity": "sha1-nLxoaXj+5l0WwA4rGcI3/Pbjg4k=", - "dev": true, - "requires": { - "load-json-file": "^4.0.0", - "normalize-package-data": "^2.3.2", - "path-type": "^3.0.0" - }, - "dependencies": { - "path-type": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/path-type/-/path-type-3.0.0.tgz", - "integrity": "sha512-T2ZUsdZFHgA3u4e5PfPbjd7HDDpxPnQb5jN0SrDsjNSuVXHJqtwTnWqG0B1jZrgmJ/7lj1EmVIByWt1gxGkWvg==", - "dev": true, - "requires": { - "pify": "^3.0.0" - } - } - } - }, - "readable-stream": { - "version": "1.1.14", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.1.14.tgz", - "integrity": "sha1-fPTFTvZI44EwhMY23SB54WbAgdk=", - "dev": true, - "requires": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.1", - "isarray": "0.0.1", - "string_decoder": "~0.10.x" - } - }, - "readable-web-to-node-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/readable-web-to-node-stream/-/readable-web-to-node-stream-2.0.0.tgz", - "integrity": "sha512-+oZJurc4hXpaaqsN68GoZGQAQIA3qr09Or4fqEsargABnbe5Aau8hFn6ISVleT3cpY/0n/8drn7huyyEvTbghA==", - "dev": true - }, - "redis": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/redis/-/redis-3.1.2.tgz", - "integrity": "sha512-grn5KoZLr/qrRQVwoSkmzdbw6pwF+/rwODtrOr6vuBRiR/f3rjSTGupbF90Zpqm2oenix8Do6RV7pYEkGwlKkw==", - "dev": true, - "requires": { - "denque": "^1.5.0", - "redis-commands": "^1.7.0", - "redis-errors": "^1.2.0", - "redis-parser": "^3.0.0" - } - }, - "redis-commands": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/redis-commands/-/redis-commands-1.7.0.tgz", - "integrity": "sha512-nJWqw3bTFy21hX/CPKHth6sfhZbdiHP6bTawSgQBlKOVRG7EZkfHbbHwQJnrE4vsQf0CMNE+3gJ4Fmm16vdVlQ==", - "dev": true - }, - "redis-errors": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", - "integrity": "sha1-62LSrbFeTq9GEMBK/hUpOEJQq60=", - "dev": true - }, - "redis-parser": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", - "integrity": "sha1-tm2CjNyv5rS4pCin3vTGvKwxyLQ=", - "dev": true, - "requires": { - "redis-errors": "^1.0.0" - } - }, - "reflect-metadata": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.1.13.tgz", - "integrity": "sha512-Ts1Y/anZELhSsjMcU605fU9RE4Oi3p5ORujwbIKXfWa+0Zxs510Qrmrce5/Jowq3cHSZSJqBjypxmHarc+vEWg==", - "dev": true - }, - "regenerator-runtime": { - "version": "0.13.9", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.9.tgz", - "integrity": "sha512-p3VT+cOEgxFsRRA9X4lkI1E+k2/CtnKtU4gcxyaCUreilL/vqI6CdZ3wxVUx3UOUg+gnUOQQcRI7BmSI656MYA==", - "dev": true - }, - "reinterval": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/reinterval/-/reinterval-1.1.0.tgz", - "integrity": "sha1-M2Hs+jymwYKDOA3Qu5VG85D17Oc=", - "dev": true - }, - "remove-trailing-separator": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz", - "integrity": "sha1-wkvOKig62tW8P1jg1IJJuSN52O8=", - "dev": true - }, - "request": { - "version": "2.88.2", - "resolved": "https://registry.npmjs.org/request/-/request-2.88.2.tgz", - "integrity": "sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw==", - "dev": true, - "requires": { - "aws-sign2": "~0.7.0", - "aws4": "^1.8.0", - "caseless": "~0.12.0", - "combined-stream": "~1.0.6", - "extend": "~3.0.2", - "forever-agent": "~0.6.1", - "form-data": "~2.3.2", - "har-validator": "~5.1.3", - "http-signature": "~1.2.0", - "is-typedarray": "~1.0.0", - "isstream": "~0.1.2", - "json-stringify-safe": "~5.0.1", - "mime-types": "~2.1.19", - "oauth-sign": "~0.9.0", - "performance-now": "^2.1.0", - "qs": "~6.5.2", - "safe-buffer": "^5.1.2", - "tough-cookie": "~2.5.0", - "tunnel-agent": "^0.6.0", - "uuid": "^3.3.2" - }, - "dependencies": { - "form-data": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.3.tgz", - "integrity": "sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==", - "dev": true, - "requires": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.6", - "mime-types": "^2.1.12" - } - }, - "qs": { - "version": "6.5.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.2.tgz", - "integrity": "sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA==", - "dev": true - }, - "tough-cookie": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.5.0.tgz", - "integrity": "sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==", - "dev": true, - "requires": { - "psl": "^1.1.28", - "punycode": "^2.1.1" - } - }, - "uuid": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", - "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==", - "dev": true - } - } - }, - "request-promise-core": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/request-promise-core/-/request-promise-core-1.1.4.tgz", - "integrity": "sha512-TTbAfBBRdWD7aNNOoVOBH4pN/KigV6LyapYNNlAPA8JwbovRti1E88m3sYAwsLi5ryhPKsE9APwnjFTgdUjTpw==", - "dev": true, - "requires": { - "lodash": "^4.17.19" - } - }, - "request-promise-native": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/request-promise-native/-/request-promise-native-1.0.9.tgz", - "integrity": "sha512-wcW+sIUiWnKgNY0dqCpOZkUbF/I+YPi+f09JZIDa39Ec+q82CpSYniDp+ISgTTbKmnpJWASeJBPZmoxH84wt3g==", - "dev": true, - "requires": { - "request-promise-core": "1.1.4", - "stealthy-require": "^1.1.1", - "tough-cookie": "^2.3.3" - }, - "dependencies": { - "tough-cookie": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.5.0.tgz", - "integrity": "sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==", - "dev": true, - "requires": { - "psl": "^1.1.28", - "punycode": "^2.1.1" - } - } - } - }, - "requestretry": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/requestretry/-/requestretry-6.0.0.tgz", - "integrity": "sha512-X7O+BMlfHgzetfSDtgQIMinLn1BuT+95W12iffDzyOS+HLoBEIQqCZv++UTChUWVjOu+pudbocD76+4j+jK9ww==", - "dev": true, - "requires": { - "extend": "^3.0.2", - "lodash": "^4.17.15" - } - }, - "require-at": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/require-at/-/require-at-1.0.6.tgz", - "integrity": "sha512-7i1auJbMUrXEAZCOQ0VNJgmcT2VOKPRl2YGJwgpHpC9CE91Mv4/4UYIUm4chGJaI381ZDq1JUicFii64Hapd8g==", - "dev": true - }, - "require-directory": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", - "integrity": "sha1-jGStX9MNqxyXbiNE/+f3kqam30I=", - "dev": true - }, - "requires-port": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", - "integrity": "sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8=", - "dev": true - }, - "resolve": { - "version": "1.20.0", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.20.0.tgz", - "integrity": "sha512-wENBPt4ySzg4ybFQW2TT1zMQucPK95HSh/nq2CFTZVOGut2+pQvSsgtda4d26YrYcr067wjbmzOG8byDPBX63A==", - "dev": true, - "requires": { - "is-core-module": "^2.2.0", - "path-parse": "^1.0.6" - } - }, - "restore-cursor": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", - "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", - "dev": true, - "requires": { - "onetime": "^5.1.0", - "signal-exit": "^3.0.2" - } - }, - "ret": { - "version": "0.1.15", - "resolved": "https://registry.npmjs.org/ret/-/ret-0.1.15.tgz", - "integrity": "sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==", - "dev": true - }, - "retry": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", - "integrity": "sha1-G0KmJmoh8HQh0bC1S33BZ7AcATs=", - "dev": true - }, - "reusify": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", - "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", - "dev": true - }, - "rhea": { - "version": "1.0.24", - "resolved": "https://registry.npmjs.org/rhea/-/rhea-1.0.24.tgz", - "integrity": "sha512-PEl62U2EhxCO5wMUZ2/bCBcXAVKN9AdMSNQOrp3+R5b77TEaOSiy16MQ0sIOmzj/iqsgIAgPs1mt3FYfu1vIXA==", - "dev": true, - "requires": { - "debug": "0.8.0 - 3.5.0" - }, - "dependencies": { - "debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", - "dev": true, - "requires": { - "ms": "^2.1.1" - } - } - } - }, - "rimraf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "dev": true, - "requires": { - "glob": "^7.1.3" - } - }, - "riot-tmpl": { - "version": "3.0.8", - "resolved": "https://registry.npmjs.org/riot-tmpl/-/riot-tmpl-3.0.8.tgz", - "integrity": "sha1-3WVOcqOhUgywCcvvcMc4Vt7VhKY=", - "dev": true, - "requires": { - "eslint-config-riot": "^1.0.0" - } - }, - "rndm": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/rndm/-/rndm-1.2.0.tgz", - "integrity": "sha1-8z/pz7Urv9UgqhgyO8ZdsRCht2w=", - "dev": true - }, - "rss-parser": { - "version": "3.12.0", - "resolved": "https://registry.npmjs.org/rss-parser/-/rss-parser-3.12.0.tgz", - "integrity": "sha512-aqD3E8iavcCdkhVxNDIdg1nkBI17jgqF+9OqPS1orwNaOgySdpvq6B+DoONLhzjzwV8mWg37sb60e4bmLK117A==", - "dev": true, - "requires": { - "entities": "^2.0.3", - "xml2js": "^0.4.19" - } - }, - "run-async": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/run-async/-/run-async-2.4.1.tgz", - "integrity": "sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==", - "dev": true - }, - "run-parallel": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "dev": true, - "requires": { - "queue-microtask": "^1.2.2" - } - }, - "rxjs": { - "version": "6.6.7", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.6.7.tgz", - "integrity": "sha512-hTdwr+7yYNIT5n4AMYp85KA6yw2Va0FLa3Rguvbpa4W3I5xynaBZo41cM3XM+4Q6fRMj3sBYIR1VAmZMXYJvRQ==", - "dev": true, - "requires": { - "tslib": "^1.9.0" - } - }, - "safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "dev": true - }, - "safe-stable-stringify": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-1.1.1.tgz", - "integrity": "sha512-ERq4hUjKDbJfE4+XtZLFPCDi8Vb1JqaxAPTxWFLBx8XcAlf9Bda/ZJdVezs/NAfsMQScyIlUMx+Yeu7P7rx5jw==", - "dev": true - }, - "safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true - }, - "saslprep": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/saslprep/-/saslprep-1.0.3.tgz", - "integrity": "sha512-/MY/PEMbk2SuY5sScONwhUDsV2p77Znkb/q3nSVstq/yQzYJOH/Azh29p9oJLsl3LnQwSvZDKagDGBsBwSooag==", - "dev": true, - "optional": true, - "requires": { - "sparse-bitfield": "^3.0.3" - } - }, - "sax": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.4.tgz", - "integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==", - "dev": true - }, - "sb-promise-queue": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/sb-promise-queue/-/sb-promise-queue-2.1.0.tgz", - "integrity": "sha512-zwq4YuP1FQFkGx2Q7GIkZYZ6PqWpV+bg0nIO1sJhWOyGyhqbj0MsTvK6lCFo5TQwX5pZr6SCQ75e8PCDCuNvkg==", - "dev": true - }, - "sb-scandir": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/sb-scandir/-/sb-scandir-3.1.0.tgz", - "integrity": "sha512-70BVm2xz9jn94zSQdpvYrEG101/UV9TVGcfWr9T5iob3QhCK4lYXeculfBqPGFv3XTeKgx4dpWyYIDeZUqo4kg==", - "dev": true, - "requires": { - "sb-promise-queue": "^2.1.0" - } - }, - "selderee": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/selderee/-/selderee-0.6.0.tgz", - "integrity": "sha512-ibqWGV5aChDvfVdqNYuaJP/HnVBhlRGSRrlbttmlMpHcLuTqqbMH36QkSs9GEgj5M88JDYLI8eyP94JaQ8xRlg==", - "dev": true, - "requires": { - "parseley": "^0.7.0" - } - }, - "semver": { - "version": "7.3.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.5.tgz", - "integrity": "sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ==", - "dev": true, - "requires": { - "lru-cache": "^6.0.0" - } - }, - "send": { - "version": "0.17.1", - "resolved": "https://registry.npmjs.org/send/-/send-0.17.1.tgz", - "integrity": "sha512-BsVKsiGcQMFwT8UxypobUKyv7irCNRHk1T0G680vk88yf6LBByGcZJOTJCrTP2xVN6yI+XjPJcNuE3V4fT9sAg==", - "dev": true, - "requires": { - "debug": "2.6.9", - "depd": "~1.1.2", - "destroy": "~1.0.4", - "encodeurl": "~1.0.2", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "fresh": "0.5.2", - "http-errors": "~1.7.2", - "mime": "1.6.0", - "ms": "2.1.1", - "on-finished": "~2.3.0", - "range-parser": "~1.2.1", - "statuses": "~1.5.0" - }, - "dependencies": { - "debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dev": true, - "requires": { - "ms": "2.0.0" - }, - "dependencies": { - "ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", - "dev": true - } - } - }, - "ms": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.1.tgz", - "integrity": "sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg==", - "dev": true - } - } - }, - "sentence-case": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/sentence-case/-/sentence-case-3.0.4.tgz", - "integrity": "sha512-8LS0JInaQMCRoQ7YUytAo/xUu5W2XnQxV2HI/6uM6U7CITS1RqPElr30V6uIqyMKM9lJGRVFy5/4CuzcixNYSg==", - "dev": true, - "requires": { - "no-case": "^3.0.4", - "tslib": "^2.0.3", - "upper-case-first": "^2.0.2" - }, - "dependencies": { - "tslib": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz", - "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw==", - "dev": true - } - } - }, - "seq-queue": { - "version": "0.0.5", - "resolved": "https://registry.npmjs.org/seq-queue/-/seq-queue-0.0.5.tgz", - "integrity": "sha1-1WgS4cAXpuTnw+Ojeh2m143TyT4=", - "dev": true - }, - "serve-static": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.14.1.tgz", - "integrity": "sha512-JMrvUwE54emCYWlTI+hGrGv5I8dEwmco/00EvkzIIsR7MqrHonbD9pO2MOfFnpFntl7ecpZs+3mW+XbQZu9QCg==", - "dev": true, - "requires": { - "encodeurl": "~1.0.2", - "escape-html": "~1.0.3", - "parseurl": "~1.3.3", - "send": "0.17.1" - } - }, - "servie": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/servie/-/servie-4.3.3.tgz", - "integrity": "sha512-b0IrY3b1gVMsWvJppCf19g1p3JSnS0hQi6xu4Hi40CIhf0Lx8pQHcvBL+xunShpmOiQzg1NOia812NAWdSaShw==", - "dev": true, - "requires": { - "@servie/events": "^1.0.0", - "byte-length": "^1.0.2", - "ts-expect": "^1.1.0" - } - }, - "set-blocking": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", - "integrity": "sha1-BF+XgtARrppoA93TgrJDkrPYkPc=", - "dev": true - }, - "setheader": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/setheader/-/setheader-1.0.2.tgz", - "integrity": "sha512-A704nIwzqGed0CnJZIqDE+0udMPS839ocgf1R9OJ8aq8vw4U980HWeNaD9ec8VnmBni9lyGEWDedOWXT/C5kxA==", - "dev": true, - "requires": { - "diagnostics": "1.x.x" - } - }, - "setprototypeof": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.1.tgz", - "integrity": "sha512-JvdAWfbXeIGaZ9cILp38HntZSFSo3mWg6xGcJJsd+d4aRMOqauag1C63dJfDw7OaMYwEbHMOxEZ1lqVRYP2OAw==", - "dev": true - }, - "sha.js": { - "version": "2.4.11", - "resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.11.tgz", - "integrity": "sha512-QMEp5B7cftE7APOjk5Y6xgrbWu+WkLVQwk8JNjZ8nKRciZaByEW6MubieAiToS7+dwvrjGhH8jRXz3MVd0AYqQ==", - "dev": true, - "requires": { - "inherits": "^2.0.1", - "safe-buffer": "^5.0.1" - } - }, - "shell-escape": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/shell-escape/-/shell-escape-0.2.0.tgz", - "integrity": "sha1-aP0CXrBJC09WegJ/C/IkgLX4QTM=", - "dev": true - }, - "shell-quote": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.7.2.tgz", - "integrity": "sha512-mRz/m/JVscCrkMyPqHc/bczi3OQHkLTqXHEFu0zDhK/qfv3UcOA4SVmRCLmos4bhjr9ekVQubj/R7waKapmiQg==", - "dev": true - }, - "side-channel": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", - "integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==", - "dev": true, - "requires": { - "call-bind": "^1.0.0", - "get-intrinsic": "^1.0.2", - "object-inspect": "^1.9.0" - } - }, - "signal-exit": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.5.tgz", - "integrity": "sha512-KWcOiKeQj6ZyXx7zq4YxSMgHRlod4czeBQZrPb8OKcohcqAXShm7E20kEMle9WBt26hFcAf0qLOcp5zmY7kOqQ==", - "dev": true - }, - "simple-git": { - "version": "2.46.0", - "resolved": "https://registry.npmjs.org/simple-git/-/simple-git-2.46.0.tgz", - "integrity": "sha512-6eumII1vfP4NpRqxZcVWCcIT5xHH6dRyvBZSjkH4dJRDRpv+0f75hrN5ysp++y23Mfr3AbRC/dO2NDbfj1lJpQ==", - "dev": true, - "requires": { - "@kwsites/file-exists": "^1.1.1", - "@kwsites/promise-deferred": "^1.1.1", - "debug": "^4.3.1" - } - }, - "simple-lru-cache": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/simple-lru-cache/-/simple-lru-cache-0.0.2.tgz", - "integrity": "sha1-1ZzDoZPBpdAyD4Tucy9uRxPlEd0=", - "dev": true - }, - "simple-swizzle": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", - "integrity": "sha1-pNprY1/8zMoz9w0Xy5JZLeleVXo=", - "dev": true, - "requires": { - "is-arrayish": "^0.3.1" - } - }, - "slash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", - "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", - "dev": true - }, - "smart-buffer": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", - "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", - "dev": true, - "optional": true - }, - "snake-case": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/snake-case/-/snake-case-3.0.4.tgz", - "integrity": "sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg==", - "dev": true, - "requires": { - "dot-case": "^3.0.4", - "tslib": "^2.0.3" - }, - "dependencies": { - "tslib": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz", - "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw==", - "dev": true - } - } - }, - "snowflake-sdk": { - "version": "1.6.4", - "resolved": "https://registry.npmjs.org/snowflake-sdk/-/snowflake-sdk-1.6.4.tgz", - "integrity": "sha512-pcUR+htapwbtcQOeBJ5wOQpFbzyVlXyo7L7Z2x9VuwFrBa5+dPdI3lXMoCnxTepl52YVYdUkAWp5Qkd0piO77w==", - "dev": true, - "requires": { - "@azure/storage-blob": "^12.5.0", - "agent-base": "^4.3.0", - "asn1.js-rfc2560": "^5.0.0", - "asn1.js-rfc5280": "^3.0.0", - "aws-sdk": "^2.878.0", - "axios": "^0.21.1", - "big-integer": "^1.6.43", - "bignumber.js": "^2.4.0", - "binascii": "0.0.2", - "browser-request": "^0.3.3", - "debug": "^3.2.6", - "expand-tilde": "^2.0.2", - "extend": "^3.0.2", - "generic-pool": "^3.8.2", - "glob": "^7.1.6", - "https-proxy-agent": "^3.0.0", - "jsonwebtoken": "^8.5.1", - "lodash": "^4.17.21", - "mime-types": "^2.1.29", - "mkdirp": "^1.0.3", - "mock-require": "^3.0.3", - "moment": "^2.23.0", - "moment-timezone": "^0.5.15", - "ocsp": "^1.2.0", - "open": "^7.3.1", - "python-struct": "^1.1.3", - "request": "^2.88.0", - "requestretry": "^6.0.0", - "simple-lru-cache": "^0.0.2", - "string-similarity": "^4.0.4", - "test-console": "^2.0.0", - "tmp": "^0.2.1", - "uuid": "^3.3.2", - "winston": "^3.1.0" - }, - "dependencies": { - "agent-base": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-4.3.0.tgz", - "integrity": "sha512-salcGninV0nPrwpGNn4VTXBb1SOuXQBiqbrNXoeizJsHrsL6ERFM2Ne3JUSBWRE6aeNJI2ROP/WEEIDUiDe3cg==", - "dev": true, - "requires": { - "es6-promisify": "^5.0.0" - } - }, - "debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", - "dev": true, - "requires": { - "ms": "^2.1.1" - } - }, - "https-proxy-agent": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-3.0.1.tgz", - "integrity": "sha512-+ML2Rbh6DAuee7d07tYGEKOEi2voWPUGan+ExdPbPW6Z3svq+JCqr0v8WmKPOkz1vOVykPCBSuobe7G8GJUtVg==", - "dev": true, - "requires": { - "agent-base": "^4.3.0", - "debug": "^3.1.0" - } - }, - "tmp": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.1.tgz", - "integrity": "sha512-76SUhtfqR2Ijn+xllcI5P1oyannHNHByD80W1q447gU3mp9G9PSpGdWmjUOHRDPiHYacIk66W7ubDTuPF3BEtQ==", - "dev": true, - "requires": { - "rimraf": "^3.0.0" - } - }, - "uuid": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", - "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==", - "dev": true - } - } - }, - "socks": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/socks/-/socks-2.6.2.tgz", - "integrity": "sha512-zDZhHhZRY9PxRruRMR7kMhnf3I8hDs4S3f9RecfnGxvcBHQcKcIH/oUcEWffsfl1XxdYlA7nnlGbbTvPz9D8gA==", - "dev": true, - "optional": true, - "requires": { - "ip": "^1.1.5", - "smart-buffer": "^4.2.0" - } - }, - "socks-proxy-agent": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-6.2.0.tgz", - "integrity": "sha512-wWqJhjb32Q6GsrUqzuFkukxb/zzide5quXYcMVpIjxalDBBYy2nqKCFQ/9+Ie4dvOYSQdOk3hUlZSdzZOd3zMQ==", - "dev": true, - "optional": true, - "requires": { - "agent-base": "^6.0.2", - "debug": "^4.3.3", - "socks": "^2.6.2" - }, - "dependencies": { - "debug": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", - "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", - "dev": true, - "optional": true, - "requires": { - "ms": "2.1.2" - } - } - } - }, - "sparse-bitfield": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz", - "integrity": "sha1-/0rm5oZWBWuks+eSqzM004JzyhE=", - "dev": true, - "optional": true, - "requires": { - "memory-pager": "^1.0.2" - } - }, - "spdx-correct": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.1.1.tgz", - "integrity": "sha512-cOYcUWwhCuHCXi49RhFRCyJEK3iPj1Ziz9DpViV3tbZOwXD49QzIN3MpOLJNxh2qwq2lJJZaKMVw9qNi4jTC0w==", - "dev": true, - "requires": { - "spdx-expression-parse": "^3.0.0", - "spdx-license-ids": "^3.0.0" - } - }, - "spdx-exceptions": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.3.0.tgz", - "integrity": "sha512-/tTrYOC7PPI1nUAgx34hUpqXuyJG+DTHJTnIULG4rDygi4xu/tfgmq1e1cIRwRzwZgo4NLySi+ricLkZkw4i5A==", - "dev": true - }, - "spdx-expression-parse": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", - "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", - "dev": true, - "requires": { - "spdx-exceptions": "^2.1.0", - "spdx-license-ids": "^3.0.0" - } - }, - "spdx-license-ids": { - "version": "3.0.10", - "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.10.tgz", - "integrity": "sha512-oie3/+gKf7QtpitB0LYLETe+k8SifzsX4KixvpOsbI6S0kRiRQ5MKOio8eMSAKQ17N06+wdEOXRiId+zOxo0hA==", - "dev": true - }, - "spex": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/spex/-/spex-3.2.0.tgz", - "integrity": "sha512-9srjJM7NaymrpwMHvSmpDeIK5GoRMX/Tq0E8aOlDPS54dDnDUIp30DrP9SphMPEETDLzEM9+4qo+KipmbtPecg==", - "dev": true - }, - "split2": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/split2/-/split2-3.2.2.tgz", - "integrity": "sha512-9NThjpgZnifTkJpzTZ7Eue85S49QwpNhZTq6GRJwObb6jnLFNGB7Qm73V5HewTROPyxD0C29xqmaI68bQtV+hg==", - "requires": { - "readable-stream": "^3.0.0" - }, - "dependencies": { - "readable-stream": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz", - "integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==", - "requires": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - } - }, - "safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==" - }, - "string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "requires": { - "safe-buffer": "~5.2.0" - } - } - } - }, - "sprintf-js": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.2.tgz", - "integrity": "sha512-VE0SOVEHCk7Qc8ulkWw3ntAzXuqf7S2lvwQaDLRnUeIEaKNQJzV6BwmLKhOqT61aGhfUMrXeaBk+oDGCzvhcug==", - "dev": true - }, - "sqlite3": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/sqlite3/-/sqlite3-5.0.8.tgz", - "integrity": "sha512-f2ACsbSyb2D1qFFcqIXPfFscLtPVOWJr5GmUzYxf4W+0qelu5MWrR+FAQE1d5IUArEltBrzSDxDORG8P/IkqyQ==", - "dev": true, - "requires": { - "@mapbox/node-pre-gyp": "^1.0.0", - "node-addon-api": "^4.2.0", - "node-gyp": "8.x", - "tar": "^6.1.11" - }, - "dependencies": { - "are-we-there-yet": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-3.0.0.tgz", - "integrity": "sha512-0GWpv50YSOcLXaN6/FAKY3vfRbllXWV2xvfA/oKJF8pzFhWXPV+yjhJXDBbjscDYowv7Yw1A3uigpzn5iEGTyw==", - "dev": true, - "optional": true, - "requires": { - "delegates": "^1.0.0", - "readable-stream": "^3.6.0" - } - }, - "chownr": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", - "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", - "dev": true - }, - "fs-minipass": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", - "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", - "dev": true, - "requires": { - "minipass": "^3.0.0" - } - }, - "gauge": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/gauge/-/gauge-4.0.4.tgz", - "integrity": "sha512-f9m+BEN5jkg6a0fZjleidjN51VE1X+mPFQ2DJ0uv1V39oCLCbsGe6yjbBnp7eK7z/+GAon99a3nHuqbuuthyPg==", - "dev": true, - "optional": true, - "requires": { - "aproba": "^1.0.3 || ^2.0.0", - "color-support": "^1.1.3", - "console-control-strings": "^1.1.0", - "has-unicode": "^2.0.1", - "signal-exit": "^3.0.7", - "string-width": "^4.2.3", - "strip-ansi": "^6.0.1", - "wide-align": "^1.1.5" - } - }, - "minipass": { - "version": "3.1.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.1.6.tgz", - "integrity": "sha512-rty5kpw9/z8SX9dmxblFA6edItUmwJgMeYDZRrwlIVN27i8gysGbznJwUggw2V/FVqFSDdWy040ZPS811DYAqQ==", - "dev": true, - "requires": { - "yallist": "^4.0.0" - } - }, - "minizlib": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", - "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", - "dev": true, - "requires": { - "minipass": "^3.0.0", - "yallist": "^4.0.0" - } - }, - "node-addon-api": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-4.3.0.tgz", - "integrity": "sha512-73sE9+3UaLYYFmDsFZnqCInzPyh3MqIwZO9cw58yIqAZhONrrabrYyYe3TuIqtIiOuTXVhsGau8hcrhhwSsDIQ==", - "dev": true - }, - "node-gyp": { - "version": "8.4.1", - "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-8.4.1.tgz", - "integrity": "sha512-olTJRgUtAb/hOXG0E93wZDs5YiJlgbXxTwQAFHyNlRsXQnYzUaF2aGgujZbw+hR8aF4ZG/rST57bWMWD16jr9w==", - "dev": true, - "optional": true, - "requires": { - "env-paths": "^2.2.0", - "glob": "^7.1.4", - "graceful-fs": "^4.2.6", - "make-fetch-happen": "^9.1.0", - "nopt": "^5.0.0", - "npmlog": "^6.0.0", - "rimraf": "^3.0.2", - "semver": "^7.3.5", - "tar": "^6.1.2", - "which": "^2.0.2" - } - }, - "nopt": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", - "integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==", - "dev": true, - "optional": true, - "requires": { - "abbrev": "1" - } - }, - "npmlog": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-6.0.2.tgz", - "integrity": "sha512-/vBvz5Jfr9dT/aFWd0FIRf+T/Q2WBsLENygUaFUqstqsycmZAP/t5BvFJTK0viFmSUxiUKTUplWy5vt+rvKIxg==", - "dev": true, - "optional": true, - "requires": { - "are-we-there-yet": "^3.0.0", - "console-control-strings": "^1.1.0", - "gauge": "^4.0.3", - "set-blocking": "^2.0.0" - } - }, - "readable-stream": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz", - "integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==", - "dev": true, - "optional": true, - "requires": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - } - }, - "safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "dev": true, - "optional": true - }, - "signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "dev": true, - "optional": true - }, - "string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "dev": true, - "optional": true, - "requires": { - "safe-buffer": "~5.2.0" - } - }, - "tar": { - "version": "6.1.11", - "resolved": "https://registry.npmjs.org/tar/-/tar-6.1.11.tgz", - "integrity": "sha512-an/KZQzQUkZCkuoAA64hM92X0Urb6VpRhAFllDzz44U2mcD5scmT3zBc4VgVpkugF580+DQn8eAFSyoQt0tznA==", - "dev": true, - "requires": { - "chownr": "^2.0.0", - "fs-minipass": "^2.0.0", - "minipass": "^3.0.0", - "minizlib": "^2.1.1", - "mkdirp": "^1.0.3", - "yallist": "^4.0.0" - } - }, - "which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, - "optional": true, - "requires": { - "isexe": "^2.0.0" - } - } - } - }, - "sqlstring": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.2.tgz", - "integrity": "sha512-vF4ZbYdKS8OnoJAWBmMxCQDkiEBkGQYU7UZPtL8flbDRSNkhaXvRJ279ZtI6M+zDaQovVU4tuRgzK5fVhvFAhg==", - "dev": true - }, - "sse-channel": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/sse-channel/-/sse-channel-3.1.1.tgz", - "integrity": "sha512-vgf4QFh60vlAMX0vGJpn6S+7gTO3ckRn7xq4DOgQGcgDs7ULBkaQFQxy4b3vj/umyk0ydhGu7i4A1nHQc5HcYw==", - "dev": true, - "requires": { - "access-control": "1.0.1", - "lodash": "^4.17.10" - } - }, - "ssf": { - "version": "0.11.2", - "resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz", - "integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==", - "dev": true, - "requires": { - "frac": "~1.1.2" - } - }, - "ssh2": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.5.0.tgz", - "integrity": "sha512-iUmRkhH9KGeszQwDW7YyyqjsMTf4z+0o48Cp4xOwlY5LjtbIAvyd3fwnsoUZW/hXmTCRA3yt7S/Jb9uVjErVlA==", - "dev": true, - "requires": { - "asn1": "^0.2.4", - "bcrypt-pbkdf": "^1.0.2", - "cpu-features": "0.0.2", - "nan": "^2.15.0" - } - }, - "ssh2-sftp-client": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/ssh2-sftp-client/-/ssh2-sftp-client-7.1.0.tgz", - "integrity": "sha512-RyeBnutDAbIwmQrGO+MafKuXHkg2F6AMrdZtB7fbQdGm2c8AhPEY6hMwc41DKJlNtDcQCr2vaZlrBriu6xC5PA==", - "dev": true, - "requires": { - "concat-stream": "^2.0.0", - "promise-retry": "^2.0.1", - "ssh2": "^1.5.0" - } - }, - "sshpk": { - "version": "1.16.1", - "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.16.1.tgz", - "integrity": "sha512-HXXqVUq7+pcKeLqqZj6mHFUMvXtOJt1uoUx09pFW6011inTMxqI8BA8PM95myrIyyKwdnzjdFjLiE6KBPVtJIg==", - "dev": true, - "requires": { - "asn1": "~0.2.3", - "assert-plus": "^1.0.0", - "bcrypt-pbkdf": "^1.0.0", - "dashdash": "^1.12.0", - "ecc-jsbn": "~0.1.1", - "getpass": "^0.1.1", - "jsbn": "~0.1.0", - "safer-buffer": "^2.0.2", - "tweetnacl": "~0.14.0" - } - }, - "ssri": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/ssri/-/ssri-8.0.1.tgz", - "integrity": "sha512-97qShzy1AiyxvPNIkLWoGua7xoQzzPjQ0HAH4B0rWKo7SZ6USuPcrUiAFrws0UH8RrbWmgq3LMTObhPIHbbBeQ==", - "dev": true, - "optional": true, - "requires": { - "minipass": "^3.1.1" - }, - "dependencies": { - "minipass": { - "version": "3.1.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.1.6.tgz", - "integrity": "sha512-rty5kpw9/z8SX9dmxblFA6edItUmwJgMeYDZRrwlIVN27i8gysGbznJwUggw2V/FVqFSDdWy040ZPS811DYAqQ==", - "dev": true, - "optional": true, - "requires": { - "yallist": "^4.0.0" - } - } - } - }, - "stack-trace": { - "version": "0.0.10", - "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz", - "integrity": "sha1-VHxws0fo0ytOEI6hoqFZ5f3eGcA=", - "dev": true - }, - "standard-as-callback": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", - "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==", - "dev": true - }, - "statuses": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", - "integrity": "sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow=", - "dev": true - }, - "stealthy-require": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/stealthy-require/-/stealthy-require-1.1.1.tgz", - "integrity": "sha1-NbCYdbT/SfJqd35QmzCQoyJr8ks=", - "dev": true - }, - "stream-shift": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.1.tgz", - "integrity": "sha512-AiisoFqQ0vbGcZgQPY1cdP2I76glaVA/RauYR4G4thNFgkTqr90yXTo4LYX60Jl+sIlPNHHdGSwo01AvbKUSVQ==", - "dev": true - }, - "string-similarity": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/string-similarity/-/string-similarity-4.0.4.tgz", - "integrity": "sha512-/q/8Q4Bl4ZKAPjj8WerIBJWALKkaPRfrvhfF8k/B23i4nzrlRj2/go1m90In7nG/3XDSbOo0+pu6RvCTM9RGMQ==", - "dev": true - }, - "string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "requires": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - } - }, - "string.prototype.padend": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/string.prototype.padend/-/string.prototype.padend-3.1.2.tgz", - "integrity": "sha512-/AQFLdYvePENU3W5rgurfWSMU6n+Ww8n/3cUt7E+vPBB/D7YDG8x+qjoFs4M/alR2bW7Qg6xMjVwWUOvuQ0XpQ==", - "dev": true, - "requires": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.3", - "es-abstract": "^1.18.0-next.2" - } - }, - "string.prototype.trimend": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.4.tgz", - "integrity": "sha512-y9xCjw1P23Awk8EvTpcyL2NIr1j7wJ39f+k6lvRnSMz+mz9CGz9NYPelDk42kOz6+ql8xjfK8oYzy3jAP5QU5A==", - "dev": true, - "requires": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.3" - } - }, - "string.prototype.trimstart": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.4.tgz", - "integrity": "sha512-jh6e984OBfvxS50tdY2nRZnoC5/mLFKOREQfw8t5yytkoUsJRNxvI/E39qu1sD0OtWI3OC0XgKSmcWwziwYuZw==", - "dev": true, - "requires": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.3" - } - }, - "string_decoder": { - "version": "0.10.31", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", - "integrity": "sha1-YuIDvEF2bGwoyfyEMB2rHFMQ+pQ=", - "dev": true - }, - "strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "requires": { - "ansi-regex": "^5.0.1" - } - }, - "strip-bom": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", - "integrity": "sha1-IzTBjpx1n3vdVv3vfprj1YjmjtM=", - "dev": true - }, - "strtok3": { - "version": "6.2.4", - "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-6.2.4.tgz", - "integrity": "sha512-GO8IcFF9GmFDvqduIspUBwCzCbqzegyVKIsSymcMgiZKeCfrN9SowtUoi8+b59WZMAjIzVZic/Ft97+pynR3Iw==", - "dev": true, - "requires": { - "@tokenizer/token": "^0.3.0", - "peek-readable": "^4.0.1" - } - }, - "supports-color": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", - "dev": true, - "requires": { - "has-flag": "^3.0.0" - } - }, - "tarn": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/tarn/-/tarn-1.1.5.tgz", - "integrity": "sha512-PMtJ3HCLAZeedWjJPgGnCvcphbCOMbtZpjKgLq3qM5Qq9aQud+XHrL0WlrlgnTyS8U+jrjGbEXprFcQrxPy52g==", - "dev": true - }, - "tdigest": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/tdigest/-/tdigest-0.1.1.tgz", - "integrity": "sha1-Ljyyw56kSeVdHmzZEReszKRYgCE=", - "dev": true, - "requires": { - "bintrees": "1.0.1" - } - }, - "tedious": { - "version": "6.7.1", - "resolved": "https://registry.npmjs.org/tedious/-/tedious-6.7.1.tgz", - "integrity": "sha512-61eg/mvUa5vIqZcRizcqw/82dY65kR2uTll1TaUFh0aJ45XOrgbc8axiVR48dva8BahIAlJByaHNfAJ/KmPV0g==", - "dev": true, - "requires": { - "@azure/ms-rest-nodeauth": "^3.0.10", - "@types/node": "^12.12.17", - "@types/readable-stream": "^2.3.5", - "bl": "^3.0.0", - "depd": "^2.0.0", - "iconv-lite": "^0.5.0", - "jsbi": "^3.1.1", - "native-duplexpair": "^1.0.0", - "punycode": "^2.1.0", - "readable-stream": "^3.4.0", - "sprintf-js": "^1.1.2" - }, - "dependencies": { - "@types/node": { - "version": "12.20.37", - "resolved": "https://registry.npmjs.org/@types/node/-/node-12.20.37.tgz", - "integrity": "sha512-i1KGxqcvJaLQali+WuypQnXwcplhtNtjs66eNsZpp2P2FL/trJJxx/VWsM0YCL2iMoIJrbXje48lvIQAQ4p2ZA==", - "dev": true - }, - "bl": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/bl/-/bl-3.0.1.tgz", - "integrity": "sha512-jrCW5ZhfQ/Vt07WX1Ngs+yn9BDqPL/gw28S7s9H6QK/gupnizNzJAss5akW20ISgOrbLTlXOOCTJeNUQqruAWQ==", - "dev": true, - "requires": { - "readable-stream": "^3.0.1" - } - }, - "depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", - "dev": true - }, - "iconv-lite": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.5.2.tgz", - "integrity": "sha512-kERHXvpSaB4aU3eANwidg79K8FlrN77m8G9V+0vOR3HYaRifrlwMEpT7ZBJqLSEIHnEgJTHcWK82wwLwwKwtag==", - "dev": true, - "requires": { - "safer-buffer": ">= 2.1.2 < 3" - } - }, - "readable-stream": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz", - "integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==", - "dev": true, - "requires": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - } - }, - "safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "dev": true - }, - "string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "dev": true, - "requires": { - "safe-buffer": "~5.2.0" - } - } - } - }, - "test-console": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/test-console/-/test-console-2.0.0.tgz", - "integrity": "sha512-ciILzfCQCny8zy1+HEw2yBLKus7LNMsAHymsp2fhvGTVh5pWE5v2EB7V+5ag3WM9aO2ULtgsXVQePWYE+fb7pA==", - "dev": true - }, - "text-hex": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz", - "integrity": "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==", - "dev": true - }, - "thenify": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", - "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", - "dev": true, - "requires": { - "any-promise": "^1.0.0" - } - }, - "thenify-all": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", - "integrity": "sha1-GhkY1ALY/D+Y+/I02wvMjMEOlyY=", - "dev": true, - "requires": { - "thenify": ">= 3.1.0 < 4" - } - }, - "through": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", - "integrity": "sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU=", - "dev": true - }, - "through2": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/through2/-/through2-2.0.5.tgz", - "integrity": "sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==", - "dev": true, - "requires": { - "readable-stream": "~2.3.6", - "xtend": "~4.0.1" - }, - "dependencies": { - "isarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=", - "dev": true - }, - "readable-stream": { - "version": "2.3.7", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", - "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", - "dev": true, - "requires": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" - } - }, - "string_decoder": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", - "dev": true, - "requires": { - "safe-buffer": "~5.1.0" - } - } - } - }, - "through2-filter": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/through2-filter/-/through2-filter-3.0.0.tgz", - "integrity": "sha512-jaRjI2WxN3W1V8/FMZ9HKIBXixtiqs3SQSX4/YGIiP3gL6djW48VoZq9tDqeCWs3MT8YY5wb/zli8VW8snY1CA==", - "dev": true, - "requires": { - "through2": "~2.0.0", - "xtend": "~4.0.0" - } - }, - "throwback": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/throwback/-/throwback-4.1.0.tgz", - "integrity": "sha512-dLFe8bU8SeH0xeqeKL7BNo8XoPC/o91nz9/ooeplZPiso+DZukhoyZcSz9TFnUNScm+cA9qjU1m1853M6sPOng==", - "dev": true - }, - "timeago.js": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/timeago.js/-/timeago.js-4.0.2.tgz", - "integrity": "sha512-a7wPxPdVlQL7lqvitHGGRsofhdwtkoSXPGATFuSOA2i1ZNQEPLrGnj68vOp2sOJTCFAQVXPeNMX/GctBaO9L2w==", - "dev": true - }, - "tlds": { - "version": "1.224.0", - "resolved": "https://registry.npmjs.org/tlds/-/tlds-1.224.0.tgz", - "integrity": "sha512-Jgdc8SEijbDFUsmCn6Wk/f7E6jBLFZOG3U1xK0amGSfEH55Xx97ItUS/d2NngsuApjn11UeWCWj8Um3VRhseZQ==", - "dev": true - }, - "tmp": { - "version": "0.0.33", - "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", - "integrity": "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==", - "dev": true, - "requires": { - "os-tmpdir": "~1.0.2" - } - }, - "tmp-promise": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/tmp-promise/-/tmp-promise-3.0.2.tgz", - "integrity": "sha512-OyCLAKU1HzBjL6Ev3gxUeraJNlbNingmi8IrHHEsYH8LTmEuhvYfqvhn2F/je+mjf4N58UmZ96OMEy1JanSCpA==", - "dev": true, - "requires": { - "tmp": "^0.2.0" - }, - "dependencies": { - "tmp": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.1.tgz", - "integrity": "sha512-76SUhtfqR2Ijn+xllcI5P1oyannHNHByD80W1q447gU3mp9G9PSpGdWmjUOHRDPiHYacIk66W7ubDTuPF3BEtQ==", - "dev": true, - "requires": { - "rimraf": "^3.0.0" - } - } - } - }, - "to-absolute-glob": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/to-absolute-glob/-/to-absolute-glob-2.0.2.tgz", - "integrity": "sha1-GGX0PZ50sIItufFFt4z/fQ98hJs=", - "dev": true, - "requires": { - "is-absolute": "^1.0.0", - "is-negated-glob": "^1.0.0" - } - }, - "to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, - "requires": { - "is-number": "^7.0.0" - } - }, - "toidentifier": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.0.tgz", - "integrity": "sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw==", - "dev": true - }, - "token-types": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/token-types/-/token-types-2.1.1.tgz", - "integrity": "sha512-wnQcqlreS6VjthyHO3Y/kpK/emflxDBNhlNUPfh7wE39KnuDdOituXomIbyI79vBtF0Ninpkh72mcuRHo+RG3Q==", - "dev": true, - "requires": { - "@tokenizer/token": "^0.1.1", - "ieee754": "^1.2.1" - }, - "dependencies": { - "@tokenizer/token": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.1.1.tgz", - "integrity": "sha512-XO6INPbZCxdprl+9qa/AAbFFOMzzwqYxpjPgLICrMD6C2FCw6qfJOPcBk6JqqPLSaZ/Qx87qn4rpPmPMwaAK6w==", - "dev": true - } - } - }, - "toposort": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/toposort/-/toposort-2.0.2.tgz", - "integrity": "sha1-riF2gXXRVZ1IvvNUILL0li8JwzA=", - "dev": true - }, - "tough-cookie": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-3.0.1.tgz", - "integrity": "sha512-yQyJ0u4pZsv9D4clxO69OEjLWYw+jbgspjTue4lTQZLfV0c5l1VmK2y1JK8E9ahdpltPOaAThPcp5nKPUgSnsg==", - "dev": true, - "requires": { - "ip-regex": "^2.1.0", - "psl": "^1.1.28", - "punycode": "^2.1.1" - } - }, - "tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha1-gYT9NH2snNwYWZLzpmIuFLnZq2o=", - "dev": true - }, - "triple-beam": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/triple-beam/-/triple-beam-1.3.0.tgz", - "integrity": "sha512-XrHUvV5HpdLmIj4uVMxHggLbFSZYIn7HEWsqePZcI50pco+MPqJ50wMGY794X7AOOhxOBAjbkqfAbEe/QMp2Lw==", - "dev": true - }, - "ts-expect": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/ts-expect/-/ts-expect-1.3.0.tgz", - "integrity": "sha512-e4g0EJtAjk64xgnFPD6kTBUtpnMVzDrMb12N1YZV0VvSlhnVT3SGxiYTLdGy8Q5cYHOIC/FAHmZ10eGrAguicQ==", - "dev": true - }, - "ts-node": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.4.0.tgz", - "integrity": "sha512-g0FlPvvCXSIO1JDF6S232P5jPYqBkRL9qly81ZgAOSU7rwI0stphCgd2kLiCrU9DjQCrJMWEqcNSjQL02s6d8A==", - "dev": true, - "requires": { - "@cspotcode/source-map-support": "0.7.0", - "@tsconfig/node10": "^1.0.7", - "@tsconfig/node12": "^1.0.7", - "@tsconfig/node14": "^1.0.0", - "@tsconfig/node16": "^1.0.2", - "acorn": "^8.4.1", - "acorn-walk": "^8.1.1", - "arg": "^4.1.0", - "create-require": "^1.1.0", - "diff": "^4.0.1", - "make-error": "^1.1.1", - "yn": "3.1.1" - } - }, - "tslib": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", - "dev": true - }, - "tsscmp": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/tsscmp/-/tsscmp-1.0.6.tgz", - "integrity": "sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==", - "dev": true - }, - "tunnel": { - "version": "0.0.6", - "resolved": "https://registry.npmjs.org/tunnel/-/tunnel-0.0.6.tgz", - "integrity": "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==", - "dev": true - }, - "tunnel-agent": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", - "integrity": "sha1-J6XeoGs2sEoKmWZ3SykIaPD8QP0=", - "dev": true, - "requires": { - "safe-buffer": "^5.0.1" - } - }, - "tweetnacl": { - "version": "0.14.5", - "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", - "integrity": "sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q=", - "dev": true - }, - "type-fest": { - "version": "0.21.3", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", - "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", - "dev": true - }, - "type-is": { - "version": "1.6.18", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", - "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", - "dev": true, - "requires": { - "media-typer": "0.3.0", - "mime-types": "~2.1.24" - } - }, - "typedarray": { - "version": "0.0.6", - "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", - "integrity": "sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c=", - "dev": true - }, - "typedarray-to-buffer": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz", - "integrity": "sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==", - "dev": true, - "requires": { - "is-typedarray": "^1.0.0" - } - }, - "typeorm": { - "version": "0.2.38", - "resolved": "https://registry.npmjs.org/typeorm/-/typeorm-0.2.38.tgz", - "integrity": "sha512-M6Y3KQcAREQcphOVJciywf4mv6+A0I/SeR+lWNjKsjnQ+a3XcMwGYMGL0Jonsx3H0Cqlf/3yYqVki1jIXSK/xg==", - "dev": true, - "requires": { - "@sqltools/formatter": "^1.2.2", - "app-root-path": "^3.0.0", - "buffer": "^6.0.3", - "chalk": "^4.1.0", - "cli-highlight": "^2.1.11", - "debug": "^4.3.1", - "dotenv": "^8.2.0", - "glob": "^7.1.6", - "js-yaml": "^4.0.0", - "mkdirp": "^1.0.4", - "reflect-metadata": "^0.1.13", - "sha.js": "^2.4.11", - "tslib": "^2.1.0", - "xml2js": "^0.4.23", - "yargonaut": "^1.1.4", - "yargs": "^17.0.1", - "zen-observable-ts": "^1.0.0" - }, - "dependencies": { - "ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "requires": { - "color-convert": "^2.0.1" - } - }, - "buffer": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", - "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", - "dev": true, - "requires": { - "base64-js": "^1.3.1", - "ieee754": "^1.2.1" - } - }, - "chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "requires": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - } - }, - "color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "requires": { - "color-name": "~1.1.4" - } - }, - "color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true - }, - "supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "requires": { - "has-flag": "^4.0.0" - } - }, - "tslib": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz", - "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw==", - "dev": true - } - } - }, - "typescript": { - "version": "3.9.10", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-3.9.10.tgz", - "integrity": "sha512-w6fIxVE/H1PkLKcCPsFqKE7Kv7QUwhU8qQY2MueZXWx5cPZdwFupLgKK3vntcK98BtNHZtAF4LA/yl2a7k8R6Q==" - }, - "uc.micro": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-1.0.6.tgz", - "integrity": "sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA==", - "dev": true - }, - "uid-safe": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz", - "integrity": "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==", - "dev": true, - "requires": { - "random-bytes": "~1.0.0" - } - }, - "unbox-primitive": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.1.tgz", - "integrity": "sha512-tZU/3NqK3dA5gpE1KtyiJUrEB0lxnGkMFHptJ7q6ewdZ8s12QrODwNbhIJStmJkd1QDXa1NRA8aF2A1zk/Ypyw==", - "dev": true, - "requires": { - "function-bind": "^1.1.1", - "has-bigints": "^1.0.1", - "has-symbols": "^1.0.2", - "which-boxed-primitive": "^1.0.2" - } - }, - "unc-path-regex": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/unc-path-regex/-/unc-path-regex-0.1.2.tgz", - "integrity": "sha1-5z3T17DXxe2G+6xrCufYxqadUPo=", - "dev": true - }, - "underscore": { - "version": "1.13.1", - "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.1.tgz", - "integrity": "sha512-hzSoAVtJF+3ZtiFX0VgfFPHEDRm7Y/QPjGyNo4TVdnDTdft3tr8hEkD25a1jC+TjTuE7tkHGKkhwCgs9dgBB2g==", - "dev": true - }, - "unique-filename": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-1.1.1.tgz", - "integrity": "sha512-Vmp0jIp2ln35UTXuryvjzkjGdRyf9b2lTXuSYUiPmzRcl3FDtYqAwOnTJkAngD9SWhnoJzDbTKwaOrZ+STtxNQ==", - "dev": true, - "optional": true, - "requires": { - "unique-slug": "^2.0.0" - } - }, - "unique-slug": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-2.0.2.tgz", - "integrity": "sha512-zoWr9ObaxALD3DOPfjPSqxt4fnZiWblxHIgeWqW8x7UqDzEtHEQLzji2cuJYQFCU6KmoJikOYAZlrTHHebjx2w==", - "dev": true, - "optional": true, - "requires": { - "imurmurhash": "^0.1.4" - } - }, - "unique-stream": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/unique-stream/-/unique-stream-2.3.1.tgz", - "integrity": "sha512-2nY4TnBE70yoxHkDli7DMazpWiP7xMdCYqU2nBRO0UB+ZpEkGsSija7MvmvnZFUeC+mrgiUfcHSr3LmRFIg4+A==", - "dev": true, - "requires": { - "json-stable-stringify-without-jsonify": "^1.0.1", - "through2-filter": "^3.0.0" - } - }, - "universalify": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", - "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", - "dev": true - }, - "unpipe": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", - "integrity": "sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw=", - "dev": true - }, - "upper-case": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/upper-case/-/upper-case-2.0.2.tgz", - "integrity": "sha512-KgdgDGJt2TpuwBUIjgG6lzw2GWFRCW9Qkfkiv0DxqHHLYJHmtmdUIKcZd8rHgFSjopVTlw6ggzCm1b8MFQwikg==", - "dev": true, - "requires": { - "tslib": "^2.0.3" - }, - "dependencies": { - "tslib": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz", - "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw==", - "dev": true - } - } - }, - "upper-case-first": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/upper-case-first/-/upper-case-first-2.0.2.tgz", - "integrity": "sha512-514ppYHBaKwfJRK/pNC6c/OxfGa0obSnAl106u97Ed0I625Nin96KAjttZF6ZL3e1XLtphxnqrOi9iWgm+u+bg==", - "dev": true, - "requires": { - "tslib": "^2.0.3" - }, - "dependencies": { - "tslib": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz", - "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw==", - "dev": true - } - } - }, - "uri-js": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dev": true, - "requires": { - "punycode": "^2.1.0" - } - }, - "url": { - "version": "0.10.3", - "resolved": "https://registry.npmjs.org/url/-/url-0.10.3.tgz", - "integrity": "sha1-Ah5NnHcF8hu/N9A861h2dAJ3TGQ=", - "dev": true, - "requires": { - "punycode": "1.3.2", - "querystring": "0.2.0" - }, - "dependencies": { - "punycode": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.3.2.tgz", - "integrity": "sha1-llOgNvt8HuQjQvIyXM7v6jkmxI0=", - "dev": true - } - } - }, - "url-parse": { - "version": "1.5.10", - "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", - "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", - "dev": true, - "requires": { - "querystringify": "^2.1.1", - "requires-port": "^1.0.0" - } - }, - "utf7": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/utf7/-/utf7-1.0.2.tgz", - "integrity": "sha1-lV9JCq5lO6IguUVqCod2wZk2CZE=", - "dev": true, - "requires": { - "semver": "~5.3.0" - }, - "dependencies": { - "semver": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-5.3.0.tgz", - "integrity": "sha1-myzl094C0XxgEq0yaqa00M9U+U8=", - "dev": true - } - } - }, - "utf8": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/utf8/-/utf8-2.1.2.tgz", - "integrity": "sha1-H6DZJw6b6FDZsFAn9jUZv0ZFfZY=", - "dev": true - }, - "util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=" - }, - "util.promisify": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/util.promisify/-/util.promisify-1.1.1.tgz", - "integrity": "sha512-/s3UsZUrIfa6xDhr7zZhnE9SLQ5RIXyYfiVnMMyMDzOc8WhWN4Nbh36H842OyurKbCDAesZOJaVyvmSl6fhGQw==", - "dev": true, - "requires": { - "call-bind": "^1.0.0", - "define-properties": "^1.1.3", - "for-each": "^0.3.3", - "has-symbols": "^1.0.1", - "object.getownpropertydescriptors": "^2.1.1" - } - }, - "utils-merge": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", - "integrity": "sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM=", - "dev": true - }, - "uuencode": { - "version": "0.0.4", - "resolved": "https://registry.npmjs.org/uuencode/-/uuencode-0.0.4.tgz", - "integrity": "sha1-yNUDcIhWY4eThas34zPH6OOwIYw=", - "dev": true - }, - "uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", - "dev": true - }, - "v-click-outside": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/v-click-outside/-/v-click-outside-3.1.2.tgz", - "integrity": "sha512-gMdRqfRE6m6XU6SiFi3dyBlFB2MWogiXpof8Aa3LQysrl9pzTndqp/iEaAphLoadaQUFnQ0ec6fLLaxr7LiY6A==", - "dev": true - }, - "validate-npm-package-license": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", - "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", - "dev": true, - "requires": { - "spdx-correct": "^3.0.0", - "spdx-expression-parse": "^3.0.0" - } - }, - "validator": { - "version": "13.7.0", - "resolved": "https://registry.npmjs.org/validator/-/validator-13.7.0.tgz", - "integrity": "sha512-nYXQLCBkpJ8X6ltALua9dRrZDHVYxjJ1wgskNt1lH9fzGjs3tgojGSCBjmEPwkWS1y29+DrizMTW19Pr9uB2nw==", - "dev": true - }, - "vary": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", - "integrity": "sha1-IpnwLG3tMNSllhsLn3RSShj2NPw=", - "dev": true - }, - "verror": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz", - "integrity": "sha1-OhBcoXBTr1XW4nDB+CiGguGNpAA=", - "dev": true, - "requires": { - "assert-plus": "^1.0.0", - "core-util-is": "1.0.2", - "extsprintf": "^1.2.0" - } - }, - "vue-fragment": { - "version": "1.5.2", - "resolved": "https://registry.npmjs.org/vue-fragment/-/vue-fragment-1.5.2.tgz", - "integrity": "sha512-KEW0gkeNOLJjtXN4jqJhTazez5jtrwimHkE5Few/VxblH4F9EcvJiEsahrV5kg5uKd5U8du4ORKS6QjGE0piYA==", - "dev": true - }, - "webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha1-JFNCdeKnvGvnvIZhHMFq4KVlSHE=", - "dev": true - }, - "whatwg-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha1-lmRU6HZUYuN2RNNib2dCzotwll0=", - "dev": true, - "requires": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" - } - }, - "which": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", - "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", - "dev": true, - "requires": { - "isexe": "^2.0.0" - } - }, - "which-boxed-primitive": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz", - "integrity": "sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg==", - "dev": true, - "requires": { - "is-bigint": "^1.0.1", - "is-boolean-object": "^1.1.0", - "is-number-object": "^1.0.4", - "is-string": "^1.0.5", - "is-symbol": "^1.0.3" - } - }, - "wide-align": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", - "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==", - "dev": true, - "requires": { - "string-width": "^1.0.2 || 2 || 3 || 4" - } - }, - "widest-line": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-3.1.0.tgz", - "integrity": "sha512-NsmoXalsWVDMGupxZ5R08ka9flZjjiLvHVAWYOKtiKM8ujtZWr9cRffak+uSE48+Ob8ObalXpwyeUiyDD6QFgg==", - "dev": true, - "requires": { - "string-width": "^4.0.0" - } - }, - "winston": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/winston/-/winston-3.3.3.tgz", - "integrity": "sha512-oEXTISQnC8VlSAKf1KYSSd7J6IWuRPQqDdo8eoRNaYKLvwSb5+79Z3Yi1lrl6KDpU6/VWaxpakDAtb1oQ4n9aw==", - "dev": true, - "requires": { - "@dabh/diagnostics": "^2.0.2", - "async": "^3.1.0", - "is-stream": "^2.0.0", - "logform": "^2.2.0", - "one-time": "^1.0.0", - "readable-stream": "^3.4.0", - "stack-trace": "0.0.x", - "triple-beam": "^1.3.0", - "winston-transport": "^4.4.0" - }, - "dependencies": { - "async": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/async/-/async-3.2.1.tgz", - "integrity": "sha512-XdD5lRO/87udXCMC9meWdYiR+Nq6ZjUfXidViUZGu2F1MO4T3XwZ1et0hb2++BgLfhyJwy44BGB/yx80ABx8hg==", - "dev": true - }, - "readable-stream": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz", - "integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==", - "dev": true, - "requires": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - } - }, - "safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "dev": true - }, - "string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "dev": true, - "requires": { - "safe-buffer": "~5.2.0" - } - } - } - }, - "winston-transport": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/winston-transport/-/winston-transport-4.4.0.tgz", - "integrity": "sha512-Lc7/p3GtqtqPBYYtS6KCN3c77/2QCev51DvcJKbkFPQNoj1sinkGwLGFDxkXY9J6p9+EPnYs+D90uwbnaiURTw==", - "dev": true, - "requires": { - "readable-stream": "^2.3.7", - "triple-beam": "^1.2.0" - }, - "dependencies": { - "isarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=", - "dev": true - }, - "readable-stream": { - "version": "2.3.7", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", - "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", - "dev": true, - "requires": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" - } - }, - "string_decoder": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", - "dev": true, - "requires": { - "safe-buffer": "~5.1.0" - } - } - } - }, - "wmf": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz", - "integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==", - "dev": true - }, - "word": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz", - "integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==", - "dev": true - }, - "wordwrap": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", - "integrity": "sha1-J1hIEIkUVqQXHI0CJkQa3pDLyus=", - "dev": true - }, - "wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "requires": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "dependencies": { - "ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "requires": { - "color-convert": "^2.0.1" - } - }, - "color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "requires": { - "color-name": "~1.1.4" - } - }, - "color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - } - } - }, - "wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", - "dev": true - }, - "ws": { - "version": "7.5.5", - "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.5.tgz", - "integrity": "sha512-BAkMFcAzl8as1G/hArkxOxq3G7pjUqQ3gzYbLL0/5zNkph70e+lCoxBGnm6AW1+/aiNeV4fnKqZ8m4GZewmH2w==", - "dev": true - }, - "xlsx": { - "version": "0.17.3", - "resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.17.3.tgz", - "integrity": "sha512-dGZKfyPSXfnoITruwisuDVZkvnxhjgqzWJXBJm2Khmh01wcw8//baRUvhroVRhW2SLbnlpGcCZZbeZO1qJgMIw==", - "dev": true, - "requires": { - "adler-32": "~1.2.0", - "cfb": "^1.1.4", - "codepage": "~1.15.0", - "commander": "~2.17.1", - "crc-32": "~1.2.0", - "exit-on-epipe": "~1.0.1", - "fflate": "^0.7.1", - "ssf": "~0.11.2", - "wmf": "~1.0.1", - "word": "~0.3.0" - }, - "dependencies": { - "commander": { - "version": "2.17.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.17.1.tgz", - "integrity": "sha512-wPMUt6FnH2yzG95SA6mzjQOEKUU3aLaDEmzs1ti+1E9h+CsrZghRlqEM/EJ4KscsQVG8uNN4uVreUeT8+drlgg==", - "dev": true - } - } - }, - "xml2js": { - "version": "0.4.23", - "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.23.tgz", - "integrity": "sha512-ySPiMjM0+pLDftHgXY4By0uswI3SPKLDw/i3UXbnO8M/p28zqexCUoPmQFrYD+/1BzhGJSs2i1ERWKJAtiLrug==", - "dev": true, - "requires": { - "sax": ">=0.6.0", - "xmlbuilder": "~11.0.0" - } - }, - "xmlbuilder": { - "version": "11.0.1", - "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", - "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", - "dev": true - }, - "xpath.js": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/xpath.js/-/xpath.js-1.1.0.tgz", - "integrity": "sha512-jg+qkfS4K8E7965sqaUl8mRngXiKb3WZGfONgE18pr03FUQiuSV6G+Ej4tS55B+rIQSFEIw3phdVAQ4pPqNWfQ==", - "dev": true - }, - "xregexp": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/xregexp/-/xregexp-2.0.0.tgz", - "integrity": "sha1-UqY+VsoLhKfzpfPWGHLxJq16WUM=", - "dev": true - }, - "xtend": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", - "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==" - }, - "y18n": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", - "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "dev": true - }, - "yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true - }, - "yargonaut": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/yargonaut/-/yargonaut-1.1.4.tgz", - "integrity": "sha512-rHgFmbgXAAzl+1nngqOcwEljqHGG9uUZoPjsdZEs1w5JW9RXYzrSvH/u70C1JE5qFi0qjsdhnUX/dJRpWqitSA==", - "dev": true, - "requires": { - "chalk": "^1.1.1", - "figlet": "^1.1.1", - "parent-require": "^1.0.0" - }, - "dependencies": { - "ansi-regex": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", - "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=", - "dev": true - }, - "ansi-styles": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz", - "integrity": "sha1-tDLdM1i2NM914eRmQ2gkBTPB3b4=", - "dev": true - }, - "chalk": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz", - "integrity": "sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=", - "dev": true, - "requires": { - "ansi-styles": "^2.2.1", - "escape-string-regexp": "^1.0.2", - "has-ansi": "^2.0.0", - "strip-ansi": "^3.0.0", - "supports-color": "^2.0.0" - } - }, - "strip-ansi": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", - "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=", - "dev": true, - "requires": { - "ansi-regex": "^2.0.0" - } - }, - "supports-color": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz", - "integrity": "sha1-U10EXOa2Nj+kARcIRimZXp3zJMc=", - "dev": true - } - } - }, - "yargs": { - "version": "17.1.1", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.1.1.tgz", - "integrity": "sha512-c2k48R0PwKIqKhPMWjeiF6y2xY/gPMUlro0sgxqXpbOIohWiLNXWslsootttv7E1e73QPAMQSg5FeySbVcpsPQ==", - "dev": true, - "requires": { - "cliui": "^7.0.2", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.0", - "y18n": "^5.0.5", - "yargs-parser": "^20.2.2" - } - }, - "yargs-parser": { - "version": "20.2.9", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", - "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", - "dev": true - }, - "yn": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", - "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", - "dev": true - }, - "yup": { - "version": "0.32.11", - "resolved": "https://registry.npmjs.org/yup/-/yup-0.32.11.tgz", - "integrity": "sha512-Z2Fe1bn+eLstG8DRR6FTavGD+MeAwyfmouhHsIUgaADz8jvFKbO/fXc2trJKZg+5EBjh4gGm3iU/t3onKlXHIg==", - "dev": true, - "requires": { - "@babel/runtime": "^7.15.4", - "@types/lodash": "^4.14.175", - "lodash": "^4.17.21", - "lodash-es": "^4.17.21", - "nanoclone": "^0.2.1", - "property-expr": "^2.0.4", - "toposort": "^2.0.2" - } - }, - "zen-observable": { - "version": "0.8.15", - "resolved": "https://registry.npmjs.org/zen-observable/-/zen-observable-0.8.15.tgz", - "integrity": "sha512-PQ2PC7R9rslx84ndNBZB/Dkv8V8fZEpk83RLgXtYd0fwUgEjseMn1Dgajh2x6S8QbZAFa9p2qVCEuYZNgve0dQ==", - "dev": true - }, - "zen-observable-ts": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/zen-observable-ts/-/zen-observable-ts-1.1.0.tgz", - "integrity": "sha512-1h4zlLSqI2cRLPJUHJFL8bCWHhkpuXkF+dbGkRaWjgDIG26DmzyshUMrdV/rL3UnR+mhaX4fRq8LPouq0MYYIA==", - "dev": true, - "requires": { - "@types/zen-observable": "0.8.3", - "zen-observable": "0.8.15" - } - } - } -} diff --git a/n8n/package.json b/n8n/package.json deleted file mode 100644 index 7dd8a499a3..0000000000 --- a/n8n/package.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "name": "sims-n8n", - "version": "0.0.0", - "description": "N8N for SIMS", - "license": "Apache-2.0", - "repository": { - "type": "git", - "url": "https://github.com/bcgov/biohubbc.git" - }, - "engines": { - "node": ">= 14.15.0", - "npm": ">= 6.14.8" - }, - "scripts": { - "export": "npm-run-all -l -s export-credentials export-workflows", - "export-workflows": "n8n export:workflow --backup --output=workflows/", - "export-credentials": "n8n export:credentials --backup --output=credentials/", - "import": "npm-run-all -l -s import-credentials import-workflows", - "import-workflows": "n8n import:workflow --separate --input=workflows/", - "import-credentials": "n8n import:credentials --separate --input=credentials/" - }, - "dependencies": { - "pg": "~8.3.0", - "typescript": "~3.9.4" - }, - "devDependencies": { - "@types/node": "~14.14.31", - "@types/pg": "~7.14.4", - "npm-run-all": "~4.1.5", - "n8n": "0.144.0", - "ts-node": "~10.4.0" - } -} diff --git a/n8n/workflows/1.json b/n8n/workflows/1.json deleted file mode 100644 index f99b9aaee9..0000000000 --- a/n8n/workflows/1.json +++ /dev/null @@ -1,235 +0,0 @@ -{ - "id": 1, - "name": "Submission Validation", - "active": true, - "nodes": [ - { - "parameters": {}, - "name": "Start", - "type": "n8n-nodes-base.start", - "typeVersion": 1, - "position": [ - 930, - 550 - ] - }, - { - "parameters": { - "authentication": "headerAuth", - "requestMethod": "POST", - "url": "=http://{{$node[\"Get N8N API Host\"].json[\"stdout\"]}}:{{$node[\"Get N8N API Port\"].json[\"stdout\"]}}/api/dwc/validate", - "responseFormat": "string", - "jsonParameters": true, - "options": {}, - "bodyParametersJson": "={\n\"project_id\": {{$node[\"Webhook\"].json[\"body\"][\"project_id\"]}},\n\"occurrence_submission_id\": {{$node[\"Webhook\"].json[\"body\"][\"occurrence_submission_id\"]}}\n}" - }, - "name": "DWC Validate", - "type": "n8n-nodes-base.httpRequest", - "typeVersion": 1, - "position": [ - 1750, - 630 - ], - "credentials": { - "httpHeaderAuth": { - "id": "1", - "name": "Bearer Token" - } - } - }, - { - "parameters": { - "httpMethod": "POST", - "path": "validate", - "options": { - "responseHeaders": { - "entries": [ - { - "name": "Access-Control-Allow-Origin", - "value": "*" - }, - { - "name": "Access-Control-Allow-Methods", - "value": "GET, POST, OPTIONS, HEAD" - }, - { - "name": "Access-Control-Allow-Headers", - "value": "Authorization, Origin, X-Requested-With, Content-Type, Accept, sessionid, responseType" - } - ] - }, - "rawBody": true - } - }, - "name": "Webhook", - "type": "n8n-nodes-base.webhook", - "typeVersion": 1, - "position": [ - 930, - 750 - ], - "webhookId": "a346c2c5-d43e-4bc8-8dd1-dbcee88e1638" - }, - { - "parameters": { - "authentication": "headerAuth", - "requestMethod": "POST", - "url": "=http://{{$node[\"Get N8N API Host\"].json[\"stdout\"]}}:{{$node[\"Get N8N API Port\"].json[\"stdout\"]}}/api/xlsx/validate", - "responseFormat": "string", - "jsonParameters": true, - "options": {}, - "bodyParametersJson": "={\n\"project_id\": {{$node[\"Webhook\"].json[\"body\"][\"project_id\"]}},\n\"occurrence_submission_id\": {{$node[\"Webhook\"].json[\"body\"][\"occurrence_submission_id\"]}}\n}" - }, - "name": "XLSX Validate", - "type": "n8n-nodes-base.httpRequest", - "typeVersion": 1, - "position": [ - 1750, - 850 - ], - "credentials": { - "httpHeaderAuth": { - "id": "1", - "name": "Bearer Token" - } - } - }, - { - "parameters": { - "command": "echo ${N8N_API_HOST}" - }, - "name": "Get N8N API Host", - "type": "n8n-nodes-base.executeCommand", - "typeVersion": 1, - "position": [ - 1120, - 750 - ] - }, - { - "parameters": { - "command": "echo ${N8N_API_PORT}" - }, - "name": "Get N8N API Port", - "type": "n8n-nodes-base.executeCommand", - "typeVersion": 1, - "position": [ - 1320, - 750 - ] - }, - { - "parameters": {}, - "name": "Complete", - "type": "n8n-nodes-base.noOp", - "typeVersion": 1, - "position": [ - 1970, - 750 - ] - }, - { - "parameters": { - "conditions": { - "boolean": [ - { - "value1": "={{$node[\"Webhook\"].json[\"body\"][\"file_type\"]}}", - "value2": "=application/x-zip-compressed" - }, - { - "value1": "={{$node[\"Webhook\"].json[\"body\"][\"file_type\"]}}", - "value2": "=application/zip" - } - ] - }, - "combineOperation": "any" - }, - "name": "If Zip", - "type": "n8n-nodes-base.if", - "typeVersion": 1, - "position": [ - 1520, - 750 - ] - } - ], - "connections": { - "Webhook": { - "main": [ - [ - { - "node": "Get N8N API Host", - "type": "main", - "index": 0 - } - ] - ] - }, - "Get N8N API Host": { - "main": [ - [ - { - "node": "Get N8N API Port", - "type": "main", - "index": 0 - } - ] - ] - }, - "Get N8N API Port": { - "main": [ - [ - { - "node": "If Zip", - "type": "main", - "index": 0 - } - ] - ] - }, - "DWC Validate": { - "main": [ - [ - { - "node": "Complete", - "type": "main", - "index": 0 - } - ] - ] - }, - "XLSX Validate": { - "main": [ - [ - { - "node": "Complete", - "type": "main", - "index": 0 - } - ] - ] - }, - "If Zip": { - "main": [ - [ - { - "node": "DWC Validate", - "type": "main", - "index": 0 - } - ], - [ - { - "node": "XLSX Validate", - "type": "main", - "index": 0 - } - ] - ] - } - }, - "createdAt": "2021-08-26T12:19:31.100Z", - "updatedAt": "2021-12-08T23:01:55.035Z", - "settings": {}, - "staticData": null -} \ No newline at end of file diff --git a/n8n/workflows/2.json b/n8n/workflows/2.json deleted file mode 100644 index 2960a7ceb5..0000000000 --- a/n8n/workflows/2.json +++ /dev/null @@ -1,158 +0,0 @@ -{ - "id": 2, - "name": "Scrape Occurrences", - "active": true, - "nodes": [ - { - "parameters": {}, - "name": "Start", - "type": "n8n-nodes-base.start", - "typeVersion": 1, - "position": [ - 940, - 560 - ] - }, - { - "parameters": { - "httpMethod": "POST", - "path": "scrape", - "options": { - "responseHeaders": { - "entries": [ - { - "name": "Access-Control-Allow-Origin", - "value": "*" - }, - { - "name": "Access-Control-Allow-Methods", - "value": "GET, POST, OPTIONS, HEAD" - }, - { - "name": "Access-Control-Allow-Headers", - "value": "Authorization, Origin, X-Requested-With, Content-Type, Accept, sessionid, responseType" - } - ] - }, - "rawBody": true - } - }, - "name": "Webhook", - "type": "n8n-nodes-base.webhook", - "typeVersion": 1, - "position": [ - 940, - 750 - ], - "webhookId": "220c29a6-f471-410b-83e7-575ce0d480cb" - }, - { - "parameters": { - "command": "echo ${N8N_API_HOST}" - }, - "name": "Get N8N API Host", - "type": "n8n-nodes-base.executeCommand", - "typeVersion": 1, - "position": [ - 1120, - 750 - ] - }, - { - "parameters": { - "command": "echo ${N8N_API_PORT}" - }, - "name": "Get N8N API Port", - "type": "n8n-nodes-base.executeCommand", - "typeVersion": 1, - "position": [ - 1320, - 750 - ] - }, - { - "parameters": { - "authentication": "headerAuth", - "requestMethod": "POST", - "url": "=http://{{$node[\"Get N8N API Host\"].json[\"stdout\"]}}:{{$node[\"Get N8N API Port\"].json[\"stdout\"]}}/api/dwc/scrape-occurrences", - "responseFormat": "string", - "jsonParameters": true, - "options": {}, - "bodyParametersJson": "={\n\"project_id\": {{$node[\"Webhook\"].json[\"body\"][\"project_id\"]}},\n\"occurrence_submission_id\": {{$node[\"Webhook\"].json[\"body\"][\"occurrence_submission_id\"]}}\n}" - }, - "name": "Scrape Occurrences", - "type": "n8n-nodes-base.httpRequest", - "typeVersion": 1, - "position": [ - 1510, - 750 - ], - "credentials": { - "httpHeaderAuth": { - "id": "1", - "name": "Bearer Token" - } - } - }, - { - "parameters": {}, - "name": "Complete", - "type": "n8n-nodes-base.noOp", - "typeVersion": 1, - "position": [ - 1690, - 750 - ] - } - ], - "connections": { - "Webhook": { - "main": [ - [ - { - "node": "Get N8N API Host", - "type": "main", - "index": 0 - } - ] - ] - }, - "Get N8N API Host": { - "main": [ - [ - { - "node": "Get N8N API Port", - "type": "main", - "index": 0 - } - ] - ] - }, - "Get N8N API Port": { - "main": [ - [ - { - "node": "Scrape Occurrences", - "type": "main", - "index": 0 - } - ] - ] - }, - "Scrape Occurrences": { - "main": [ - [ - { - "node": "Complete", - "type": "main", - "index": 0 - } - ] - ] - } - }, - "createdAt": "2021-09-23T10:29:03.227Z", - "updatedAt": "2021-12-08T23:02:13.812Z", - "settings": {}, - "staticData": null -} \ No newline at end of file diff --git a/n8n/workflows/3.json b/n8n/workflows/3.json deleted file mode 100644 index e27adee242..0000000000 --- a/n8n/workflows/3.json +++ /dev/null @@ -1,200 +0,0 @@ -{ - "id": 3, - "name": "Transformation", - "active": true, - "nodes": [ - { - "parameters": {}, - "name": "Start", - "type": "n8n-nodes-base.start", - "typeVersion": 1, - "position": [ - 940, - 580 - ] - }, - { - "parameters": { - "httpMethod": "POST", - "path": "transform", - "options": { - "responseHeaders": { - "entries": [ - { - "name": "Access-Control-Allow-Origin", - "value": "*" - }, - { - "name": "Access-Control-Allow-Methods", - "value": "GET, POST, OPTIONS, HEAD" - }, - { - "name": "Access-Control-Allow-Headers", - "value": "Authorization, Origin, X-Requested-With, Content-Type, Accept, sessionid, responseType" - } - ] - }, - "rawBody": true - } - }, - "name": "Webhook", - "type": "n8n-nodes-base.webhook", - "typeVersion": 1, - "position": [ - 940, - 750 - ], - "webhookId": "c14a8c22-a2f4-4e76-aff7-37c74f59d40a" - }, - { - "parameters": { - "conditions": { - "boolean": [ - { - "value1": "={{$node[\"Webhook\"].json[\"body\"][\"file_type\"]}}", - "value2": "=application/x-zip-compressed" - }, - { - "value1": "={{$node[\"Webhook\"].json[\"body\"][\"file_type\"]}}", - "value2": "=application/zip" - } - ] - }, - "combineOperation": "any" - }, - "name": "IF", - "type": "n8n-nodes-base.if", - "typeVersion": 1, - "position": [ - 1520, - 750 - ] - }, - { - "parameters": { - "command": "echo ${N8N_API_HOST}" - }, - "name": "Get N8N API Host", - "type": "n8n-nodes-base.executeCommand", - "typeVersion": 1, - "position": [ - 1120, - 750 - ] - }, - { - "parameters": { - "command": "echo ${N8N_API_PORT}" - }, - "name": "Get N8N API Port", - "type": "n8n-nodes-base.executeCommand", - "typeVersion": 1, - "position": [ - 1320, - 750 - ] - }, - { - "parameters": { - "authentication": "headerAuth", - "requestMethod": "POST", - "url": "=http://{{$node[\"Get N8N API Host\"].json[\"stdout\"]}}:{{$node[\"Get N8N API Port\"].json[\"stdout\"]}}/api/xlsx/transform", - "responseFormat": "string", - "jsonParameters": true, - "options": {}, - "bodyParametersJson": "={\n\"project_id\": {{$node[\"Webhook\"].json[\"body\"][\"project_id\"]}},\n\"occurrence_submission_id\": {{$node[\"Webhook\"].json[\"body\"][\"occurrence_submission_id\"]}}\n}" - }, - "name": "XLSX Transform", - "type": "n8n-nodes-base.httpRequest", - "typeVersion": 1, - "position": [ - 1720, - 840 - ], - "credentials": { - "httpHeaderAuth": { - "id": "1", - "name": "Bearer Token" - } - } - }, - { - "parameters": {}, - "name": "Complete", - "type": "n8n-nodes-base.noOp", - "typeVersion": 1, - "position": [ - 1900, - 730 - ] - } - ], - "connections": { - "Webhook": { - "main": [ - [ - { - "node": "Get N8N API Host", - "type": "main", - "index": 0 - } - ] - ] - }, - "IF": { - "main": [ - [ - { - "node": "Complete", - "type": "main", - "index": 0 - } - ], - [ - { - "node": "XLSX Transform", - "type": "main", - "index": 0 - } - ] - ] - }, - "Get N8N API Host": { - "main": [ - [ - { - "node": "Get N8N API Port", - "type": "main", - "index": 0 - } - ] - ] - }, - "Get N8N API Port": { - "main": [ - [ - { - "node": "IF", - "type": "main", - "index": 0 - } - ] - ] - }, - "XLSX Transform": { - "main": [ - [ - { - "node": "Complete", - "type": "main", - "index": 0 - } - ] - ] - } - }, - "createdAt": "2021-09-23T14:54:04.504Z", - "updatedAt": "2021-12-08T23:02:03.845Z", - "settings": {}, - "staticData": null -} \ No newline at end of file diff --git a/n8n/workflows/4.json b/n8n/workflows/4.json deleted file mode 100644 index df5655d565..0000000000 --- a/n8n/workflows/4.json +++ /dev/null @@ -1,466 +0,0 @@ -{ - "id": 4, - "name": "Process Occurrence Submission", - "active": true, - "nodes": [ - { - "parameters": {}, - "name": "Start", - "type": "n8n-nodes-base.start", - "typeVersion": 1, - "position": [ - 510, - -40 - ] - }, - { - "parameters": { - "httpMethod": "POST", - "path": "process-occurrence-submission", - "options": { - "responseHeaders": { - "entries": [ - { - "name": "Access-Control-Allow-Origin", - "value": "*" - }, - { - "name": "Access-Control-Allow-Methods", - "value": "GET, POST, OPTIONS, HEAD" - }, - { - "name": "Access-Control-Allow-Headers", - "value": "Authorization, Origin, X-Requested-With, Content-Type, Accept, sessionid, responseType" - } - ] - }, - "rawBody": true - } - }, - "name": "Webhook", - "type": "n8n-nodes-base.webhook", - "typeVersion": 1, - "position": [ - 510, - 130 - ], - "webhookId": "a346c2c5-d43e-4bc8-8dd1-dbcee88e1638" - }, - { - "parameters": { - "command": "echo ${N8N_API_HOST}" - }, - "name": "Get N8N API Host", - "type": "n8n-nodes-base.executeCommand", - "typeVersion": 1, - "position": [ - 680, - 130 - ] - }, - { - "parameters": { - "command": "echo ${N8N_API_PORT}" - }, - "name": "Get N8N API Port", - "type": "n8n-nodes-base.executeCommand", - "typeVersion": 1, - "position": [ - 850, - 130 - ] - }, - { - "parameters": { - "authentication": "headerAuth", - "requestMethod": "POST", - "url": "=http://{{$node[\"Get N8N API Host\"].json[\"stdout\"]}}:{{$node[\"Get N8N API Port\"].json[\"stdout\"]}}/api/xlsx/validate", - "responseFormat": "string", - "jsonParameters": true, - "options": {}, - "bodyParametersJson": "={\n\"project_id\": {{$node[\"Webhook\"].json[\"body\"][\"project_id\"]}},\n\"occurrence_submission_id\": {{$node[\"Webhook\"].json[\"body\"][\"occurrence_submission_id\"]}}\n}" - }, - "name": "XLSX Validate", - "type": "n8n-nodes-base.httpRequest", - "typeVersion": 1, - "position": [ - 1210, - 250 - ], - "credentials": { - "httpHeaderAuth": { - "id": "1", - "name": "Bearer Token" - } - } - }, - { - "parameters": { - "authentication": "headerAuth", - "requestMethod": "POST", - "url": "=http://{{$node[\"Get N8N API Host\"].json[\"stdout\"]}}:{{$node[\"Get N8N API Port\"].json[\"stdout\"]}}/api/xlsx/transform", - "responseFormat": "string", - "jsonParameters": true, - "options": {}, - "bodyParametersJson": "={\n\"project_id\": {{$node[\"Webhook\"].json[\"body\"][\"project_id\"]}},\n\"occurrence_submission_id\": {{$node[\"Webhook\"].json[\"body\"][\"occurrence_submission_id\"]}}\n}" - }, - "name": "XLSX Transform", - "type": "n8n-nodes-base.httpRequest", - "typeVersion": 1, - "position": [ - 1620, - 230 - ], - "credentials": { - "httpHeaderAuth": { - "id": "1", - "name": "Bearer Token" - } - } - }, - { - "parameters": { - "authentication": "headerAuth", - "requestMethod": "POST", - "url": "=http://{{$node[\"Get N8N API Host\"].json[\"stdout\"]}}:{{$node[\"Get N8N API Port\"].json[\"stdout\"]}}/api/dwc/scrape-occurrences", - "responseFormat": "string", - "jsonParameters": true, - "options": {}, - "bodyParametersJson": "={\n\"project_id\": {{$node[\"Webhook\"].json[\"body\"][\"project_id\"]}},\n\"occurrence_submission_id\": {{$node[\"Webhook\"].json[\"body\"][\"occurrence_submission_id\"]}}\n}" - }, - "name": "Scrape Occurrences", - "type": "n8n-nodes-base.httpRequest", - "typeVersion": 1, - "position": [ - 2020, - 70 - ], - "credentials": { - "httpHeaderAuth": { - "id": "1", - "name": "Bearer Token" - } - } - }, - { - "parameters": { - "authentication": "headerAuth", - "requestMethod": "POST", - "url": "=http://{{$node[\"Get N8N API Host\"].json[\"stdout\"]}}:{{$node[\"Get N8N API Port\"].json[\"stdout\"]}}/api/dwc/validate", - "responseFormat": "string", - "jsonParameters": true, - "options": {}, - "bodyParametersJson": "={\n\"project_id\": {{$node[\"Webhook\"].json[\"body\"][\"project_id\"]}},\n\"occurrence_submission_id\": {{$node[\"Webhook\"].json[\"body\"][\"occurrence_submission_id\"]}}\n}" - }, - "name": "DWC Validate", - "type": "n8n-nodes-base.httpRequest", - "typeVersion": 1, - "position": [ - 1210, - -20 - ], - "credentials": { - "httpHeaderAuth": { - "id": "1", - "name": "Bearer Token" - } - } - }, - { - "parameters": { - "functionCode": "throw Error(JSON.stringify(items));\n" - }, - "name": "Throw Error", - "type": "n8n-nodes-base.function", - "typeVersion": 1, - "position": [ - 2450, - 300 - ], - "notes": "The previous steps always return a \"200\" (unless a run-time exception occurs) and so the workflow will indicate it succeeded even if the response from one of the steps returns \"status: failed\". This step throws an Error, so that the workflow will be marked as having failed." - }, - { - "parameters": { - "conditions": { - "string": [ - { - "value1": "={{$node[\"DWC Validate\"].json[\"data\"][\"status\"]}}", - "value2": "success" - } - ] - } - }, - "name": "If DWC Validate Success", - "type": "n8n-nodes-base.if", - "typeVersion": 1, - "position": [ - 1410, - -20 - ] - }, - { - "parameters": { - "conditions": { - "string": [ - { - "value1": "={{$node[\"XLSX Validate\"].json[\"data\"][\"status\"]}}", - "value2": "success" - } - ] - } - }, - "name": "If XLSX Validate Success", - "type": "n8n-nodes-base.if", - "typeVersion": 1, - "position": [ - 1410, - 250 - ] - }, - { - "parameters": { - "conditions": { - "string": [ - { - "value1": "={{$node[\"XLSX Transform\"].json[\"data\"][\"status\"]}}", - "value2": "success" - } - ] - } - }, - "name": "If XLSX Transform Success", - "type": "n8n-nodes-base.if", - "typeVersion": 1, - "position": [ - 1820, - 230 - ] - }, - { - "parameters": { - "conditions": { - "string": [ - { - "value1": "={{$node[\"Scrape Occurrences\"].json[\"data\"][\"status\"]}}", - "value2": "success" - } - ] - } - }, - "name": "If Scrape Occurrences Success", - "type": "n8n-nodes-base.if", - "typeVersion": 1, - "position": [ - 2230, - 70 - ] - }, - { - "parameters": {}, - "name": "Complete", - "type": "n8n-nodes-base.noOp", - "typeVersion": 1, - "position": [ - 2450, - 50 - ] - }, - { - "parameters": { - "conditions": { - "boolean": [ - { - "value1": "={{$node[\"Webhook\"].json[\"body\"][\"file_type\"]}}", - "value2": "=application/x-zip-compressed" - }, - { - "value1": "={{$node[\"Webhook\"].json[\"body\"][\"file_type\"]}}", - "value2": "=application/zip" - } - ] - }, - "combineOperation": "any" - }, - "name": "If Zip", - "type": "n8n-nodes-base.if", - "typeVersion": 1, - "position": [ - 1020, - 130 - ] - } - ], - "connections": { - "Webhook": { - "main": [ - [ - { - "node": "Get N8N API Host", - "type": "main", - "index": 0 - } - ] - ] - }, - "Get N8N API Host": { - "main": [ - [ - { - "node": "Get N8N API Port", - "type": "main", - "index": 0 - } - ] - ] - }, - "Get N8N API Port": { - "main": [ - [ - { - "node": "If Zip", - "type": "main", - "index": 0 - } - ] - ] - }, - "XLSX Validate": { - "main": [ - [ - { - "node": "If XLSX Validate Success", - "type": "main", - "index": 0 - } - ] - ] - }, - "DWC Validate": { - "main": [ - [ - { - "node": "If DWC Validate Success", - "type": "main", - "index": 0 - } - ] - ] - }, - "XLSX Transform": { - "main": [ - [ - { - "node": "If XLSX Transform Success", - "type": "main", - "index": 0 - } - ] - ] - }, - "Scrape Occurrences": { - "main": [ - [ - { - "node": "If Scrape Occurrences Success", - "type": "main", - "index": 0 - } - ] - ] - }, - "If DWC Validate Success": { - "main": [ - [ - { - "node": "Scrape Occurrences", - "type": "main", - "index": 0 - } - ], - [ - { - "node": "Throw Error", - "type": "main", - "index": 0 - } - ] - ] - }, - "If XLSX Validate Success": { - "main": [ - [ - { - "node": "XLSX Transform", - "type": "main", - "index": 0 - } - ], - [ - { - "node": "Throw Error", - "type": "main", - "index": 0 - } - ] - ] - }, - "If XLSX Transform Success": { - "main": [ - [ - { - "node": "Scrape Occurrences", - "type": "main", - "index": 0 - } - ], - [ - { - "node": "Throw Error", - "type": "main", - "index": 0 - } - ] - ] - }, - "If Scrape Occurrences Success": { - "main": [ - [ - { - "node": "Complete", - "type": "main", - "index": 0 - } - ], - [ - { - "node": "Throw Error", - "type": "main", - "index": 0 - } - ] - ] - }, - "If Zip": { - "main": [ - [ - { - "node": "DWC Validate", - "type": "main", - "index": 0 - } - ], - [ - { - "node": "XLSX Validate", - "type": "main", - "index": 0 - } - ] - ] - } - }, - "createdAt": "2021-09-29T15:30:03.805Z", - "updatedAt": "2021-12-08T23:01:41.277Z", - "settings": {}, - "staticData": null -} \ No newline at end of file diff --git a/testing/postman/README.md b/testing/postman/README.md index 6dc5ec70c0..13a8bc8012 100644 --- a/testing/postman/README.md +++ b/testing/postman/README.md @@ -10,9 +10,7 @@ https://www.postman.com/downloads/ ## 2. Import the API Collection and Environment files for SIMS and BIoHub. -- Download the API Collection file and Environment Variables file - - [BioHub Collection.postman_collection.json](https://nrs.objectstore.gov.bc.ca/gblhvt/postman/BioHub%20Collection.postman_collection.json) - - [BioHub Collection.postman_environment.json](https://nrs.objectstore.gov.bc.ca/gblhvt/postman/BioHub%20Collection.postman_environment.json) +- Download the API Collection file and Environment Variables files from `https://nrs.objectstore.gov.bc.ca/gblhvt/postman/` - In Postman, on the `Collections` tab - Click the `Import` button - Select and import both the API Collection file and the Environment Variables file. @@ -53,4 +51,4 @@ There are 2 kinds of authentication that are supported by this Postman collectio - Update the collection in the Postman app, and export the API Collection file and/or Environment Variables file. - Note: Environment variables marked secret should automatically be scrubbed from the exported environment variables file, but it - is always best to double check just in case. -- Upload the files to S3 under ./postman +- Upload the files to S3 under `./postman` From f81993c140a3349434a169b313b45f852f1468d5 Mon Sep 17 00:00:00 2001 From: Nick Phura Date: Fri, 12 Apr 2024 17:23:11 -0700 Subject: [PATCH 02/31] TechDebt: Fix Prettier (#1271) Bump eslint/prettier versions, update .eslintrc --- api/.eslintrc | 9 +- api/package-lock.json | 1616 +++++++++-------- api/package.json | 18 +- api/src/__mocks__/db.ts | 12 +- api/src/app.ts | 2 +- api/src/database/db-utils.test.ts | 7 +- api/src/database/db-utils.ts | 38 +- api/src/database/db.test.ts | 32 +- api/src/models/project-view.test.ts | 6 +- api/src/models/survey-view.test.ts | 4 +- .../paths/administrative-activities.test.ts | 6 +- api/src/paths/administrative-activity.test.ts | 4 +- api/src/paths/codes.test.ts | 6 +- api/src/paths/codes.ts | 2 +- api/src/paths/project/create.test.ts | 2 +- api/src/paths/project/list.test.ts | 8 +- .../{projectId}/attachments/list.test.ts | 2 +- .../attachments/report/upload.test.ts | 8 +- .../{projectId}/attachments/upload.test.ts | 8 +- .../attachments/{attachmentId}/delete.test.ts | 4 +- .../{attachmentId}/getSignedUrl.test.ts | 6 +- .../{attachmentId}/metadata/get.test.ts | 8 +- .../{attachmentId}/metadata/update.test.ts | 4 +- .../paths/project/{projectId}/delete.test.ts | 6 +- .../{projectId}/participants/index.test.ts | 10 +- .../{projectId}/participants/self.test.ts | 2 +- .../attachments/keyx/upload.test.ts | 10 +- .../{surveyId}/attachments/list.test.ts | 4 +- .../attachments/report/upload.test.ts | 8 +- .../{surveyId}/attachments/upload.test.ts | 8 +- .../attachments/{attachmentId}/delete.test.ts | 4 +- .../{attachmentId}/getSignedUrl.test.ts | 6 +- .../{attachmentId}/metadata/get.test.ts | 8 +- .../{attachmentId}/metadata/update.test.ts | 4 +- .../{surveyId}/critters/{critterId}.test.ts | 4 +- .../{critterId}/deployments/index.test.ts | 4 +- .../deployments/{bctwDeploymentId}.test.ts | 2 +- .../critters/{critterId}/telemetry.test.ts | 2 +- .../critters/{critterId}/telemetry.ts | 5 +- .../survey/{surveyId}/delete.test.ts | 16 +- .../{surveyId}/observations/index.test.ts | 12 +- .../survey/{surveyId}/observations/index.ts | 9 +- .../{surveyId}/observations/process.test.ts | 2 +- .../{surveyId}/observations/upload.test.ts | 12 +- .../{surveyId}/participants/index.test.ts | 8 +- .../{surveyId}/sample-site/delete.test.ts | 4 +- .../{surveyId}/sample-site/index.test.ts | 4 +- .../{surveySampleSiteId}/index.test.ts | 4 +- .../sample-method/index.test.ts | 4 +- .../{surveySampleMethodId}/index.test.ts | 4 +- .../survey/{surveyId}/update/get.test.ts | 14 +- .../survey/{surveyId}/view.test.ts | 2 +- .../paths/project/{projectId}/view.test.ts | 2 +- .../paths/publish/attachment/resubmit.test.ts | 4 +- api/src/paths/publish/survey.test.ts | 4 +- api/src/paths/telemetry/deployments.test.ts | 4 +- api/src/paths/telemetry/device/index.test.ts | 2 +- .../paths/telemetry/device/{deviceId}.test.ts | 2 +- api/src/paths/telemetry/manual/delete.test.ts | 4 +- .../telemetry/manual/deployments.test.ts | 4 +- api/src/paths/telemetry/manual/index.test.ts | 10 +- .../telemetry/vendor/deployments.test.ts | 4 +- api/src/paths/version.test.ts | 2 +- ...administrative-activity-repository.test.ts | 28 +- .../attachment-repository.test.ts | 176 +- .../funding-source-repository.test.ts | 58 +- .../history-publish-repository.test.ts | 26 +- .../observation-repository.test.ts | 22 +- .../repositories/permit-repository.test.ts | 42 +- .../project-participation-repository.test.ts | 36 +- .../repositories/project-repository.test.ts | 58 +- .../repositories/region-repository.test.ts | 16 +- .../sample-blocks-repository.test.ts | 24 +- .../sample-location-repository.test.ts | 20 +- .../sample-method-repository.test.ts | 16 +- .../sample-period-repository.test.ts | 16 +- .../sample-stratums-repository.test.ts | 24 +- ...site-selection-strategy-repository.test.ts | 32 +- .../repositories/subcount-repository.test.ts | 36 +- .../survey-block-repository.test.ts | 42 +- .../survey-critter-repository.test.ts | 12 +- .../survey-location-repository.test.ts | 10 +- .../survey-participation-repository.test.ts | 26 +- .../repositories/survey-repository.test.ts | 156 +- api/src/repositories/user-repository.test.ts | 36 +- .../security/authentication.test.ts | 24 +- .../security/authorization.test.ts | 22 +- .../security/authorization.ts | 2 +- api/src/services/attachment-service.test.ts | 132 +- .../services/authorization-service.test.ts | 84 +- api/src/services/bctw-service.test.ts | 4 +- api/src/services/critterbase-service.ts | 4 +- api/src/services/eml-service.test.ts | 6 +- api/src/services/funding-source-service.ts | 4 +- .../services/history-publish-service.test.ts | 6 +- api/src/services/history-publish-service.ts | 4 +- api/src/services/observation-service.ts | 11 +- api/src/services/platform-service.test.ts | 6 +- api/src/services/project-service.test.ts | 2 +- api/src/services/subcount-service.ts | 4 +- api/src/services/survey-block-service.test.ts | 8 +- api/src/services/survey-service.test.ts | 74 +- api/src/services/user-service.test.ts | 12 +- api/src/utils/file-utils.test.ts | 4 +- api/src/utils/keycloak-utils.test.ts | 2 +- api/src/utils/logger.ts | 2 +- api/src/utils/media/csv/csv-file.test.ts | 4 +- api/src/utils/media/media-utils.test.ts | 62 +- .../media/xlsx/validation/xlsx-validation.ts | 7 +- api/src/utils/pagination.test.ts | 4 +- api/src/utils/spatial-utils.test.ts | 4 +- api/src/utils/string-utils.ts | 4 +- .../utils/xlsx-utils/worksheet-utils.test.ts | 6 +- app/.eslintrc | 12 +- app/package-lock.json | 1576 ++++++++++++---- app/package.json | 17 +- .../file-upload/FileUploadItem.test.tsx | 6 +- .../map/components/MarkerCluster.tsx | 2 +- .../components/PublishSurveyContent.tsx | 2 - app/src/components/security/RouteGuards.tsx | 2 +- app/src/constants/dateTimeFormats.ts | 1 - .../projects/create/CreateProjectPage.tsx | 2 +- .../projects/edit/EditProjectPage.tsx | 2 +- .../projects/view/components/TeamMember.tsx | 2 +- app/src/features/surveys/CreateSurveyPage.tsx | 2 +- .../components/ProprietaryDataForm.tsx | 4 +- .../features/surveys/edit/EditSurveyPage.tsx | 2 +- .../sampling-sites/SamplingSitePage.tsx | 2 +- .../components/SamplingBlockForm.tsx | 8 +- .../components/SamplingSiteMapControl.tsx | 2 +- .../components/SamplingStratumForm.tsx | 8 +- .../edit/SamplingSiteEditPage.tsx | 2 +- .../SampleSiteGeneralInformationForm.tsx | 2 +- .../edit/components/SamplingBlockEditForm.tsx | 8 +- .../components/SamplingSiteEditMapControl.tsx | 2 +- .../components/SamplingStratumEditForm.tsx | 8 +- app/src/features/surveys/view/SurveyMap.tsx | 2 +- .../components/SurveyGeneralInformation.tsx | 4 +- .../SurveyPurposeAndMethodologyData.tsx | 2 +- .../telemetry-device/TelemetryMap.tsx | 2 +- database/.eslintrc | 18 +- database/package-lock.json | 1292 ++++++------- database/package.json | 19 +- 143 files changed, 3675 insertions(+), 2794 deletions(-) diff --git a/api/.eslintrc b/api/.eslintrc index db41111d79..00d07b6983 100644 --- a/api/.eslintrc +++ b/api/.eslintrc @@ -1,7 +1,6 @@ { "extends": [ "eslint:recommended", - "prettier/@typescript-eslint", "plugin:prettier/recommended", "plugin:@typescript-eslint/eslint-recommended", "plugin:@typescript-eslint/recommended" @@ -34,6 +33,14 @@ "ts-check": false } ], + "@typescript-eslint/no-unused-vars": [ + "error", + { + "args": "all", + "argsIgnorePattern": "^_", + "caughtErrorsIgnorePattern": "^_" + } + ], "no-var": "error" } } diff --git a/api/package-lock.json b/api/package-lock.json index 8aebaa6819..65187482b5 100644 --- a/api/package-lock.json +++ b/api/package-lock.json @@ -69,21 +69,21 @@ "@types/uuid": "^8.3.1", "@types/xml2js": "^0.4.9", "@types/yamljs": "^0.2.31", - "@typescript-eslint/eslint-plugin": "^4.33.0", - "@typescript-eslint/parser": "^4.33.0", + "@typescript-eslint/eslint-plugin": "~7.6.0", + "@typescript-eslint/parser": "~7.6.0", "chai": "^4.3.4", "del": "^6.0.0", - "eslint": "^7.32.0", - "eslint-config-prettier": "^6.15.0", - "eslint-plugin-prettier": "^3.3.1", + "eslint": "~8.56.0", + "eslint-config-prettier": "~8.10.0", + "eslint-plugin-prettier": "~4.2.1", "gulp": "^4.0.2", "gulp-typescript": "^5.0.1", "mocha": "^10.4.0", "nodemon": "^3.1.0", "npm-run-all": "^4.1.5", "nyc": "^15.1.0", - "prettier": "^2.2.1", - "prettier-plugin-organize-imports": "^2.3.4", + "prettier": "^2.8.8", + "prettier-plugin-organize-imports": "^3.2.4", "sinon": "^17.0.1", "sinon-chai": "^3.7.0", "ts-mocha": "^10.0.0", @@ -117,12 +117,16 @@ } }, "node_modules/@babel/code-frame": { - "version": "7.12.11", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.12.11.tgz", - "integrity": "sha512-Zt1yodBx1UcyiePMSkWnU4hPqhwq7hGi2nFL1LeA3EUl+q2LQx16MISgJ0+z7dnmgvP9QtIleuETGOiOH1RcIw==", + "version": "7.24.2", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.24.2.tgz", + "integrity": "sha512-y5+tLQyV8pg3fsiln67BVLD1P13Eg4lh5RW9mF0zUuvLrv9uIQ4MCL+CRT+FTsBlBjcIan6PGsLcBN0m3ClUyQ==", "dev": true, "dependencies": { - "@babel/highlight": "^7.10.4" + "@babel/highlight": "^7.24.2", + "picocolors": "^1.0.0" + }, + "engines": { + "node": ">=6.9.0" } }, "node_modules/@babel/compat-data": { @@ -164,19 +168,6 @@ "url": "https://opencollective.com/babel" } }, - "node_modules/@babel/core/node_modules/@babel/code-frame": { - "version": "7.24.2", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.24.2.tgz", - "integrity": "sha512-y5+tLQyV8pg3fsiln67BVLD1P13Eg4lh5RW9mF0zUuvLrv9uIQ4MCL+CRT+FTsBlBjcIan6PGsLcBN0m3ClUyQ==", - "dev": true, - "dependencies": { - "@babel/highlight": "^7.24.2", - "picocolors": "^1.0.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@babel/core/node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -489,19 +480,6 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/template/node_modules/@babel/code-frame": { - "version": "7.24.2", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.24.2.tgz", - "integrity": "sha512-y5+tLQyV8pg3fsiln67BVLD1P13Eg4lh5RW9mF0zUuvLrv9uIQ4MCL+CRT+FTsBlBjcIan6PGsLcBN0m3ClUyQ==", - "dev": true, - "dependencies": { - "@babel/highlight": "^7.24.2", - "picocolors": "^1.0.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@babel/traverse": { "version": "7.24.1", "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.24.1.tgz", @@ -523,19 +501,6 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/traverse/node_modules/@babel/code-frame": { - "version": "7.24.2", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.24.2.tgz", - "integrity": "sha512-y5+tLQyV8pg3fsiln67BVLD1P13Eg4lh5RW9mF0zUuvLrv9uIQ4MCL+CRT+FTsBlBjcIan6PGsLcBN0m3ClUyQ==", - "dev": true, - "dependencies": { - "@babel/highlight": "^7.24.2", - "picocolors": "^1.0.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@babel/traverse/node_modules/globals": { "version": "11.12.0", "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", @@ -560,9 +525,9 @@ } }, "node_modules/@colors/colors": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", - "integrity": "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.6.0.tgz", + "integrity": "sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==", "engines": { "node": ">=0.1.90" } @@ -599,24 +564,51 @@ "kuler": "^2.0.0" } }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", + "integrity": "sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==", + "dev": true, + "dependencies": { + "eslint-visitor-keys": "^3.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.10.0", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.10.0.tgz", + "integrity": "sha512-Cu96Sd2By9mCNTx2iyKOmq10v22jUVQv0lQnlGNy16oE9589yE+QADPbrMGCkA51cKZSg3Pu/aTJVTGfL/qjUA==", + "dev": true, + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, "node_modules/@eslint/eslintrc": { - "version": "0.4.3", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-0.4.3.tgz", - "integrity": "sha512-J6KFFz5QCYUJq3pf0mjEcCJVERbzv71PUIDczuh9JkwGEzced6CO5ADLHB1rbf/+oPBtoPfMYNOpGDzCANlbXw==", + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", + "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", "dev": true, "dependencies": { "ajv": "^6.12.4", - "debug": "^4.1.1", - "espree": "^7.3.0", - "globals": "^13.9.0", - "ignore": "^4.0.6", + "debug": "^4.3.2", + "espree": "^9.6.0", + "globals": "^13.19.0", + "ignore": "^5.2.0", "import-fresh": "^3.2.1", - "js-yaml": "^3.13.1", - "minimatch": "^3.0.4", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", "strip-json-comments": "^3.1.1" }, "engines": { - "node": "^10.12.0 || >=12.0.0" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" } }, "node_modules/@eslint/eslintrc/node_modules/ajv": { @@ -635,13 +627,14 @@ "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/@eslint/eslintrc/node_modules/ignore": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-4.0.6.tgz", - "integrity": "sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg==", + "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", "dev": true, - "engines": { - "node": ">= 4" + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" } }, "node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": { @@ -650,24 +643,80 @@ "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", "dev": true }, + "node_modules/@eslint/eslintrc/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/js": { + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.56.0.tgz", + "integrity": "sha512-gMsVel9D7f2HLkBma9VbtzZRehRogVRfbr++f06nL2vnCGCNlzOD+/MUov/F4p8myyAHspEhVobgjpX64q5m6A==", + "dev": true, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, "node_modules/@humanwhocodes/config-array": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.5.0.tgz", - "integrity": "sha512-FagtKFz74XrTl7y6HCzQpwDfXP0yhxe9lHLD1UZxjvZIcbyRz8zTFF/yYNfSfzU414eDwZ1SrO0Qvtyf+wFMQg==", + "version": "0.11.14", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz", + "integrity": "sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==", "dev": true, "dependencies": { - "@humanwhocodes/object-schema": "^1.2.0", - "debug": "^4.1.1", - "minimatch": "^3.0.4" + "@humanwhocodes/object-schema": "^2.0.2", + "debug": "^4.3.1", + "minimatch": "^3.0.5" }, "engines": { "node": ">=10.10.0" } }, + "node_modules/@humanwhocodes/config-array/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@humanwhocodes/config-array/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, "node_modules/@humanwhocodes/object-schema": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-1.2.1.tgz", - "integrity": "sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", + "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", "dev": true }, "node_modules/@istanbuljs/load-nyc-config": { @@ -686,6 +735,15 @@ "node": ">=8" } }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, "node_modules/@istanbuljs/load-nyc-config/node_modules/find-up": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", @@ -699,6 +757,19 @@ "node": ">=8" } }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { + "version": "3.14.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", + "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "dev": true, + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/@istanbuljs/load-nyc-config/node_modules/locate-path": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", @@ -1038,9 +1109,9 @@ } }, "node_modules/@types/express-serve-static-core": { - "version": "4.17.43", - "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.17.43.tgz", - "integrity": "sha512-oaYtiBirUOPQGSWNGPWnzyAFJ0BP3cwvN4oWZQY+zUBwpVIGsKUkpBpSztp74drYcjavs7SKFZ4DX1V2QeN8rg==", + "version": "4.19.0", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.0.tgz", + "integrity": "sha512-bGyep3JqPCRry1wq+O5n7oiBgGWmeIJXPjXXCo8EK0u8duZGSYar7cGqd3ML2JUsLGeB7fmc06KYo9fLGWqPvQ==", "dependencies": { "@types/node": "*", "@types/qs": "*", @@ -1113,9 +1184,9 @@ } }, "node_modules/@types/lodash": { - "version": "4.14.202", - "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.202.tgz", - "integrity": "sha512-OvlIYQK9tNneDlS0VN54LLd5uiPCBOp7gS5Z0f1mjoJYBrtStzgmJBxONW3U6OZqdtNzZPmn9BS/7WI7BFFcFQ==", + "version": "4.17.0", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.0.tgz", + "integrity": "sha512-t7dhREVv6dbNj0q17X12j7yDG4bD/DHYX7o5/DbDxobP0HnGPgpRz2Ej77aL7TZT3DSw13fqUTj8J4mMnqa7WA==", "dev": true }, "node_modules/@types/mime": { @@ -1140,17 +1211,17 @@ } }, "node_modules/@types/node": { - "version": "18.19.29", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.29.tgz", - "integrity": "sha512-5pAX7ggTmWZdhUrhRWLPf+5oM7F80bcKVCBbr0zwEkTNzTJL2CWQjznpFgHYy6GrzkYi2Yjy7DHKoynFxqPV8g==", + "version": "18.19.31", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.31.tgz", + "integrity": "sha512-ArgCD39YpyyrtFKIqMDvjz79jto5fcI/SVUs2HwB+f0dAzq68yqOdyaSivLiLugSziTpNXLQrVb7RZFmdZzbhA==", "dependencies": { "undici-types": "~5.26.4" } }, "node_modules/@types/pg": { - "version": "8.11.4", - "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.11.4.tgz", - "integrity": "sha512-yw3Bwbda6vO+NvI1Ue/YKOwtl31AYvvd/e73O3V4ZkNzuGpTDndLSyc0dQRB2xrQqDePd20pEGIfqSp/GH3pRw==", + "version": "8.11.5", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.11.5.tgz", + "integrity": "sha512-2xMjVviMxneZHDHX5p5S6tsRRs7TpDHeeK7kTTMe/kAC/mRRNjWHjZg0rkiY+e17jXSZV3zJYDxXV8Cy72/Vuw==", "dev": true, "dependencies": { "@types/node": "*", @@ -1158,63 +1229,6 @@ "pg-types": "^4.0.1" } }, - "node_modules/@types/pg/node_modules/pg-types": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-4.0.2.tgz", - "integrity": "sha512-cRL3JpS3lKMGsKaWndugWQoLOCoP+Cic8oseVcbr0qhPzYD5DWXK+RZ9LY9wxRf7RQia4SCwQlXk0q6FCPrVng==", - "dev": true, - "dependencies": { - "pg-int8": "1.0.1", - "pg-numeric": "1.0.2", - "postgres-array": "~3.0.1", - "postgres-bytea": "~3.0.0", - "postgres-date": "~2.1.0", - "postgres-interval": "^3.0.0", - "postgres-range": "^1.1.1" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/@types/pg/node_modules/postgres-array": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-3.0.2.tgz", - "integrity": "sha512-6faShkdFugNQCLwucjPcY5ARoW1SlbnrZjmGl0IrrqewpvxvhSLHimCVzqeuULCbG0fQv7Dtk1yDbG3xv7Veog==", - "dev": true, - "engines": { - "node": ">=12" - } - }, - "node_modules/@types/pg/node_modules/postgres-bytea": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-3.0.0.tgz", - "integrity": "sha512-CNd4jim9RFPkObHSjVHlVrxoVQXz7quwNFpz7RY1okNNme49+sVyiTvTRobiLV548Hx/hb1BG+iE7h9493WzFw==", - "dev": true, - "dependencies": { - "obuf": "~1.1.2" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/@types/pg/node_modules/postgres-date": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-2.1.0.tgz", - "integrity": "sha512-K7Juri8gtgXVcDfZttFKVmhglp7epKb1K4pgrkLxehjqkrgPhfG6OO8LHLkfaqkbpjNRnra018XwAr1yQFWGcA==", - "dev": true, - "engines": { - "node": ">=12" - } - }, - "node_modules/@types/pg/node_modules/postgres-interval": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-3.0.0.tgz", - "integrity": "sha512-BSNDnbyZCXSxgA+1f5UU2GmwhoI0aU5yMxRGO8CdFEcY2BQF9xm/7MqKnYoM1nJDk8nONNWDk9WeSmePFhQdlw==", - "dev": true, - "engines": { - "node": ">=12" - } - }, "node_modules/@types/picomatch": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/@types/picomatch/-/picomatch-2.3.3.tgz", @@ -1231,6 +1245,12 @@ "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==" }, + "node_modules/@types/semver": { + "version": "7.5.8", + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.8.tgz", + "integrity": "sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ==", + "dev": true + }, "node_modules/@types/send": { "version": "0.17.4", "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.4.tgz", @@ -1299,6 +1319,11 @@ "@types/serve-static": "*" } }, + "node_modules/@types/triple-beam": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/triple-beam/-/triple-beam-1.3.5.tgz", + "integrity": "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==" + }, "node_modules/@types/undertaker": { "version": "1.2.11", "resolved": "https://registry.npmjs.org/@types/undertaker/-/undertaker-1.2.11.tgz", @@ -1365,30 +1390,33 @@ "dev": true }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "4.33.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-4.33.0.tgz", - "integrity": "sha512-aINiAxGVdOl1eJyVjaWn/YcVAq4Gi/Yo35qHGCnqbWVz61g39D0h23veY/MA0rFFGfxK7TySg2uwDeNv+JgVpg==", + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.6.0.tgz", + "integrity": "sha512-gKmTNwZnblUdnTIJu3e9kmeRRzV2j1a/LUO27KNNAnIC5zjy1aSvXSRp4rVNlmAoHlQ7HzX42NbKpcSr4jF80A==", "dev": true, "dependencies": { - "@typescript-eslint/experimental-utils": "4.33.0", - "@typescript-eslint/scope-manager": "4.33.0", - "debug": "^4.3.1", - "functional-red-black-tree": "^1.0.1", - "ignore": "^5.1.8", - "regexpp": "^3.1.0", - "semver": "^7.3.5", - "tsutils": "^3.21.0" + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "7.6.0", + "@typescript-eslint/type-utils": "7.6.0", + "@typescript-eslint/utils": "7.6.0", + "@typescript-eslint/visitor-keys": "7.6.0", + "debug": "^4.3.4", + "graphemer": "^1.4.0", + "ignore": "^5.3.1", + "natural-compare": "^1.4.0", + "semver": "^7.6.0", + "ts-api-utils": "^1.3.0" }, "engines": { - "node": "^10.12.0 || >=12.0.0" + "node": "^18.18.0 || >=20.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^4.0.0", - "eslint": "^5.0.0 || ^6.0.0 || ^7.0.0" + "@typescript-eslint/parser": "^7.0.0", + "eslint": "^8.56.0" }, "peerDependenciesMeta": { "typescript": { @@ -1396,81 +1424,85 @@ } } }, - "node_modules/@typescript-eslint/experimental-utils": { - "version": "4.33.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/experimental-utils/-/experimental-utils-4.33.0.tgz", - "integrity": "sha512-zeQjOoES5JFjTnAhI5QY7ZviczMzDptls15GFsI6jyUOq0kOf9+WonkhtlIhh0RgHRnqj5gdNxW5j1EvAyYg6Q==", + "node_modules/@typescript-eslint/parser": { + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.6.0.tgz", + "integrity": "sha512-usPMPHcwX3ZoPWnBnhhorc14NJw9J4HpSXQX4urF2TPKG0au0XhJoZyX62fmvdHONUkmyUe74Hzm1//XA+BoYg==", "dev": true, "dependencies": { - "@types/json-schema": "^7.0.7", - "@typescript-eslint/scope-manager": "4.33.0", - "@typescript-eslint/types": "4.33.0", - "@typescript-eslint/typescript-estree": "4.33.0", - "eslint-scope": "^5.1.1", - "eslint-utils": "^3.0.0" + "@typescript-eslint/scope-manager": "7.6.0", + "@typescript-eslint/types": "7.6.0", + "@typescript-eslint/typescript-estree": "7.6.0", + "@typescript-eslint/visitor-keys": "7.6.0", + "debug": "^4.3.4" }, "engines": { - "node": "^10.12.0 || >=12.0.0" + "node": "^18.18.0 || >=20.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "*" + "eslint": "^8.56.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, - "node_modules/@typescript-eslint/parser": { - "version": "4.33.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-4.33.0.tgz", - "integrity": "sha512-ZohdsbXadjGBSK0/r+d87X0SBmKzOq4/S5nzK6SBgJspFo9/CUDJ7hjayuze+JK7CZQLDMroqytp7pOcFKTxZA==", + "node_modules/@typescript-eslint/scope-manager": { + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.6.0.tgz", + "integrity": "sha512-ngttyfExA5PsHSx0rdFgnADMYQi+Zkeiv4/ZxGYUWd0nLs63Ha0ksmp8VMxAIC0wtCFxMos7Lt3PszJssG/E6w==", "dev": true, "dependencies": { - "@typescript-eslint/scope-manager": "4.33.0", - "@typescript-eslint/types": "4.33.0", - "@typescript-eslint/typescript-estree": "4.33.0", - "debug": "^4.3.1" + "@typescript-eslint/types": "7.6.0", + "@typescript-eslint/visitor-keys": "7.6.0" }, "engines": { - "node": "^10.12.0 || >=12.0.0" + "node": "^18.18.0 || >=20.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^5.0.0 || ^6.0.0 || ^7.0.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } } }, - "node_modules/@typescript-eslint/scope-manager": { - "version": "4.33.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-4.33.0.tgz", - "integrity": "sha512-5IfJHpgTsTZuONKbODctL4kKuQje/bzBRkwHE8UOZ4f89Zeddg+EGZs8PD8NcN4LdM3ygHWYB3ukPAYjvl/qbQ==", + "node_modules/@typescript-eslint/type-utils": { + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.6.0.tgz", + "integrity": "sha512-NxAfqAPNLG6LTmy7uZgpK8KcuiS2NZD/HlThPXQRGwz6u7MDBWRVliEEl1Gj6U7++kVJTpehkhZzCJLMK66Scw==", "dev": true, "dependencies": { - "@typescript-eslint/types": "4.33.0", - "@typescript-eslint/visitor-keys": "4.33.0" + "@typescript-eslint/typescript-estree": "7.6.0", + "@typescript-eslint/utils": "7.6.0", + "debug": "^4.3.4", + "ts-api-utils": "^1.3.0" }, "engines": { - "node": "^8.10.0 || ^10.13.0 || >=11.10.1" + "node": "^18.18.0 || >=20.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.56.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, "node_modules/@typescript-eslint/types": { - "version": "4.33.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-4.33.0.tgz", - "integrity": "sha512-zKp7CjQzLQImXEpLt2BUw1tvOMPfNoTAfb8l51evhYbOEEzdWyQNmHWWGPR6hwKJDAi+1VXSBmnhL9kyVTTOuQ==", + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.6.0.tgz", + "integrity": "sha512-h02rYQn8J+MureCvHVVzhl69/GAfQGPQZmOMjG1KfCl7o3HtMSlPaPUAPu6lLctXI5ySRGIYk94clD/AUMCUgQ==", "dev": true, "engines": { - "node": "^8.10.0 || ^10.13.0 || >=11.10.1" + "node": "^18.18.0 || >=20.0.0" }, "funding": { "type": "opencollective", @@ -1478,21 +1510,22 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "4.33.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-4.33.0.tgz", - "integrity": "sha512-rkWRY1MPFzjwnEVHsxGemDzqqddw2QbTJlICPD9p9I9LfsO8fdmfQPOX3uKfUaGRDFJbfrtm/sXhVXN4E+bzCA==", + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.6.0.tgz", + "integrity": "sha512-+7Y/GP9VuYibecrCQWSKgl3GvUM5cILRttpWtnAu8GNL9j11e4tbuGZmZjJ8ejnKYyBRb2ddGQ3rEFCq3QjMJw==", "dev": true, "dependencies": { - "@typescript-eslint/types": "4.33.0", - "@typescript-eslint/visitor-keys": "4.33.0", - "debug": "^4.3.1", - "globby": "^11.0.3", - "is-glob": "^4.0.1", - "semver": "^7.3.5", - "tsutils": "^3.21.0" + "@typescript-eslint/types": "7.6.0", + "@typescript-eslint/visitor-keys": "7.6.0", + "debug": "^4.3.4", + "globby": "^11.1.0", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^1.3.0" }, "engines": { - "node": "^10.12.0 || >=12.0.0" + "node": "^18.18.0 || >=20.0.0" }, "funding": { "type": "opencollective", @@ -1504,23 +1537,54 @@ } } }, + "node_modules/@typescript-eslint/utils": { + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.6.0.tgz", + "integrity": "sha512-x54gaSsRRI+Nwz59TXpCsr6harB98qjXYzsRxGqvA5Ue3kQH+FxS7FYU81g/omn22ML2pZJkisy6Q+ElK8pBCA==", + "dev": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.4.0", + "@types/json-schema": "^7.0.15", + "@types/semver": "^7.5.8", + "@typescript-eslint/scope-manager": "7.6.0", + "@typescript-eslint/types": "7.6.0", + "@typescript-eslint/typescript-estree": "7.6.0", + "semver": "^7.6.0" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.56.0" + } + }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "4.33.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-4.33.0.tgz", - "integrity": "sha512-uqi/2aSz9g2ftcHWf8uLPJA70rUv6yuMW5Bohw+bwcuzaxQIHaKFZCKGoGXIrc9vkTJ3+0txM73K0Hq3d5wgIg==", + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.6.0.tgz", + "integrity": "sha512-4eLB7t+LlNUmXzfOu1VAIAdkjbu5xNSerURS9X/S5TUKWFRpXRQZbmtPqgKmYx8bj3J0irtQXSiWAOY82v+cgw==", "dev": true, "dependencies": { - "@typescript-eslint/types": "4.33.0", - "eslint-visitor-keys": "^2.0.0" + "@typescript-eslint/types": "7.6.0", + "eslint-visitor-keys": "^3.4.3" }, "engines": { - "node": "^8.10.0 || ^10.13.0 || >=11.10.1" + "node": "^18.18.0 || >=20.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@ungap/structured-clone": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz", + "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==", + "dev": true + }, "node_modules/abbrev": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", @@ -1540,9 +1604,9 @@ } }, "node_modules/acorn": { - "version": "7.4.1", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", - "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==", + "version": "8.11.3", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz", + "integrity": "sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==", "dev": true, "bin": { "acorn": "bin/acorn" @@ -1622,12 +1686,15 @@ } }, "node_modules/ansi-colors": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", - "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-1.1.0.tgz", + "integrity": "sha512-SFKX67auSNoVR38N3L+nvsPjOE0bybKTYbkf5tRvushrAPQ9V75huw0ZxBkKVeRU9kqH3d6HA4xTckbwZ4ixmA==", "dev": true, + "dependencies": { + "ansi-wrap": "^0.1.0" + }, "engines": { - "node": ">=6" + "node": ">=0.10.0" } }, "node_modules/ansi-gray": { @@ -1728,12 +1795,10 @@ "dev": true }, "node_modules/argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", - "dependencies": { - "sprintf-js": "~1.0.2" - } + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true }, "node_modules/arr-diff": { "version": "4.0.0", @@ -1957,15 +2022,6 @@ "node": ">=0.10.0" } }, - "node_modules/astral-regex": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", - "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, "node_modules/async": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/async/-/async-3.2.3.tgz", @@ -2042,9 +2098,9 @@ } }, "node_modules/aws-sdk": { - "version": "2.1592.0", - "resolved": "https://registry.npmjs.org/aws-sdk/-/aws-sdk-2.1592.0.tgz", - "integrity": "sha512-iwmS46jOEHMNodfrpNBJ5eHwjKAY05t/xYV2cp+KyzMX2yGgt2/EtWWnlcoMGBKR31qKTsjMj5ZPouC9/VeDOA==", + "version": "2.1598.0", + "resolved": "https://registry.npmjs.org/aws-sdk/-/aws-sdk-2.1598.0.tgz", + "integrity": "sha512-/oPetmY5v62lAt2jTRfIEHrdrg8hfz5KI8qvvP/jhFdNJfLZ85nsn3+fSS8i3FgfeWXIS5yv4ZPpA+JNAnBwdQ==", "hasInstallScript": true, "dependencies": { "buffer": "4.9.2", @@ -2202,23 +2258,26 @@ "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==" }, "node_modules/body-parser": { - "version": "1.19.2", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.19.2.tgz", - "integrity": "sha512-SAAwOxgoCKMGs9uUAUFHygfLAyaniaoun6I8mFY9pRAJL9+Kec34aU+oIjDhTycub1jozEfEwx1W1IuOYxVSFw==", + "version": "1.20.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.2.tgz", + "integrity": "sha512-ml9pReCu3M61kGlqoTm2umSXTlRTuGTx0bfYj+uIUKKYycG5NtSbeetV3faSU6R7ajOPw0g/J1PvK4qNy7s5bA==", "dependencies": { "bytes": "3.1.2", - "content-type": "~1.0.4", + "content-type": "~1.0.5", "debug": "2.6.9", - "depd": "~1.1.2", - "http-errors": "1.8.1", + "depd": "2.0.0", + "destroy": "1.2.0", + "http-errors": "2.0.0", "iconv-lite": "0.4.24", - "on-finished": "~2.3.0", - "qs": "6.9.7", - "raw-body": "2.4.3", - "type-is": "~1.6.18" + "on-finished": "2.4.1", + "qs": "6.11.0", + "raw-body": "2.5.2", + "type-is": "~1.6.18", + "unpipe": "1.0.0" }, "engines": { - "node": ">= 0.8" + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" } }, "node_modules/body-parser/node_modules/debug": { @@ -2235,9 +2294,12 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" }, "node_modules/body-parser/node_modules/qs": { - "version": "6.9.7", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.9.7.tgz", - "integrity": "sha512-IhMFgUmuNpyRfxA90umL7ByLlgRXu6tIfKPpF5TmcfRLlLCckfP/g3IQmju6jjpu+Hh8rA+2p6A27ZSPOOHdKw==", + "version": "6.11.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", + "integrity": "sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==", + "dependencies": { + "side-channel": "^1.0.4" + }, "engines": { "node": ">=0.6" }, @@ -2246,13 +2308,11 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" + "balanced-match": "^1.0.0" } }, "node_modules/braces": { @@ -2336,14 +2396,6 @@ "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==" }, - "node_modules/buffer-writer": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/buffer-writer/-/buffer-writer-2.0.0.tgz", - "integrity": "sha512-a7ZpuTZU1TRtnwyCNW3I5dc0wWNC3VR9S++Ewyk2HHZdrO3CQJqSpd+95Us590V6AL7JqUAH2IwZ/398PmNFgw==", - "engines": { - "node": ">=4" - } - }, "node_modules/busboy": { "version": "0.2.14", "resolved": "https://registry.npmjs.org/busboy/-/busboy-0.2.14.tgz", @@ -2456,9 +2508,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001605", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001605.tgz", - "integrity": "sha512-nXwGlFWo34uliI9z3n6Qc0wZaf7zaZWA1CPZ169La5mV3I/gem7bst0vr5XQH5TJXZIMfDeZyOrZnSlVzKxxHQ==", + "version": "1.0.30001609", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001609.tgz", + "integrity": "sha512-JFPQs34lHKx1B5t1EpQpWH4c+29zIyn/haGsbpfq3suuV9v56enjFt23zqijxGTMwy1p/4H2tjnQMY+p1WoAyA==", "dev": true, "funding": [ { @@ -2476,9 +2528,9 @@ ] }, "node_modules/chai": { - "version": "4.3.10", - "resolved": "https://registry.npmjs.org/chai/-/chai-4.3.10.tgz", - "integrity": "sha512-0UXG04VuVbruMUYbJ6JctvH0YnC/4q3/AkT18q4NaITo91CUm0liMS9VqzT9vZhVQ/1eqPanMWjBM+Juhfb/9g==", + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/chai/-/chai-4.4.1.tgz", + "integrity": "sha512-13sOfMv2+DWduEU+/xbun3LScLoqN17nBeTLUsmDfKdoiC1fr0n9PU4guu4AhRcOVFk/sW8LyZWHuhWtQZiF+g==", "dev": true, "dependencies": { "assertion-error": "^1.1.0", @@ -2742,9 +2794,9 @@ "integrity": "sha512-3tlv/dIP7FWvj3BsbHrGLJ6l/oKh1O3TcgBqMn+yyCagOxc23fyzDS6HypQbgxWbkpDnf52p1LuR4eWDQ/K9WQ==" }, "node_modules/colors": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/colors/-/colors-1.4.0.tgz", - "integrity": "sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/colors/-/colors-1.0.3.tgz", + "integrity": "sha512-pFGrxThWcWQ2MsAz6RtgeWe4NK2kUE1WfsrvvlctdII745EW9I0yflqhe7++M5LEc7bV2c/9/5zc8sFcpL0Drw==", "engines": { "node": ">=0.1.90" } @@ -2838,9 +2890,9 @@ "dev": true }, "node_modules/cookie": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.2.tgz", - "integrity": "sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA==", + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", "engines": { "node": ">= 0.6" } @@ -3025,22 +3077,14 @@ } }, "node_modules/db-migrate-pg": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/db-migrate-pg/-/db-migrate-pg-1.2.4.tgz", - "integrity": "sha512-E0Sl7Kk3HxOvB9/tATqb2qULCOglmqALghwxd+R4L6/au0P2Yl/h7XsgXklglukbIYKPTf2UfvnOnvNKUEvWXA==", + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/db-migrate-pg/-/db-migrate-pg-1.5.2.tgz", + "integrity": "sha512-agbT9biJi43E7wld9JgnpMKadYgIobMlRXdtRO8JLRWHI1Jc7mObl9pM7iv4AQ4UTLDgjtkqUqtXlfeWtRuRbA==", "dependencies": { "bluebird": "^3.1.1", "db-migrate-base": "^2.3.0", - "pg": "^8.0.3", - "semver": "^5.0.3" - } - }, - "node_modules/db-migrate-pg/node_modules/semver": { - "version": "5.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", - "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", - "bin": { - "semver": "bin/semver" + "pg": "^8.11.2", + "semver": "^7.5.4" } }, "node_modules/db-migrate-shared": { @@ -3198,9 +3242,9 @@ } }, "node_modules/del": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/del/-/del-6.0.0.tgz", - "integrity": "sha512-1shh9DQ23L16oXSZKB2JxpL7iMy2E0S9d517ptA1P8iw0alkPtQcrKH7ru31rYtKwF499HkTu+DRzq3TCKDFRQ==", + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/del/-/del-6.1.1.tgz", + "integrity": "sha512-ua8BhapfP0JUJKC/zV9yHHDW/rDoDxP4Zhn3AkA6/xT6gY7jYXJiaeyBZznYVujhZZET+UgcbZiQ7sN3WqcImg==", "dev": true, "dependencies": { "globby": "^11.0.1", @@ -3228,17 +3272,21 @@ } }, "node_modules/depd": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", - "integrity": "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", "engines": { - "node": ">= 0.6" + "node": ">= 0.8" } }, "node_modules/destroy": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.0.4.tgz", - "integrity": "sha512-3NdhDuEXnfun/z7x9GOElY49LoqVHoGScmOKwmxhsS8N5Y+Z8KyPPDnaSzqWgYt/ji4mqwfTS34Htrk0zPIXVg==" + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } }, "node_modules/detect-file": { "version": "1.0.0", @@ -3379,9 +3427,9 @@ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" }, "node_modules/electron-to-chromium": { - "version": "1.4.725", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.725.tgz", - "integrity": "sha512-OGkMXLY7XH6ykHE5ZOVVIMHaGAvvxqw98cswTKB683dntBJre7ufm9wouJ0ExDm0VXhHenU8mREvxIbV5nNoVQ==", + "version": "1.4.735", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.735.tgz", + "integrity": "sha512-pkYpvwg8VyOTQAeBqZ7jsmpCjko1Qc6We1ZtZCjRyYbT5v4AIUKDy5cQTRotQlSSZmMr8jqpEt6JtOj5k7lR7A==", "dev": true }, "node_modules/emoji-regex": { @@ -3411,19 +3459,6 @@ "once": "^1.4.0" } }, - "node_modules/enquirer": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/enquirer/-/enquirer-2.4.1.tgz", - "integrity": "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==", - "dev": true, - "dependencies": { - "ansi-colors": "^4.1.1", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8.6" - } - }, "node_modules/error-ex": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", @@ -3639,136 +3674,119 @@ } }, "node_modules/eslint": { - "version": "7.32.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-7.32.0.tgz", - "integrity": "sha512-VHZ8gX+EDfz+97jGcgyGCyRia/dPOd6Xh9yPv8Bl1+SoaIwD+a/vlrOmGRUyOYu7MwUhc7CxqeaDZU13S4+EpA==", - "dev": true, - "dependencies": { - "@babel/code-frame": "7.12.11", - "@eslint/eslintrc": "^0.4.3", - "@humanwhocodes/config-array": "^0.5.0", - "ajv": "^6.10.0", + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.56.0.tgz", + "integrity": "sha512-Go19xM6T9puCOWntie1/P997aXxFsOi37JIHRWI514Hc6ZnaHGKY9xFhrU65RT6CcBEzZoGG1e6Nq+DT04ZtZQ==", + "dev": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.6.1", + "@eslint/eslintrc": "^2.1.4", + "@eslint/js": "8.56.0", + "@humanwhocodes/config-array": "^0.11.13", + "@humanwhocodes/module-importer": "^1.0.1", + "@nodelib/fs.walk": "^1.2.8", + "@ungap/structured-clone": "^1.2.0", + "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.2", - "debug": "^4.0.1", + "debug": "^4.3.2", "doctrine": "^3.0.0", - "enquirer": "^2.3.5", "escape-string-regexp": "^4.0.0", - "eslint-scope": "^5.1.1", - "eslint-utils": "^2.1.0", - "eslint-visitor-keys": "^2.0.0", - "espree": "^7.3.1", - "esquery": "^1.4.0", + "eslint-scope": "^7.2.2", + "eslint-visitor-keys": "^3.4.3", + "espree": "^9.6.1", + "esquery": "^1.4.2", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^6.0.1", - "functional-red-black-tree": "^1.0.1", - "glob-parent": "^5.1.2", - "globals": "^13.6.0", - "ignore": "^4.0.6", - "import-fresh": "^3.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "globals": "^13.19.0", + "graphemer": "^1.4.0", + "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", - "js-yaml": "^3.13.1", + "is-path-inside": "^3.0.3", + "js-yaml": "^4.1.0", "json-stable-stringify-without-jsonify": "^1.0.1", "levn": "^0.4.1", "lodash.merge": "^4.6.2", - "minimatch": "^3.0.4", + "minimatch": "^3.1.2", "natural-compare": "^1.4.0", - "optionator": "^0.9.1", - "progress": "^2.0.0", - "regexpp": "^3.1.0", - "semver": "^7.2.1", - "strip-ansi": "^6.0.0", - "strip-json-comments": "^3.1.0", - "table": "^6.0.9", - "text-table": "^0.2.0", - "v8-compile-cache": "^2.0.3" + "optionator": "^0.9.3", + "strip-ansi": "^6.0.1", + "text-table": "^0.2.0" }, "bin": { "eslint": "bin/eslint.js" }, "engines": { - "node": "^10.12.0 || >=12.0.0" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, "funding": { "url": "https://opencollective.com/eslint" } }, "node_modules/eslint-config-prettier": { - "version": "6.15.0", - "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-6.15.0.tgz", - "integrity": "sha512-a1+kOYLR8wMGustcgAjdydMsQ2A/2ipRPwRKUmfYaSxc9ZPcrku080Ctl6zrZzZNs/U82MjSv+qKREkoq3bJaw==", + "version": "8.10.0", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-8.10.0.tgz", + "integrity": "sha512-SM8AMJdeQqRYT9O9zguiruQZaN7+z+E4eAP9oiLNGKMtomwaB1E9dcgUD6ZAn/eQAb52USbvezbiljfZUhbJcg==", "dev": true, - "dependencies": { - "get-stdin": "^6.0.0" - }, "bin": { - "eslint-config-prettier-check": "bin/cli.js" + "eslint-config-prettier": "bin/cli.js" }, "peerDependencies": { - "eslint": ">=3.14.1" + "eslint": ">=7.0.0" } }, "node_modules/eslint-plugin-prettier": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-3.3.1.tgz", - "integrity": "sha512-Rq3jkcFY8RYeQLgk2cCwuc0P7SEFwDravPhsJZOQ5N4YI4DSg50NyqJ/9gdZHzQlHf8MvafSesbNJCcP/FF6pQ==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-4.2.1.tgz", + "integrity": "sha512-f/0rXLXUt0oFYs8ra4w49wYZBG5GKZpAYsJSm6rnYL5uVDjd+zowwMwVZHnAjf4edNrKpCDYfXDgmRE/Ak7QyQ==", "dev": true, "dependencies": { "prettier-linter-helpers": "^1.0.0" }, "engines": { - "node": ">=6.0.0" + "node": ">=12.0.0" }, "peerDependencies": { - "eslint": ">=5.0.0", - "prettier": ">=1.13.0" + "eslint": ">=7.28.0", + "prettier": ">=2.0.0" }, "peerDependenciesMeta": { "eslint-config-prettier": { - "optional": true - } - } - }, - "node_modules/eslint-scope": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", - "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", - "dev": true, - "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^4.1.1" - }, - "engines": { - "node": ">=8.0.0" + "optional": true + } } }, - "node_modules/eslint-utils": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/eslint-utils/-/eslint-utils-3.0.0.tgz", - "integrity": "sha512-uuQC43IGctw68pJA1RgbQS8/NP7rch6Cwd4j3ZBtgo4/8Flj4eGE7ZYSZRN3iq5pVUv6GPdW5Z1RFleo84uLDA==", + "node_modules/eslint-scope": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", + "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", "dev": true, "dependencies": { - "eslint-visitor-keys": "^2.0.0" + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" }, "engines": { - "node": "^10.0.0 || ^12.0.0 || >= 14.0.0" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, "funding": { - "url": "https://github.com/sponsors/mysticatea" - }, - "peerDependencies": { - "eslint": ">=5" + "url": "https://opencollective.com/eslint" } }, "node_modules/eslint-visitor-keys": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-2.1.0.tgz", - "integrity": "sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw==", + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", "dev": true, "engines": { - "node": ">=10" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" } }, "node_modules/eslint/node_modules/ajv": { @@ -3787,37 +3805,26 @@ "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/eslint/node_modules/eslint-utils": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/eslint-utils/-/eslint-utils-2.1.0.tgz", - "integrity": "sha512-w94dQYoauyvlDc43XnGB8lU3Zt713vNChgt4EWwhXAP2XkBvndfxF0AgIqKOOasjPIPzj9JqgwkwbCYD0/V3Zg==", + "node_modules/eslint/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", "dev": true, "dependencies": { - "eslint-visitor-keys": "^1.1.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/mysticatea" - } - }, - "node_modules/eslint/node_modules/eslint-utils/node_modules/eslint-visitor-keys": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-1.3.0.tgz", - "integrity": "sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ==", - "dev": true, - "engines": { - "node": ">=4" + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" } }, - "node_modules/eslint/node_modules/ignore": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-4.0.6.tgz", - "integrity": "sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg==", + "node_modules/eslint/node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", "dev": true, + "dependencies": { + "is-glob": "^4.0.3" + }, "engines": { - "node": ">= 4" + "node": ">=10.13.0" } }, "node_modules/eslint/node_modules/json-schema-traverse": { @@ -3826,6 +3833,18 @@ "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", "dev": true }, + "node_modules/eslint/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/esm": { "version": "3.2.25", "resolved": "https://registry.npmjs.org/esm/-/esm-3.2.25.tgz", @@ -3850,26 +3869,20 @@ } }, "node_modules/espree": { - "version": "7.3.1", - "resolved": "https://registry.npmjs.org/espree/-/espree-7.3.1.tgz", - "integrity": "sha512-v3JCNCE64umkFpmkFGqzVKsOT0tN1Zr+ueqLZfpV1Ob8e+CEgPWa+OxCoGH3tnhimMKIaBm4m/vaRpJ/krRz2g==", + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", + "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", "dev": true, "dependencies": { - "acorn": "^7.4.0", - "acorn-jsx": "^5.3.1", - "eslint-visitor-keys": "^1.3.0" + "acorn": "^8.9.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^3.4.1" }, "engines": { - "node": "^10.12.0 || >=12.0.0" - } - }, - "node_modules/espree/node_modules/eslint-visitor-keys": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-1.3.0.tgz", - "integrity": "sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ==", - "dev": true, - "engines": { - "node": ">=4" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" } }, "node_modules/esprima": { @@ -3896,15 +3909,6 @@ "node": ">=0.10" } }, - "node_modules/esquery/node_modules/estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "dev": true, - "engines": { - "node": ">=4.0" - } - }, "node_modules/esrecurse": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", @@ -3917,7 +3921,7 @@ "node": ">=4.0" } }, - "node_modules/esrecurse/node_modules/estraverse": { + "node_modules/estraverse": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", @@ -3926,15 +3930,6 @@ "node": ">=4.0" } }, - "node_modules/estraverse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", - "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", - "dev": true, - "engines": { - "node": ">=4.0" - } - }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -4067,37 +4062,38 @@ } }, "node_modules/express": { - "version": "4.17.3", - "resolved": "https://registry.npmjs.org/express/-/express-4.17.3.tgz", - "integrity": "sha512-yuSQpz5I+Ch7gFrPCk4/c+dIBKlQUxtgwqzph132bsT6qhuzss6I8cLJQz7B3rFblzd6wtcI0ZbGltH/C4LjUg==", + "version": "4.19.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.19.2.tgz", + "integrity": "sha512-5T6nhjsT+EOMzuck8JjBHARTHfMht0POzlA60WV2pMD3gyXw2LZnZ+ueGdNxG+0calOJcWKbpFcuzLZ91YWq9Q==", "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", - "body-parser": "1.19.2", + "body-parser": "1.20.2", "content-disposition": "0.5.4", "content-type": "~1.0.4", - "cookie": "0.4.2", + "cookie": "0.6.0", "cookie-signature": "1.0.6", "debug": "2.6.9", - "depd": "~1.1.2", + "depd": "2.0.0", "encodeurl": "~1.0.2", "escape-html": "~1.0.3", "etag": "~1.8.1", - "finalhandler": "~1.1.2", + "finalhandler": "1.2.0", "fresh": "0.5.2", + "http-errors": "2.0.0", "merge-descriptors": "1.0.1", "methods": "~1.1.2", - "on-finished": "~2.3.0", + "on-finished": "2.4.1", "parseurl": "~1.3.3", "path-to-regexp": "0.1.7", "proxy-addr": "~2.0.7", - "qs": "6.9.7", + "qs": "6.11.0", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", - "send": "0.17.2", - "serve-static": "1.14.2", + "send": "0.18.0", + "serve-static": "1.15.0", "setprototypeof": "1.2.0", - "statuses": "~1.5.0", + "statuses": "2.0.1", "type-is": "~1.6.18", "utils-merge": "1.0.1", "vary": "~1.1.2" @@ -4135,9 +4131,12 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" }, "node_modules/express/node_modules/qs": { - "version": "6.9.7", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.9.7.tgz", - "integrity": "sha512-IhMFgUmuNpyRfxA90umL7ByLlgRXu6tIfKPpF5TmcfRLlLCckfP/g3IQmju6jjpu+Hh8rA+2p6A27ZSPOOHdKw==", + "version": "6.11.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", + "integrity": "sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==", + "dependencies": { + "side-channel": "^1.0.4" + }, "engines": { "node": ">=0.6" }, @@ -4367,16 +4366,16 @@ } }, "node_modules/finalhandler": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.2.tgz", - "integrity": "sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.2.0.tgz", + "integrity": "sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg==", "dependencies": { "debug": "2.6.9", "encodeurl": "~1.0.2", "escape-html": "~1.0.3", - "on-finished": "~2.3.0", + "on-finished": "2.4.1", "parseurl": "~1.3.3", - "statuses": "~1.5.0", + "statuses": "2.0.1", "unpipe": "~1.0.0" }, "engines": { @@ -4862,12 +4861,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/functional-red-black-tree": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz", - "integrity": "sha512-dsKNQNdj6xA3T+QlADDA7mOSlX0qiMINjn0cgr+eGHGsbSHzTabcIogz2+p/iqP1Xs6EP/sS2SbqH+brGTbq0g==", - "dev": true - }, "node_modules/functions-have-names": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", @@ -4929,15 +4922,6 @@ "node": ">=8.0.0" } }, - "node_modules/get-stdin": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/get-stdin/-/get-stdin-6.0.0.tgz", - "integrity": "sha512-jp4tHawyV7+fkkSKyvjuLZswblUtz+SQKzSWnBbii16BuZksJlU1wuBYXY75r+duh/llF1ur6oNwi+2ZzjKZ7g==", - "dev": true, - "engines": { - "node": ">=4" - } - }, "node_modules/get-symbol-description": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.0.2.tgz", @@ -5020,6 +5004,16 @@ "node": ">= 0.10" } }, + "node_modules/glob-stream/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, "node_modules/glob-stream/node_modules/glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", @@ -5062,6 +5056,18 @@ "node": ">=0.10.0" } }, + "node_modules/glob-stream/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/glob-watcher": { "version": "5.0.5", "resolved": "https://registry.npmjs.org/glob-watcher/-/glob-watcher-5.0.5.tgz", @@ -5340,14 +5346,6 @@ "node": ">=0.10.0" } }, - "node_modules/glob/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, "node_modules/glob/node_modules/minimatch": { "version": "5.1.6", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", @@ -5480,6 +5478,12 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "dev": true }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true + }, "node_modules/gulp": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/gulp/-/gulp-4.0.2.tgz", @@ -5530,18 +5534,6 @@ "node": ">= 0.10" } }, - "node_modules/gulp-cli/node_modules/ansi-colors": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-1.1.0.tgz", - "integrity": "sha512-SFKX67auSNoVR38N3L+nvsPjOE0bybKTYbkf5tRvushrAPQ9V75huw0ZxBkKVeRU9kqH3d6HA4xTckbwZ4ixmA==", - "dev": true, - "dependencies": { - "ansi-wrap": "^0.1.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/gulp-cli/node_modules/ansi-regex": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", @@ -5916,18 +5908,18 @@ "dev": true }, "node_modules/http-errors": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.8.1.tgz", - "integrity": "sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", "dependencies": { - "depd": "~1.1.2", + "depd": "2.0.0", "inherits": "2.0.4", "setprototypeof": "1.2.0", - "statuses": ">= 1.5.0 < 2", + "statuses": "2.0.1", "toidentifier": "1.0.1" }, "engines": { - "node": ">= 0.6" + "node": ">= 0.8" } }, "node_modules/http-proxy": { @@ -6786,12 +6778,12 @@ "dev": true }, "node_modules/js-yaml": { - "version": "3.14.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", - "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, "dependencies": { - "argparse": "^1.0.7", - "esprima": "^4.0.0" + "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" @@ -7241,12 +7233,6 @@ "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==" }, - "node_modules/lodash.truncate": { - "version": "4.4.2", - "resolved": "https://registry.npmjs.org/lodash.truncate/-/lodash.truncate-4.4.2.tgz", - "integrity": "sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==", - "dev": true - }, "node_modules/log-symbols": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", @@ -7264,15 +7250,19 @@ } }, "node_modules/logform": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/logform/-/logform-2.3.2.tgz", - "integrity": "sha512-V6JiPThZzTsbVRspNO6TmHkR99oqYTs8fivMBYQkjZj6rxW92KxtDCPE6IkAk1DNBnYKNkjm4jYBm6JDUcyhOA==", + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/logform/-/logform-2.6.0.tgz", + "integrity": "sha512-1ulHeNPp6k/LD8H91o7VYFBng5i1BDE7HoKxVbZiGFidS1Rj65qcywLxX+pVfAPoQJEjRdvKcusKwOupHCVOVQ==", "dependencies": { - "colors": "1.4.0", + "@colors/colors": "1.6.0", + "@types/triple-beam": "^1.3.2", "fecha": "^4.2.0", "ms": "^2.1.1", - "safe-stable-stringify": "^1.1.0", + "safe-stable-stringify": "^2.3.1", "triple-beam": "^1.3.0" + }, + "engines": { + "node": ">= 12.0.0" } }, "node_modules/loupe": { @@ -7637,15 +7627,18 @@ } }, "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "9.0.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.4.tgz", + "integrity": "sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw==", "dev": true, "dependencies": { - "brace-expansion": "^1.1.7" + "brace-expansion": "^2.0.1" }, "engines": { - "node": "*" + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/minimist": { @@ -7724,21 +7717,6 @@ "node": ">=6" } }, - "node_modules/mocha/node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true - }, - "node_modules/mocha/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", - "dev": true, - "dependencies": { - "balanced-match": "^1.0.0" - } - }, "node_modules/mocha/node_modules/chokidar": { "version": "3.5.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", @@ -7777,18 +7755,6 @@ "wrap-ansi": "^7.0.0" } }, - "node_modules/mocha/node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dev": true, - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, "node_modules/mocha/node_modules/minimatch": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.0.1.tgz", @@ -7989,9 +7955,9 @@ } }, "node_modules/nise/node_modules/path-to-regexp": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.2.1.tgz", - "integrity": "sha512-JLyh7xT1kizaEvcaXOQwOc2/Yhw6KZOvPf1S8401UyLk86CU79LN3vl7ztXGm/pZ+YjoyAJ4rxmHwbkBXJX+yw==", + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.2.2.tgz", + "integrity": "sha512-GQX3SSMokngb36+whdpRXE+3f9V8UzyAorlYvOGx87ufGHehNTn5lCxrKtLyZ4Yl/wEKnNnr98ZzOwwDZV5ogw==", "dev": true }, "node_modules/node-fs": { @@ -8056,6 +8022,16 @@ "url": "https://opencollective.com/nodemon" } }, + "node_modules/nodemon/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, "node_modules/nodemon/node_modules/has-flag": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", @@ -8065,6 +8041,18 @@ "node": ">=4" } }, + "node_modules/nodemon/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/nodemon/node_modules/supports-color": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", @@ -8171,6 +8159,16 @@ "node": ">=4" } }, + "node_modules/npm-run-all/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, "node_modules/npm-run-all/node_modules/chalk": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", @@ -8234,6 +8232,18 @@ "node": ">=4" } }, + "node_modules/npm-run-all/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/npm-run-all/node_modules/path-key": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-2.0.1.tgz", @@ -8347,6 +8357,16 @@ "node": ">=8.9" } }, + "node_modules/nyc/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, "node_modules/nyc/node_modules/find-up": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", @@ -8392,6 +8412,18 @@ "node": ">=8" } }, + "node_modules/nyc/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/nyc/node_modules/p-limit": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", @@ -8606,9 +8638,9 @@ "dev": true }, "node_modules/on-finished": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", - "integrity": "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==", + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", "dependencies": { "ee-first": "1.1.1" }, @@ -8660,6 +8692,26 @@ "ts-log": "^2.1.4" } }, + "node_modules/openapi-framework/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/openapi-framework/node_modules/js-yaml": { + "version": "3.14.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", + "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/openapi-jsonschema-parameters": { "version": "9.3.1", "resolved": "https://registry.npmjs.org/openapi-jsonschema-parameters/-/openapi-jsonschema-parameters-9.3.1.tgz", @@ -8829,11 +8881,6 @@ "node": ">=8" } }, - "node_modules/packet-reader": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/packet-reader/-/packet-reader-1.0.0.tgz", - "integrity": "sha512-HAKu/fG3HpHFO0AA8WE8q2g+gBJaZ9MG7fcKk+IJPLTGAD6Psw4443l+9DGRbOIh3/aXr7Phy0TjilYivJo5XQ==" - }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -9001,23 +9048,24 @@ } }, "node_modules/pg": { - "version": "8.7.3", - "resolved": "https://registry.npmjs.org/pg/-/pg-8.7.3.tgz", - "integrity": "sha512-HPmH4GH4H3AOprDJOazoIcpI49XFsHCe8xlrjHkWiapdbHK+HLtbm/GQzXYAZwmPju/kzKhjaSfMACG+8cgJcw==", - "dependencies": { - "buffer-writer": "2.0.0", - "packet-reader": "1.0.0", - "pg-connection-string": "^2.5.0", - "pg-pool": "^3.5.1", - "pg-protocol": "^1.5.0", + "version": "8.11.5", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.11.5.tgz", + "integrity": "sha512-jqgNHSKL5cbDjFlHyYsCXmQDrfIX/3RsNwYqpd4N0Kt8niLuNoRNH+aazv6cOd43gPh9Y4DjQCtb+X0MH0Hvnw==", + "dependencies": { + "pg-connection-string": "^2.6.4", + "pg-pool": "^3.6.2", + "pg-protocol": "^1.6.1", "pg-types": "^2.1.0", "pgpass": "1.x" }, "engines": { "node": ">= 8.0.0" }, + "optionalDependencies": { + "pg-cloudflare": "^1.1.1" + }, "peerDependencies": { - "pg-native": ">=2.0.0" + "pg-native": ">=3.0.1" }, "peerDependenciesMeta": { "pg-native": { @@ -9025,6 +9073,12 @@ } } }, + "node_modules/pg-cloudflare": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.1.1.tgz", + "integrity": "sha512-xWPagP/4B6BgFO+EKz3JONXv3YDgvkbVrGw2mTo3D6tVDQRh1e7cqVGvyR3BE+eQgAvx1XhW/iEASj4/jCWl3Q==", + "optional": true + }, "node_modules/pg-connection-string": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.6.1.tgz", @@ -9061,6 +9115,29 @@ "integrity": "sha512-jPIlvgoD63hrEuihvIg+tJhoGjUsLPn6poJY9N5CnlPd91c2T18T/9zBtLxZSb1EhYxBRoZJtzScCaWlYLtktg==" }, "node_modules/pg-types": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-4.0.2.tgz", + "integrity": "sha512-cRL3JpS3lKMGsKaWndugWQoLOCoP+Cic8oseVcbr0qhPzYD5DWXK+RZ9LY9wxRf7RQia4SCwQlXk0q6FCPrVng==", + "dev": true, + "dependencies": { + "pg-int8": "1.0.1", + "pg-numeric": "1.0.2", + "postgres-array": "~3.0.1", + "postgres-bytea": "~3.0.0", + "postgres-date": "~2.1.0", + "postgres-interval": "^3.0.0", + "postgres-range": "^1.1.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/pg/node_modules/pg-connection-string": { + "version": "2.6.4", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.6.4.tgz", + "integrity": "sha512-v+Z7W/0EO707aNMaAEfiGnGL9sxxumwLl2fJvCQtMn9Fxsg+lPpPkdcyBSv/KFgpGdYkMfn+EI1Or2EHjpgLCA==" + }, + "node_modules/pg/node_modules/pg-types": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", @@ -9075,6 +9152,41 @@ "node": ">=4" } }, + "node_modules/pg/node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "engines": { + "node": ">=4" + } + }, + "node_modules/pg/node_modules/postgres-bytea": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.0.tgz", + "integrity": "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pg/node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pg/node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/pgpass": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", @@ -9221,18 +9333,6 @@ "node": ">= 0.10" } }, - "node_modules/plugin-error/node_modules/ansi-colors": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-1.1.0.tgz", - "integrity": "sha512-SFKX67auSNoVR38N3L+nvsPjOE0bybKTYbkf5tRvushrAPQ9V75huw0ZxBkKVeRU9kqH3d6HA4xTckbwZ4ixmA==", - "dev": true, - "dependencies": { - "ansi-wrap": "^0.1.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/posix-character-classes": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/posix-character-classes/-/posix-character-classes-0.1.1.tgz", @@ -9251,38 +9351,42 @@ } }, "node_modules/postgres-array": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", - "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-3.0.2.tgz", + "integrity": "sha512-6faShkdFugNQCLwucjPcY5ARoW1SlbnrZjmGl0IrrqewpvxvhSLHimCVzqeuULCbG0fQv7Dtk1yDbG3xv7Veog==", + "dev": true, "engines": { - "node": ">=4" + "node": ">=12" } }, "node_modules/postgres-bytea": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.0.tgz", - "integrity": "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-3.0.0.tgz", + "integrity": "sha512-CNd4jim9RFPkObHSjVHlVrxoVQXz7quwNFpz7RY1okNNme49+sVyiTvTRobiLV548Hx/hb1BG+iE7h9493WzFw==", + "dev": true, + "dependencies": { + "obuf": "~1.1.2" + }, "engines": { - "node": ">=0.10.0" + "node": ">= 6" } }, "node_modules/postgres-date": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", - "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-2.1.0.tgz", + "integrity": "sha512-K7Juri8gtgXVcDfZttFKVmhglp7epKb1K4pgrkLxehjqkrgPhfG6OO8LHLkfaqkbpjNRnra018XwAr1yQFWGcA==", + "dev": true, "engines": { - "node": ">=0.10.0" + "node": ">=12" } }, "node_modules/postgres-interval": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", - "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", - "dependencies": { - "xtend": "^4.0.0" - }, + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-3.0.0.tgz", + "integrity": "sha512-BSNDnbyZCXSxgA+1f5UU2GmwhoI0aU5yMxRGO8CdFEcY2BQF9xm/7MqKnYoM1nJDk8nONNWDk9WeSmePFhQdlw==", + "dev": true, "engines": { - "node": ">=0.10.0" + "node": ">=12" } }, "node_modules/postgres-range": { @@ -9301,15 +9405,18 @@ } }, "node_modules/prettier": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.2.1.tgz", - "integrity": "sha512-PqyhM2yCjg/oKkFPtTGUojv7gnZAoG80ttl45O6x2Ug/rMJw4wcc9k6aaf2hibP7BGVCCM33gZoGjyvt9mm16Q==", + "version": "2.8.8", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.8.tgz", + "integrity": "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==", "dev": true, "bin": { "prettier": "bin-prettier.js" }, "engines": { "node": ">=10.13.0" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" } }, "node_modules/prettier-linter-helpers": { @@ -9325,13 +9432,23 @@ } }, "node_modules/prettier-plugin-organize-imports": { - "version": "2.3.4", - "resolved": "https://registry.npmjs.org/prettier-plugin-organize-imports/-/prettier-plugin-organize-imports-2.3.4.tgz", - "integrity": "sha512-R8o23sf5iVL/U71h9SFUdhdOEPsi3nm42FD/oDYIZ2PQa4TNWWuWecxln6jlIQzpZTDMUeO1NicJP6lLn2TtRw==", + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/prettier-plugin-organize-imports/-/prettier-plugin-organize-imports-3.2.4.tgz", + "integrity": "sha512-6m8WBhIp0dfwu0SkgfOxJqh+HpdyfqSSLfKKRZSFbDuEQXDDndb8fTpRWkUrX/uBenkex3MgnVk0J3b3Y5byog==", "dev": true, "peerDependencies": { + "@volar/vue-language-plugin-pug": "^1.0.4", + "@volar/vue-typescript": "^1.0.4", "prettier": ">=2.0", "typescript": ">=2.9" + }, + "peerDependenciesMeta": { + "@volar/vue-language-plugin-pug": { + "optional": true + }, + "@volar/vue-typescript": { + "optional": true + } } }, "node_modules/pretty-hrtime": { @@ -9360,15 +9477,6 @@ "node": ">=8" } }, - "node_modules/progress": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", - "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", - "dev": true, - "engines": { - "node": ">=0.4.0" - } - }, "node_modules/prompt": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/prompt/-/prompt-1.3.0.tgz", @@ -9384,10 +9492,10 @@ "node": ">= 6.0.0" } }, - "node_modules/prompt/node_modules/colors": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/colors/-/colors-1.0.3.tgz", - "integrity": "sha512-pFGrxThWcWQ2MsAz6RtgeWe4NK2kUE1WfsrvvlctdII745EW9I0yflqhe7++M5LEc7bV2c/9/5zc8sFcpL0Drw==", + "node_modules/prompt/node_modules/@colors/colors": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", + "integrity": "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==", "engines": { "node": ">=0.1.90" } @@ -9474,11 +9582,11 @@ } }, "node_modules/qs": { - "version": "6.10.4", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.10.4.tgz", - "integrity": "sha512-OQiU+C+Ds5qiH91qh/mg0w+8nwQuLjM4F4M/PbmhDOoYehPh+Fb0bDjtR1sOvy7YKxvj28Y/M0PhP5uVX0kB+g==", + "version": "6.12.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.12.0.tgz", + "integrity": "sha512-trVZiI6RMOkO476zLGaBIzszOdFPnCCXHPG9kn0yuS1uz6xdVxPfZdB3vUig9pxPFDM9BRAgz/YUIVQ1/vuiUg==", "dependencies": { - "side-channel": "^1.0.4" + "side-channel": "^1.0.6" }, "engines": { "node": ">=0.6" @@ -9534,12 +9642,12 @@ } }, "node_modules/raw-body": { - "version": "2.4.3", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.4.3.tgz", - "integrity": "sha512-UlTNLIcu0uzb4D2f4WltY6cVjLi+/jEN4lgEUj3E04tpMDpUlkBo/eSn6zou9hum2VMNpCCUone0O0WeJim07g==", + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", + "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", "dependencies": { "bytes": "3.1.2", - "http-errors": "1.8.1", + "http-errors": "2.0.0", "iconv-lite": "0.4.24", "unpipe": "1.0.0" }, @@ -9794,18 +9902,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/regexpp": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/regexpp/-/regexpp-3.2.0.tgz", - "integrity": "sha512-pq2bWo9mVD43nbts2wGv17XLiNLya+GklZ8kaDLV2Z08gDCsGpnKn9BFMepvWuHCbyVvY7J5o5+BVvoQbmlJLg==", - "dev": true, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/mysticatea" - } - }, "node_modules/release-zalgo": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/release-zalgo/-/release-zalgo-1.0.0.tgz", @@ -10027,6 +10123,16 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/rimraf/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, "node_modules/rimraf/node_modules/glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", @@ -10047,6 +10153,18 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/rimraf/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -10140,9 +10258,12 @@ } }, "node_modules/safe-stable-stringify": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-1.1.1.tgz", - "integrity": "sha512-ERq4hUjKDbJfE4+XtZLFPCDi8Vb1JqaxAPTxWFLBx8XcAlf9Bda/ZJdVezs/NAfsMQScyIlUMx+Yeu7P7rx5jw==" + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.4.3.tgz", + "integrity": "sha512-e2bDA2WJT0wxseVd4lsDP4+3ONX6HpMXQa1ZhFQ7SU+GjvORCmShbCMltrtIDfkYhVHrOcPtj+KhmDBdPdZD1g==", + "engines": { + "node": ">=10" + } }, "node_modules/safer-buffer": { "version": "2.1.2", @@ -10158,7 +10279,6 @@ "version": "7.6.0", "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz", "integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==", - "dev": true, "dependencies": { "lru-cache": "^6.0.0" }, @@ -10185,7 +10305,6 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, "dependencies": { "yallist": "^4.0.0" }, @@ -10196,27 +10315,26 @@ "node_modules/semver/node_modules/yallist": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" }, "node_modules/send": { - "version": "0.17.2", - "resolved": "https://registry.npmjs.org/send/-/send-0.17.2.tgz", - "integrity": "sha512-UJYB6wFSJE3G00nEivR5rgWp8c2xXvJ3OPWPhmuteU0IKj8nKbG3DrjiOmLwpnHGYWAVwA69zmTm++YG0Hmwww==", + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/send/-/send-0.18.0.tgz", + "integrity": "sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==", "dependencies": { "debug": "2.6.9", - "depd": "~1.1.2", - "destroy": "~1.0.4", + "depd": "2.0.0", + "destroy": "1.2.0", "encodeurl": "~1.0.2", "escape-html": "~1.0.3", "etag": "~1.8.1", "fresh": "0.5.2", - "http-errors": "1.8.1", + "http-errors": "2.0.0", "mime": "1.6.0", "ms": "2.1.3", - "on-finished": "~2.3.0", + "on-finished": "2.4.1", "range-parser": "~1.2.1", - "statuses": "~1.5.0" + "statuses": "2.0.1" }, "engines": { "node": ">= 0.8.0" @@ -10261,14 +10379,14 @@ } }, "node_modules/serve-static": { - "version": "1.14.2", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.14.2.tgz", - "integrity": "sha512-+TMNA9AFxUEGuC0z2mevogSnn9MXKb4fa7ngeRMJaaGv8vTwnIEkKi+QGvPt33HSnf8pRS+WGM0EbMtCJLKMBQ==", + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.15.0.tgz", + "integrity": "sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g==", "dependencies": { "encodeurl": "~1.0.2", "escape-html": "~1.0.3", "parseurl": "~1.3.3", - "send": "0.17.2" + "send": "0.18.0" }, "engines": { "node": ">= 0.8.0" @@ -10487,23 +10605,6 @@ "node": ">=8" } }, - "node_modules/slice-ansi": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz", - "integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==", - "dev": true, - "dependencies": { - "ansi-styles": "^4.0.0", - "astral-regex": "^2.0.0", - "is-fullwidth-code-point": "^3.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/slice-ansi?sponsor=1" - } - }, "node_modules/snapdragon": { "version": "0.8.2", "resolved": "https://registry.npmjs.org/snapdragon/-/snapdragon-0.8.2.tgz", @@ -10847,11 +10948,11 @@ } }, "node_modules/statuses": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", - "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", "engines": { - "node": ">= 0.6" + "node": ">= 0.8" } }, "node_modules/stream-exhaust": { @@ -11038,38 +11139,22 @@ } }, "node_modules/swagger-ui-dist": { - "version": "5.13.0", - "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.13.0.tgz", - "integrity": "sha512-uaWhh6j18IIs5tOX0arvIBnVINAzpTXaQXkr7qAk8zoupegJVg0UU/5+S/FgsgVCnzVsJ9d7QLjIxkswEeTg0Q==" + "version": "5.15.1", + "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.15.1.tgz", + "integrity": "sha512-Et/WY0NFdKj8sUBOyEx5P3VybsvGl7bo/y9JvgQ22TkH1a/KscQ0ZiQST2YeJ3cwCrIjYTbHbt165fkku0y1Ig==" }, "node_modules/swagger-ui-express": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/swagger-ui-express/-/swagger-ui-express-4.3.0.tgz", - "integrity": "sha512-jN46SEEe9EoXa3ZgZoKgnSF6z0w3tnM1yqhO4Y+Q4iZVc8JOQB960EZpIAz6rNROrDApVDwcMHR0mhlnc/5Omw==", + "version": "4.6.3", + "resolved": "https://registry.npmjs.org/swagger-ui-express/-/swagger-ui-express-4.6.3.tgz", + "integrity": "sha512-CDje4PndhTD2HkgyKH3pab+LKspDeB/NhPN2OF1j+piYIamQqBYwAXWESOT1Yju2xFg51bRW9sUng2WxDjzArw==", "dependencies": { - "swagger-ui-dist": ">=4.1.3" + "swagger-ui-dist": ">=4.11.0" }, "engines": { "node": ">= v0.10.32" }, "peerDependencies": { - "express": ">=4.0.0" - } - }, - "node_modules/table": { - "version": "6.8.2", - "resolved": "https://registry.npmjs.org/table/-/table-6.8.2.tgz", - "integrity": "sha512-w2sfv80nrAh2VCbqR5AK27wswXhqcck2AhfnNW76beQXskGZ1V12GwS//yYVa3d3fcvAip2OUnbDAjW2k3v9fA==", - "dev": true, - "dependencies": { - "ajv": "^8.0.1", - "lodash.truncate": "^4.4.2", - "slice-ansi": "^4.0.0", - "string-width": "^4.2.3", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=10.0.0" + "express": ">=4.0.0 || >=5.0.0-beta" } }, "node_modules/tarn": { @@ -11094,6 +11179,16 @@ "node": ">=8" } }, + "node_modules/test-exclude/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, "node_modules/test-exclude/node_modules/glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", @@ -11114,6 +11209,18 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/test-exclude/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/text-hex": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz", @@ -11294,6 +11401,18 @@ "node": ">= 14.0.0" } }, + "node_modules/ts-api-utils": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.3.0.tgz", + "integrity": "sha512-UQMIo7pb8WRomKR1/+MFVLTroIvDVtMX3K6OUir8ynLyzB8Jeriont2bTAtmNPa1ekAgN7YPDyf6V+ygrdU+eQ==", + "dev": true, + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "typescript": ">=4.2.0" + } + }, "node_modules/ts-log": { "version": "2.2.5", "resolved": "https://registry.npmjs.org/ts-log/-/ts-log-2.2.5.tgz", @@ -11403,18 +11522,6 @@ } } }, - "node_modules/ts-node/node_modules/acorn": { - "version": "8.11.3", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz", - "integrity": "sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==", - "dev": true, - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, "node_modules/ts-node/node_modules/diff": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", @@ -11460,27 +11567,6 @@ "node": ">=4" } }, - "node_modules/tslib": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", - "dev": true - }, - "node_modules/tsutils": { - "version": "3.21.0", - "resolved": "https://registry.npmjs.org/tsutils/-/tsutils-3.21.0.tgz", - "integrity": "sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==", - "dev": true, - "dependencies": { - "tslib": "^1.8.1" - }, - "engines": { - "node": ">= 6" - }, - "peerDependencies": { - "typescript": ">=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta" - } - }, "node_modules/tunnel-ssh": { "version": "4.1.6", "resolved": "https://registry.npmjs.org/tunnel-ssh/-/tunnel-ssh-4.1.6.tgz", @@ -11936,12 +12022,6 @@ "uuid": "dist/bin/uuid" } }, - "node_modules/v8-compile-cache": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/v8-compile-cache/-/v8-compile-cache-2.4.0.tgz", - "integrity": "sha512-ocyWc3bAHBB/guyqJQVI5o4BZkPhznPYUG2ea80Gond/BgNWpap8TOmLSeeQG7bnh2KMISxskdADG59j7zruhw==", - "dev": true - }, "node_modules/v8-compile-cache-lib": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", @@ -12132,22 +12212,24 @@ } }, "node_modules/winston": { - "version": "3.3.4", - "resolved": "https://registry.npmjs.org/winston/-/winston-3.3.4.tgz", - "integrity": "sha512-zWJrfmqE+2IXtVJ125vxpA2m303TjwchLhfRbcnma7c76Qd4pv80JIp37l8uGnWbCoG4X6PMz3vAQeh+vH1CtA==", + "version": "3.13.0", + "resolved": "https://registry.npmjs.org/winston/-/winston-3.13.0.tgz", + "integrity": "sha512-rwidmA1w3SE4j0E5MuIufFhyJPBDG7Nu71RkZor1p2+qHvJSZ9GYDA81AyleQcZbh/+V6HjeBdfnTZJm9rSeQQ==", "dependencies": { + "@colors/colors": "^1.6.0", "@dabh/diagnostics": "^2.0.2", "async": "^3.2.3", "is-stream": "^2.0.0", - "logform": "^2.3.2", + "logform": "^2.4.0", "one-time": "^1.0.0", "readable-stream": "^3.4.0", + "safe-stable-stringify": "^2.3.1", "stack-trace": "0.0.x", "triple-beam": "^1.3.0", - "winston-transport": "^4.4.2" + "winston-transport": "^4.7.0" }, "engines": { - "node": ">= 6.4.0" + "node": ">= 12.0.0" } }, "node_modules/winston-transport": { diff --git a/api/package.json b/api/package.json index 93704170f6..5d58ce0207 100644 --- a/api/package.json +++ b/api/package.json @@ -18,8 +18,8 @@ "coverage": "nyc mocha", "lint": "eslint . --ignore-pattern 'node_modules' --ext .ts", "lint-fix": "eslint . --fix --ignore-pattern 'node_modules' --ext .ts", - "format": "prettier --check \"./src/**/*.{js,jsx,ts,tsx,css,scss}\"", - "format-fix": "prettier --write \"./src/**/*.{js,jsx,ts,tsx,json,css,scss}\"", + "format": "prettier --loglevel=warn --check \"./src/**/*.{js,jsx,ts,tsx,css,scss}\"", + "format-fix": "prettier --loglevel=warn --write \"./src/**/*.{js,jsx,ts,tsx,json,css,scss}\"", "fix": "npm-run-all -l -s lint-fix format-fix" }, "engines": { @@ -87,21 +87,21 @@ "@types/uuid": "^8.3.1", "@types/xml2js": "^0.4.9", "@types/yamljs": "^0.2.31", - "@typescript-eslint/eslint-plugin": "^4.33.0", - "@typescript-eslint/parser": "^4.33.0", + "@typescript-eslint/eslint-plugin": "~7.6.0", + "@typescript-eslint/parser": "~7.6.0", "chai": "^4.3.4", "del": "^6.0.0", - "eslint": "^7.32.0", - "eslint-config-prettier": "^6.15.0", - "eslint-plugin-prettier": "^3.3.1", + "eslint": "~8.56.0", + "eslint-config-prettier": "~8.10.0", + "eslint-plugin-prettier": "~4.2.1", "gulp": "^4.0.2", "gulp-typescript": "^5.0.1", "mocha": "^10.4.0", "nodemon": "^3.1.0", "npm-run-all": "^4.1.5", "nyc": "^15.1.0", - "prettier": "^2.2.1", - "prettier-plugin-organize-imports": "^2.3.4", + "prettier": "^2.8.8", + "prettier-plugin-organize-imports": "^3.2.4", "sinon": "^17.0.1", "sinon-chai": "^3.7.0", "ts-mocha": "^10.0.0", diff --git a/api/src/__mocks__/db.ts b/api/src/__mocks__/db.ts index 023b1d1f44..dffb914469 100644 --- a/api/src/__mocks__/db.ts +++ b/api/src/__mocks__/db.ts @@ -27,13 +27,13 @@ export const registerMockDBConnection = (config?: Partial): IDBCo export const getMockDBConnection = (config?: Partial): IDBConnection => { return { systemUserId: () => { - return (null as unknown) as number; + return null as unknown as number; }, systemUserGUID: () => { - return (null as unknown) as string; + return null as unknown as string; }, systemUserIdentifier: () => { - return (null as unknown) as string; + return null as unknown as string; }, open: async () => { // do nothing @@ -48,13 +48,13 @@ export const getMockDBConnection = (config?: Partial): IDBConnect // do nothing }, query: async () => { - return (undefined as unknown) as QueryResult; + return undefined as unknown as QueryResult; }, sql: async () => { - return (undefined as unknown) as QueryResult; + return undefined as unknown as QueryResult; }, knex: async () => { - return (undefined as unknown) as QueryResult; + return undefined as unknown as QueryResult; }, ...config }; diff --git a/api/src/app.ts b/api/src/app.ts index ff713486ff..375707833c 100644 --- a/api/src/app.ts +++ b/api/src/app.ts @@ -104,7 +104,7 @@ const openAPIFramework = initialize({ return authenticateRequestOptional(req); } }, - errorTransformer: function (openapiError: object, ajvError: object): object { + errorTransformer: function (_, ajvError: object): object { // Transform openapi-request-validator and openapi-response-validator errors defaultLog.error({ label: 'errorTransformer', message: 'ajvError', ajvError }); return ajvError; diff --git a/api/src/database/db-utils.test.ts b/api/src/database/db-utils.test.ts index 65a8f12d75..a64c52381a 100644 --- a/api/src/database/db-utils.test.ts +++ b/api/src/database/db-utils.test.ts @@ -40,10 +40,9 @@ function zodImplements() { return { with: < - Schema extends ZodImplements & - { - [unknownKey in Exclude]: never; - } + Schema extends ZodImplements & { + [unknownKey in Exclude]: never; + } >( schema: Schema ) => z.object(schema) diff --git a/api/src/database/db-utils.ts b/api/src/database/db-utils.ts index 465955fc6e..87bf71ef75 100644 --- a/api/src/database/db-utils.ts +++ b/api/src/database/db-utils.ts @@ -29,16 +29,16 @@ type GenericizedKeycloakUserInformation = { * @param fn the function to be wrapped * @returns Promise A Promise with the wrapped functions return value */ -export const asyncErrorWrapper = ( - fn: (...args: WrapperArgs) => Promise -) => async (...args: WrapperArgs): Promise => { - try { - // asyncErrorWrapper must return the awaited promise, and cannot simply `return fn(...args)`. - return await fn(...args); - } catch (err) { - throw parseError(err); - } -}; +export const asyncErrorWrapper = + (fn: (...args: WrapperArgs) => Promise) => + async (...args: WrapperArgs): Promise => { + try { + // asyncErrorWrapper must return the awaited promise, and cannot simply `return fn(...args)`. + return await fn(...args); + } catch (err) { + throw parseError(err); + } + }; /** * A synchronous wrapper function that will catch any exceptions thrown by the wrapped function @@ -46,15 +46,15 @@ export const asyncErrorWrapper = ( * @param fn the function to be wrapped * @returns WrapperReturn The wrapped functions return value */ -export const syncErrorWrapper = ( - fn: (...args: WrapperArgs) => WrapperReturn -) => (...args: WrapperArgs): WrapperReturn => { - try { - return fn(...args); - } catch (err) { - throw parseError(err); - } -}; +export const syncErrorWrapper = + (fn: (...args: WrapperArgs) => WrapperReturn) => + (...args: WrapperArgs): WrapperReturn => { + try { + return fn(...args); + } catch (err) { + throw parseError(err); + } + }; /** * This function parses the passed in error and translates them into a human readable error diff --git a/api/src/database/db.test.ts b/api/src/database/db.test.ts index c0897e09aa..2ef4d1fb5d 100644 --- a/api/src/database/db.test.ts +++ b/api/src/database/db.test.ts @@ -43,7 +43,7 @@ describe('db', () => { describe('getDBConnection', () => { it('throws an error if keycloak token is undefined', () => { try { - getDBConnection((null as unknown) as KeycloakUserInformation); + getDBConnection(null as unknown as KeycloakUserInformation); expect.fail(); } catch (actualError) { @@ -92,7 +92,7 @@ describe('db', () => { describe('open', () => { describe('when not previously called', () => { it('opens a new connection, sets the user context, and sends a `BEGIN` query', async () => { - const getDBPoolStub = sinonSandbox.stub(db, 'getDBPool').returns((mockPool as unknown) as pg.Pool); + const getDBPoolStub = sinonSandbox.stub(db, 'getDBPool').returns(mockPool as unknown as pg.Pool); await connection.open(); @@ -105,7 +105,7 @@ describe('db', () => { describe('when previously called', () => { it('does nothing', async () => { - const getDBPoolStub = sinonSandbox.stub(db, 'getDBPool').returns((mockPool as unknown) as pg.Pool); + const getDBPoolStub = sinonSandbox.stub(db, 'getDBPool').returns(mockPool as unknown as pg.Pool); // call first time await connection.open(); @@ -158,7 +158,7 @@ describe('db', () => { describe('when a connection is open', () => { describe('when not previously called', () => { it('releases the open connection', async () => { - sinonSandbox.stub(db, 'getDBPool').returns((mockPool as unknown) as pg.Pool); + sinonSandbox.stub(db, 'getDBPool').returns(mockPool as unknown as pg.Pool); await connection.open(); @@ -170,7 +170,7 @@ describe('db', () => { describe('when previously called', () => { it('does not attempt to release a connection', async () => { - sinonSandbox.stub(db, 'getDBPool').returns((mockPool as unknown) as pg.Pool); + sinonSandbox.stub(db, 'getDBPool').returns(mockPool as unknown as pg.Pool); await connection.open(); @@ -200,7 +200,7 @@ describe('db', () => { describe('commit', () => { describe('when a connection is open', () => { it('sends a `COMMIT` query', async () => { - sinonSandbox.stub(db, 'getDBPool').returns((mockPool as unknown) as pg.Pool); + sinonSandbox.stub(db, 'getDBPool').returns(mockPool as unknown as pg.Pool); await connection.open(); @@ -212,7 +212,7 @@ describe('db', () => { describe('when a connection is not open', () => { it('throws an error', async () => { - sinonSandbox.stub(db, 'getDBPool').returns((mockPool as unknown) as pg.Pool); + sinonSandbox.stub(db, 'getDBPool').returns(mockPool as unknown as pg.Pool); let expectedError: ApiExecuteSQLError; try { @@ -239,7 +239,7 @@ describe('db', () => { describe('rollback', () => { describe('when a connection is open', () => { it('sends a `ROLLBACK` query', async () => { - sinonSandbox.stub(db, 'getDBPool').returns((mockPool as unknown) as pg.Pool); + sinonSandbox.stub(db, 'getDBPool').returns(mockPool as unknown as pg.Pool); await connection.open(); @@ -251,7 +251,7 @@ describe('db', () => { describe('when a connection is not open', () => { it('throws an error', async () => { - sinonSandbox.stub(db, 'getDBPool').returns((mockPool as unknown) as pg.Pool); + sinonSandbox.stub(db, 'getDBPool').returns(mockPool as unknown as pg.Pool); let expectedError: ApiExecuteSQLError; try { @@ -278,7 +278,7 @@ describe('db', () => { describe('query', () => { describe('when a connection is open', () => { it('sends a query with values', async () => { - sinonSandbox.stub(db, 'getDBPool').returns((mockPool as unknown) as pg.Pool); + sinonSandbox.stub(db, 'getDBPool').returns(mockPool as unknown as pg.Pool); await connection.open(); @@ -288,7 +288,7 @@ describe('db', () => { }); it('sends a query with empty values', async () => { - sinonSandbox.stub(db, 'getDBPool').returns((mockPool as unknown) as pg.Pool); + sinonSandbox.stub(db, 'getDBPool').returns(mockPool as unknown as pg.Pool); await connection.open(); @@ -300,7 +300,7 @@ describe('db', () => { describe('when a connection is not open', () => { it('throws an error', async () => { - sinonSandbox.stub(db, 'getDBPool').returns((mockPool as unknown) as pg.Pool); + sinonSandbox.stub(db, 'getDBPool').returns(mockPool as unknown as pg.Pool); let expectedError: ApiExecuteSQLError; try { @@ -327,7 +327,7 @@ describe('db', () => { describe('sql', () => { describe('when a connection is open', () => { it('sends a sql statement', async () => { - sinonSandbox.stub(db, 'getDBPool').returns((mockPool as unknown) as pg.Pool); + sinonSandbox.stub(db, 'getDBPool').returns(mockPool as unknown as pg.Pool); await connection.open(); @@ -341,7 +341,7 @@ describe('db', () => { describe('when a connection is not open', () => { it('throws an error', async () => { - sinonSandbox.stub(db, 'getDBPool').returns((mockPool as unknown) as pg.Pool); + sinonSandbox.stub(db, 'getDBPool').returns(mockPool as unknown as pg.Pool); let expectedError: ApiExecuteSQLError; try { @@ -379,7 +379,7 @@ describe('db', () => { it('calls getDBConnection for the biohub_api user', () => { const getDBConnectionStub = Sinon.stub(db, 'getDBConnection').returns( - ('stubbed DBConnection object' as unknown) as IDBConnection + 'stubbed DBConnection object' as unknown as IDBConnection ); getAPIUserDBConnection(); @@ -404,7 +404,7 @@ describe('db', () => { it('calls getDBConnection for the biohub_api user', () => { const getDBConnectionStub = Sinon.stub(db, 'getDBConnection').returns( - ('stubbed DBConnection object' as unknown) as IDBConnection + 'stubbed DBConnection object' as unknown as IDBConnection ); const sourceSystem = SOURCE_SYSTEM['SIMS-SVC-4464']; diff --git a/api/src/models/project-view.test.ts b/api/src/models/project-view.test.ts index c66afe337d..e24454106e 100644 --- a/api/src/models/project-view.test.ts +++ b/api/src/models/project-view.test.ts @@ -133,7 +133,7 @@ describe('GetIUCNClassificationData', () => { let iucnClassificationData: GetIUCNClassificationData; before(() => { - iucnClassificationData = new GetIUCNClassificationData((null as unknown) as any[]); + iucnClassificationData = new GetIUCNClassificationData(null as unknown as any[]); }); it('sets classification details', function () { @@ -185,7 +185,7 @@ describe('GetAttachmentsData', () => { let data: GetAttachmentsData; before(() => { - data = new GetAttachmentsData((null as unknown) as any[]); + data = new GetAttachmentsData(null as unknown as any[]); }); it('sets attachmentDetails', function () { @@ -288,7 +288,7 @@ describe('GetAttachmentsData', () => { describe('GetReportAttachmentsData', () => { describe('No values provided', () => { it('sets attachmentDetails', function () { - const data: GetReportAttachmentsData = new GetReportAttachmentsData((null as unknown) as any[]); + const data: GetReportAttachmentsData = new GetReportAttachmentsData(null as unknown as any[]); expect(data.attachmentDetails).to.eql([]); }); diff --git a/api/src/models/survey-view.test.ts b/api/src/models/survey-view.test.ts index 9ebd984b39..b9d0bc5cc6 100644 --- a/api/src/models/survey-view.test.ts +++ b/api/src/models/survey-view.test.ts @@ -368,7 +368,7 @@ describe('GetAttachmentsData', () => { let data: GetAttachmentsData; before(() => { - data = new GetAttachmentsData((null as unknown) as any[]); + data = new GetAttachmentsData(null as unknown as any[]); }); it('sets attachmentDetails', function () { @@ -471,7 +471,7 @@ describe('GetAttachmentsData', () => { describe('GetReportAttachmentsData', () => { describe('No values provided', () => { it('sets attachmentDetails', function () { - const data: GetReportAttachmentsData = new GetReportAttachmentsData((null as unknown) as any[]); + const data: GetReportAttachmentsData = new GetReportAttachmentsData(null as unknown as any[]); expect(data.attachmentDetails).to.eql([]); }); diff --git a/api/src/paths/administrative-activities.test.ts b/api/src/paths/administrative-activities.test.ts index e7042e8aea..2bbf6ae8a1 100644 --- a/api/src/paths/administrative-activities.test.ts +++ b/api/src/paths/administrative-activities.test.ts @@ -15,7 +15,7 @@ describe('openapi schema', () => { describe('GET', () => { it('is valid openapi v3 schema', () => { - expect(ajv.validateSchema((GET.apiDoc as unknown) as object)).to.be.true; + expect(ajv.validateSchema(GET.apiDoc as unknown as object)).to.be.true; }); }); }); @@ -28,7 +28,7 @@ describe('getAdministrativeActivities', () => { it('should return the rows on success (empty)', async () => { const mockDBConnection = getMockDBConnection({ sql: async () => { - return ({ rowCount: 0, rows: [] } as any) as Promise>; + return { rowCount: 0, rows: [] } as any as Promise>; } }); @@ -63,7 +63,7 @@ describe('getAdministrativeActivities', () => { const mockDBConnection = getMockDBConnection({ sql: async () => { - return ({ rowCount: 1, rows: [data] } as any) as Promise>; + return { rowCount: 1, rows: [data] } as any as Promise>; } }); diff --git a/api/src/paths/administrative-activity.test.ts b/api/src/paths/administrative-activity.test.ts index 97b5e212ba..2a13a35167 100644 --- a/api/src/paths/administrative-activity.test.ts +++ b/api/src/paths/administrative-activity.test.ts @@ -20,13 +20,13 @@ describe('openapi schema', () => { describe('POST', () => { it('is valid openapi v3 schema', () => { - expect(ajv.validateSchema((POST.apiDoc as unknown) as object)).to.be.true; + expect(ajv.validateSchema(POST.apiDoc as unknown as object)).to.be.true; }); }); describe('GET', () => { it('is valid openapi v3 schema', () => { - expect(ajv.validateSchema((GET.apiDoc as unknown) as object)).to.be.true; + expect(ajv.validateSchema(GET.apiDoc as unknown as object)).to.be.true; }); }); }); diff --git a/api/src/paths/codes.test.ts b/api/src/paths/codes.test.ts index 8866448c32..86a8b4b383 100644 --- a/api/src/paths/codes.test.ts +++ b/api/src/paths/codes.test.ts @@ -43,7 +43,7 @@ describe('codes', () => { try { const result = codes.getAllCodes(); - await result(sampleReq, (null as unknown) as any, (null as unknown) as any); + await result(sampleReq, null as unknown as any, null as unknown as any); expect.fail(); } catch (actualError) { expect((actualError as HTTPError).status).to.equal(500); @@ -59,7 +59,7 @@ describe('codes', () => { const result = codes.getAllCodes(); - await result(sampleReq, sampleRes as any, (null as unknown) as any); + await result(sampleReq, sampleRes as any, null as unknown as any); expect(actualResult.management_action_type).to.eql({ id: 1, name: 'management action type' }); }); @@ -73,7 +73,7 @@ describe('codes', () => { try { const result = codes.getAllCodes(); - await result(sampleReq, sampleRes as any, (null as unknown) as any); + await result(sampleReq, sampleRes as any, null as unknown as any); expect.fail(); } catch (actualError) { expect((actualError as HTTPError).message).to.equal(expectedError.message); diff --git a/api/src/paths/codes.ts b/api/src/paths/codes.ts index a622f14e10..606ffdca45 100644 --- a/api/src/paths/codes.ts +++ b/api/src/paths/codes.ts @@ -395,7 +395,7 @@ GET.apiDoc = { * @returns {RequestHandler} */ export function getAllCodes(): RequestHandler { - return async (req, res) => { + return async (_, res) => { const connection = getAPIUserDBConnection(); try { diff --git a/api/src/paths/project/create.test.ts b/api/src/paths/project/create.test.ts index 61e75615c3..ceb7237649 100644 --- a/api/src/paths/project/create.test.ts +++ b/api/src/paths/project/create.test.ts @@ -16,7 +16,7 @@ describe('create', () => { const ajv = new Ajv(); it('is valid openapi v3 schema', () => { - expect(ajv.validateSchema((POST.apiDoc as unknown) as object)).to.be.true; + expect(ajv.validateSchema(POST.apiDoc as unknown as object)).to.be.true; }); }); diff --git a/api/src/paths/project/list.test.ts b/api/src/paths/project/list.test.ts index 250d2ff335..a16f575a9b 100644 --- a/api/src/paths/project/list.test.ts +++ b/api/src/paths/project/list.test.ts @@ -18,7 +18,7 @@ describe('list', () => { const ajv = new Ajv(); it('is valid openapi v3 schema', () => { - expect(ajv.validateSchema((list.GET.apiDoc as unknown) as object)).to.be.true; + expect(ajv.validateSchema(list.GET.apiDoc as unknown as object)).to.be.true; }); }); @@ -66,7 +66,7 @@ describe('list', () => { const result = list.getProjectList(); - await result(sampleReq, sampleRes as any, (null as unknown) as any); + await result(sampleReq, sampleRes as any, null as unknown as any); expect(actualResult).to.eql({ pagination: { @@ -105,7 +105,7 @@ describe('list', () => { const result = list.getProjectList(); - await result(sampleReq, (sampleRes as unknown) as any, (null as unknown) as any); + await result(sampleReq, sampleRes as unknown as any, null as unknown as any); expect(actualResult).to.eql({ pagination: { @@ -141,7 +141,7 @@ describe('list', () => { try { const requestHandler = list.getProjectList(); - await requestHandler(sampleReq, sampleRes as any, (null as unknown) as any); + await requestHandler(sampleReq, sampleRes as any, null as unknown as any); expect.fail(); } catch (actualError) { expect(dbConnectionObj.release).to.have.been.called; diff --git a/api/src/paths/project/{projectId}/attachments/list.test.ts b/api/src/paths/project/{projectId}/attachments/list.test.ts index f4594a14d1..788d092393 100644 --- a/api/src/paths/project/{projectId}/attachments/list.test.ts +++ b/api/src/paths/project/{projectId}/attachments/list.test.ts @@ -75,7 +75,7 @@ describe('getAttachments', () => { const result = list.getAttachments(); - await result((mockReq as unknown) as any, (sampleRes as unknown) as any, (null as unknown) as any); + await result(mockReq as unknown as any, sampleRes as unknown as any, null as unknown as any); expect(actualResult).to.eql(expectedResponse); expect(getProjectAttachmentsStub).to.be.calledOnce; }); diff --git a/api/src/paths/project/{projectId}/attachments/report/upload.test.ts b/api/src/paths/project/{projectId}/attachments/report/upload.test.ts index ddb15043bb..746163a3da 100644 --- a/api/src/paths/project/{projectId}/attachments/report/upload.test.ts +++ b/api/src/paths/project/{projectId}/attachments/report/upload.test.ts @@ -44,7 +44,7 @@ describe('uploadMedia', () => { try { const result = upload.uploadMedia(); - await result({ ...mockReq, files: [] }, (null as unknown) as any, (null as unknown) as any); + await result({ ...mockReq, files: [] }, null as unknown as any, null as unknown as any); expect.fail(); } catch (actualError) { expect((actualError as HTTPError).status).to.equal(400); @@ -65,7 +65,7 @@ describe('uploadMedia', () => { try { const result = upload.uploadMedia(); - await result(mockReq, (null as unknown) as any, (null as unknown) as any); + await result(mockReq, null as unknown as any, null as unknown as any); expect.fail(); } catch (actualError) { expect((actualError as HTTPError).status).to.equal(400); @@ -89,7 +89,7 @@ describe('uploadMedia', () => { try { const result = upload.uploadMedia(); - await result(mockReq, (null as unknown) as any, (null as unknown) as any); + await result(mockReq, null as unknown as any, null as unknown as any); expect.fail(); } catch (actualError) { expect((actualError as HTTPError).message).to.equal(expectedError.message); @@ -126,7 +126,7 @@ describe('uploadMedia', () => { const result = upload.uploadMedia(); - await result(mockReq, (sampleRes as unknown) as any, (null as unknown) as any); + await result(mockReq, sampleRes as unknown as any, null as unknown as any); expect(actualResult).to.eql(expectedResponse); expect(upsertProjectReportAttachmentStub).to.be.calledOnce; }); diff --git a/api/src/paths/project/{projectId}/attachments/upload.test.ts b/api/src/paths/project/{projectId}/attachments/upload.test.ts index 572fd97921..249ea28964 100644 --- a/api/src/paths/project/{projectId}/attachments/upload.test.ts +++ b/api/src/paths/project/{projectId}/attachments/upload.test.ts @@ -42,7 +42,7 @@ describe('uploadMedia', () => { try { const result = upload.uploadMedia(); - await result({ ...mockReq, files: [] }, (null as unknown) as any, (null as unknown) as any); + await result({ ...mockReq, files: [] }, null as unknown as any, null as unknown as any); expect.fail(); } catch (actualError) { expect((actualError as HTTPError).status).to.equal(400); @@ -63,7 +63,7 @@ describe('uploadMedia', () => { try { const result = upload.uploadMedia(); - await result(mockReq, (null as unknown) as any, (null as unknown) as any); + await result(mockReq, null as unknown as any, null as unknown as any); expect.fail(); } catch (actualError) { expect((actualError as HTTPError).status).to.equal(400); @@ -87,7 +87,7 @@ describe('uploadMedia', () => { try { const result = upload.uploadMedia(); - await result(mockReq, (null as unknown) as any, (null as unknown) as any); + await result(mockReq, null as unknown as any, null as unknown as any); expect.fail(); } catch (actualError) { expect((actualError as HTTPError).message).to.equal(expectedError.message); @@ -124,7 +124,7 @@ describe('uploadMedia', () => { const result = upload.uploadMedia(); - await result(mockReq, (sampleRes as unknown) as any, (null as unknown) as any); + await result(mockReq, sampleRes as unknown as any, null as unknown as any); expect(actualResult).to.eql(expectedResponse); expect(upsertProjectAttachmentStub).to.be.calledOnce; }); diff --git a/api/src/paths/project/{projectId}/attachments/{attachmentId}/delete.test.ts b/api/src/paths/project/{projectId}/attachments/{attachmentId}/delete.test.ts index 331f692964..9d9e76167c 100644 --- a/api/src/paths/project/{projectId}/attachments/{attachmentId}/delete.test.ts +++ b/api/src/paths/project/{projectId}/attachments/{attachmentId}/delete.test.ts @@ -44,7 +44,7 @@ describe('deleteAttachment', () => { try { const result = deleteAttachment.deleteAttachment(); - await result(sampleReq, (null as unknown) as any, (null as unknown) as any); + await result(sampleReq, null as unknown as any, null as unknown as any); expect.fail(); } catch (actualError) { expect(handleDeleteProjectAttachmentStub).to.be.calledOnce; @@ -89,7 +89,7 @@ describe('deleteAttachment', () => { const result = deleteAttachment.deleteAttachment(); - await result(sampleReq, (sampleRes as unknown) as any, (null as unknown) as any); + await result(sampleReq, sampleRes as unknown as any, null as unknown as any); expect(handleDeleteProjectAttachmentStub).to.be.calledOnceWith(1, 2, 'Report'); }); diff --git a/api/src/paths/project/{projectId}/attachments/{attachmentId}/getSignedUrl.test.ts b/api/src/paths/project/{projectId}/attachments/{attachmentId}/getSignedUrl.test.ts index 789fdb865c..bff32fcf41 100644 --- a/api/src/paths/project/{projectId}/attachments/{attachmentId}/getSignedUrl.test.ts +++ b/api/src/paths/project/{projectId}/attachments/{attachmentId}/getSignedUrl.test.ts @@ -39,7 +39,7 @@ describe('getProjectAttachmentSignedURL', () => { try { const result = get_signed_url.getProjectAttachmentSignedURL(); - await result(sampleReq, (null as unknown) as any, (null as unknown) as any); + await result(sampleReq, null as unknown as any, null as unknown as any); expect.fail(); } catch (actualError) { expect((actualError as HTTPError).message).to.equal(expectedError.message); @@ -82,7 +82,7 @@ describe('getProjectAttachmentSignedURL', () => { const result = get_signed_url.getProjectAttachmentSignedURL(); - await result(sampleReq, sampleRes as any, (null as unknown) as any); + await result(sampleReq, sampleRes as any, null as unknown as any); expect(actualResult).to.eql('myurlsigned.com'); expect(getProjectReportAttachmentS3KeyStub).to.be.calledOnce; @@ -127,7 +127,7 @@ describe('getProjectAttachmentSignedURL', () => { const result = get_signed_url.getProjectAttachmentSignedURL(); - await result(sampleReq, sampleRes as any, (null as unknown) as any); + await result(sampleReq, sampleRes as any, null as unknown as any); expect(actualResult).to.eql(null); expect(getProjectAttachmentS3KeyStub).to.be.calledOnce; diff --git a/api/src/paths/project/{projectId}/attachments/{attachmentId}/metadata/get.test.ts b/api/src/paths/project/{projectId}/attachments/{attachmentId}/metadata/get.test.ts index d200a116f0..dbebfdea2e 100644 --- a/api/src/paths/project/{projectId}/attachments/{attachmentId}/metadata/get.test.ts +++ b/api/src/paths/project/{projectId}/attachments/{attachmentId}/metadata/get.test.ts @@ -38,7 +38,7 @@ describe('getProjectReportDetails', () => { try { const result = get.getProjectReportDetails(); - await result(mockReq, (null as unknown) as any, (null as unknown) as any); + await result(mockReq, null as unknown as any, null as unknown as any); expect.fail(); } catch (actualError) { expect((actualError as HTTPError).message).to.equal(expectedError.message); @@ -60,11 +60,11 @@ describe('getProjectReportDetails', () => { const getProjectReportAttachmentByIdStub = sinon .stub(AttachmentService.prototype, 'getProjectReportAttachmentById') - .resolves(({ project_report_attachment_id: 1 } as unknown) as IProjectReportAttachment); + .resolves({ project_report_attachment_id: 1 } as unknown as IProjectReportAttachment); const getProjectReportAttachmentAuthorsStub = sinon .stub(AttachmentService.prototype, 'getProjectReportAttachmentAuthors') - .resolves([({ author: 2 } as unknown) as IProjectReportAttachmentAuthor]); + .resolves([{ author: 2 } as unknown as IProjectReportAttachmentAuthor]); const expectedResponse = { metadata: { project_report_attachment_id: 1 }, @@ -83,7 +83,7 @@ describe('getProjectReportDetails', () => { }; const result = get.getProjectReportDetails(); - await result(mockReq, (sampleRes as unknown) as any, (null as unknown) as any); + await result(mockReq, sampleRes as unknown as any, null as unknown as any); expect(actualResult).to.eql(expectedResponse); expect(getProjectReportAttachmentByIdStub).to.be.calledOnce; diff --git a/api/src/paths/project/{projectId}/attachments/{attachmentId}/metadata/update.test.ts b/api/src/paths/project/{projectId}/attachments/{attachmentId}/metadata/update.test.ts index f1a4f70246..5e88c743d8 100644 --- a/api/src/paths/project/{projectId}/attachments/{attachmentId}/metadata/update.test.ts +++ b/api/src/paths/project/{projectId}/attachments/{attachmentId}/metadata/update.test.ts @@ -48,7 +48,7 @@ describe('updates metadata for a project report', () => { try { const result = update_project_metadata.updateProjectAttachmentMetadata(); - await result(sampleReq, (null as unknown) as any, (null as unknown) as any); + await result(sampleReq, null as unknown as any, null as unknown as any); expect.fail(); } catch (actualError) { expect((actualError as HTTPError).message).to.equal(expectedError.message); @@ -104,7 +104,7 @@ describe('updates metadata for a project report', () => { }; const requestHandler = update_project_metadata.updateProjectAttachmentMetadata(); - await requestHandler(sampleReq, (sampleRes as unknown) as any, (null as unknown) as any); + await requestHandler(sampleReq, sampleRes as unknown as any, null as unknown as any); expect(actualResult).to.equal(undefined); expect(updateProjectReportAttachmentMetadataStub).to.be.calledOnce; diff --git a/api/src/paths/project/{projectId}/delete.test.ts b/api/src/paths/project/{projectId}/delete.test.ts index 5a052b9ac7..9b610672d3 100644 --- a/api/src/paths/project/{projectId}/delete.test.ts +++ b/api/src/paths/project/{projectId}/delete.test.ts @@ -31,7 +31,7 @@ describe('deleteProject', () => { try { const result = delete_project.deleteProject(); - await result(sampleReq, (null as unknown) as any, (null as unknown) as any); + await result(sampleReq, null as unknown as any, null as unknown as any); expect.fail(); } catch (actualError) { expect((actualError as HTTPError).status).to.equal(400); @@ -57,7 +57,7 @@ describe('deleteProject', () => { try { const result = delete_project.deleteProject(); - await result(sampleReq, (null as unknown) as any, (null as unknown) as any); + await result(sampleReq, null as unknown as any, null as unknown as any); expect.fail(); } catch (actualError) { expect((actualError as HTTPError).message).to.equal(expectedError.message); @@ -93,7 +93,7 @@ describe('deleteProject', () => { const result = delete_project.deleteProject(); - await result(sampleReq, (sampleRes as unknown) as any, (null as unknown) as any); + await result(sampleReq, sampleRes as unknown as any, null as unknown as any); expect(actualResult).to.eql(expectedResponse); expect(deleteProjectStub).to.be.calledOnce; diff --git a/api/src/paths/project/{projectId}/participants/index.test.ts b/api/src/paths/project/{projectId}/participants/index.test.ts index 6704a86a32..5db9ebad2a 100644 --- a/api/src/paths/project/{projectId}/participants/index.test.ts +++ b/api/src/paths/project/{projectId}/participants/index.test.ts @@ -143,8 +143,8 @@ describe('postProjectParticipants', () => { const result = create_project_participants.postProjectParticipants(); await result( { ...sampleReq, params: { ...sampleReq.params, projectId: null } }, - (null as unknown) as any, - (null as unknown) as any + null as unknown as any, + null as unknown as any ); expect.fail(); } catch (actualError) { @@ -160,8 +160,8 @@ describe('postProjectParticipants', () => { const result = create_project_participants.postProjectParticipants(); await result( { ...sampleReq, body: { ...sampleReq.body, participants: [] } }, - (null as unknown) as any, - (null as unknown) as any + null as unknown as any, + null as unknown as any ); expect.fail(); } catch (actualError) { @@ -182,7 +182,7 @@ describe('postProjectParticipants', () => { try { const result = create_project_participants.postProjectParticipants(); - await result({ ...sampleReq }, (null as unknown) as any, (null as unknown) as any); + await result({ ...sampleReq }, null as unknown as any, null as unknown as any); expect.fail(); } catch (actualError) { expect((actualError as HTTPError).message).to.equal('an error'); diff --git a/api/src/paths/project/{projectId}/participants/self.test.ts b/api/src/paths/project/{projectId}/participants/self.test.ts index f67ef83bf5..7c7ffd70eb 100644 --- a/api/src/paths/project/{projectId}/participants/self.test.ts +++ b/api/src/paths/project/{projectId}/participants/self.test.ts @@ -38,7 +38,7 @@ describe('getSelf', () => { const dbConnectionObj = getMockDBConnection(); sinon.stub(db, 'getDBConnection').returns({ ...dbConnectionObj, - systemUserId: () => (null as unknown) as number + systemUserId: () => null as unknown as number }); const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/attachments/keyx/upload.test.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/attachments/keyx/upload.test.ts index 287be7b54b..d7cadca50a 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/attachments/keyx/upload.test.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/attachments/keyx/upload.test.ts @@ -47,7 +47,7 @@ describe('uploadMedia', () => { try { const result = upload.uploadKeyxMedia(); - await result({ ...mockReq, files: [] }, (null as unknown) as any, (null as unknown) as any); + await result({ ...mockReq, files: [] }, null as unknown as any, null as unknown as any); expect.fail(); } catch (actualError) { expect((actualError as HTTPError).status).to.equal(400); @@ -68,7 +68,7 @@ describe('uploadMedia', () => { try { const result = upload.uploadKeyxMedia(); - await result(mockReq, (null as unknown) as any, (null as unknown) as any); + await result(mockReq, null as unknown as any, null as unknown as any); expect.fail(); } catch (actualError) { expect((actualError as HTTPError).status).to.equal(400); @@ -90,7 +90,7 @@ describe('uploadMedia', () => { try { const result = upload.uploadKeyxMedia(); - await result(mockReq, (null as unknown) as any, (null as unknown) as any); + await result(mockReq, null as unknown as any, null as unknown as any); expect.fail(); } catch (actualError) { expect((actualError as HTTPError).status).to.equal(400); @@ -116,7 +116,7 @@ describe('uploadMedia', () => { try { const result = upload.uploadKeyxMedia(); - await result(mockReq, (null as unknown) as any, (null as unknown) as any); + await result(mockReq, null as unknown as any, null as unknown as any); expect.fail(); } catch (actualError) { expect((actualError as HTTPError).message).to.equal(expectedError.message); @@ -154,7 +154,7 @@ describe('uploadMedia', () => { const result = upload.uploadKeyxMedia(); - await result(mockReq, (sampleRes as unknown) as any, (null as unknown) as any); + await result(mockReq, sampleRes as unknown as any, null as unknown as any); expect(actualResult).to.eql(expectedResponse); expect(uploadKeyXStub).to.be.calledOnce; expect(upsertSurveyAttachmentStub).to.be.calledOnce; diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/attachments/list.test.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/attachments/list.test.ts index 2fedeabbf2..46b9ddc46c 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/attachments/list.test.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/attachments/list.test.ts @@ -36,7 +36,7 @@ describe('getSurveyAttachments', () => { try { const result = list.getSurveyAttachments(); - await result(sampleReq, (null as unknown) as any, (null as unknown) as any); + await result(sampleReq, null as unknown as any, null as unknown as any); expect.fail(); } catch (actualError) { expect(getSurveyAttachmentsStub).to.be.calledOnce; @@ -80,7 +80,7 @@ describe('getSurveyAttachments', () => { const result = list.getSurveyAttachments(); - await result(sampleReq, (sampleRes as unknown) as any, (null as unknown) as any); + await result(sampleReq, sampleRes as unknown as any, null as unknown as any); expect(actualResult).to.eql(expectedResult); expect(getSurveyAttachmentsStub).to.be.calledOnce; expect(getSurveyReportAttachmentsStub).to.be.calledOnce; diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/attachments/report/upload.test.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/attachments/report/upload.test.ts index 272bd5525c..6ea3c9771c 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/attachments/report/upload.test.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/attachments/report/upload.test.ts @@ -35,7 +35,7 @@ describe('uploadMedia', () => { try { const result = upload.uploadMedia(); - await result(mockReq, (null as unknown) as any, (null as unknown) as any); + await result(mockReq, null as unknown as any, null as unknown as any); expect.fail(); } catch (actualError) { expect((actualError as HTTPError).status).to.equal(400); @@ -72,7 +72,7 @@ describe('uploadMedia', () => { try { const result = upload.uploadMedia(); - await result(mockReq, (null as unknown) as any, (null as unknown) as any); + await result(mockReq, null as unknown as any, null as unknown as any); expect.fail(); } catch (actualError) { expect((actualError as HTTPError).status).to.equal(400); @@ -112,7 +112,7 @@ describe('uploadMedia', () => { try { const result = upload.uploadMedia(); - await result(mockReq, (null as unknown) as any, (null as unknown) as any); + await result(mockReq, null as unknown as any, null as unknown as any); expect.fail(); } catch (actualError) { expect((actualError as HTTPError).message).to.equal(expectedError.message); @@ -165,7 +165,7 @@ describe('uploadMedia', () => { const result = upload.uploadMedia(); - await result(mockReq, (sampleRes as unknown) as any, (null as unknown) as any); + await result(mockReq, sampleRes as unknown as any, null as unknown as any); expect(actualResult).to.eql(expectedResponse); expect(upsertSurveyReportAttachmentStub).to.be.calledOnce; }); diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/attachments/upload.test.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/attachments/upload.test.ts index a121613586..cc8e1fbb83 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/attachments/upload.test.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/attachments/upload.test.ts @@ -42,7 +42,7 @@ describe('uploadMedia', () => { try { const result = upload.uploadMedia(); - await result({ ...mockReq, files: [] }, (null as unknown) as any, (null as unknown) as any); + await result({ ...mockReq, files: [] }, null as unknown as any, null as unknown as any); expect.fail(); } catch (actualError) { expect((actualError as HTTPError).status).to.equal(400); @@ -63,7 +63,7 @@ describe('uploadMedia', () => { try { const result = upload.uploadMedia(); - await result(mockReq, (null as unknown) as any, (null as unknown) as any); + await result(mockReq, null as unknown as any, null as unknown as any); expect.fail(); } catch (actualError) { expect((actualError as HTTPError).status).to.equal(400); @@ -87,7 +87,7 @@ describe('uploadMedia', () => { try { const result = upload.uploadMedia(); - await result(mockReq, (null as unknown) as any, (null as unknown) as any); + await result(mockReq, null as unknown as any, null as unknown as any); expect.fail(); } catch (actualError) { expect((actualError as HTTPError).message).to.equal(expectedError.message); @@ -124,7 +124,7 @@ describe('uploadMedia', () => { const result = upload.uploadMedia(); - await result(mockReq, (sampleRes as unknown) as any, (null as unknown) as any); + await result(mockReq, sampleRes as unknown as any, null as unknown as any); expect(actualResult).to.eql(expectedResponse); expect(upsertSurveyAttachmentStub).to.be.calledOnce; }); diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/attachments/{attachmentId}/delete.test.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/attachments/{attachmentId}/delete.test.ts index 6a3dda6470..c5dc1ff265 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/attachments/{attachmentId}/delete.test.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/attachments/{attachmentId}/delete.test.ts @@ -44,7 +44,7 @@ describe('deleteAttachment', () => { try { const result = deleteAttachment.deleteAttachment(); - await result(sampleReq, (null as unknown) as any, (null as unknown) as any); + await result(sampleReq, null as unknown as any, null as unknown as any); expect.fail(); } catch (actualError) { expect(handleDeleteSurveyAttachmentStub).to.be.calledOnce; @@ -89,7 +89,7 @@ describe('deleteAttachment', () => { const result = deleteAttachment.deleteAttachment(); - await result(sampleReq, (sampleRes as unknown) as any, (null as unknown) as any); + await result(sampleReq, sampleRes as unknown as any, null as unknown as any); expect(handleDeleteSurveyAttachmentStub).to.be.calledOnceWith(1, 2, 'Report'); }); diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/attachments/{attachmentId}/getSignedUrl.test.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/attachments/{attachmentId}/getSignedUrl.test.ts index cd574d23b3..f16e4b7f1d 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/attachments/{attachmentId}/getSignedUrl.test.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/attachments/{attachmentId}/getSignedUrl.test.ts @@ -39,7 +39,7 @@ describe('getSurveyAttachmentSignedURL', () => { try { const result = get_signed_url.getSurveyAttachmentSignedURL(); - await result(sampleReq, (null as unknown) as any, (null as unknown) as any); + await result(sampleReq, null as unknown as any, null as unknown as any); expect.fail(); } catch (actualError) { expect((actualError as HTTPError).message).to.equal(expectedError.message); @@ -82,7 +82,7 @@ describe('getSurveyAttachmentSignedURL', () => { const result = get_signed_url.getSurveyAttachmentSignedURL(); - await result(sampleReq, sampleRes as any, (null as unknown) as any); + await result(sampleReq, sampleRes as any, null as unknown as any); expect(actualResult).to.eql('myurlsigned.com'); expect(getSurveyReportAttachmentS3KeyStub).to.be.calledOnce; @@ -127,7 +127,7 @@ describe('getSurveyAttachmentSignedURL', () => { const result = get_signed_url.getSurveyAttachmentSignedURL(); - await result(sampleReq, sampleRes as any, (null as unknown) as any); + await result(sampleReq, sampleRes as any, null as unknown as any); expect(actualResult).to.eql(null); expect(getSurveyAttachmentS3KeyStub).to.be.calledOnce; diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/attachments/{attachmentId}/metadata/get.test.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/attachments/{attachmentId}/metadata/get.test.ts index f0ad1b91f1..31b543fd70 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/attachments/{attachmentId}/metadata/get.test.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/attachments/{attachmentId}/metadata/get.test.ts @@ -38,7 +38,7 @@ describe('getSurveyReportDetails', () => { try { const result = get.getSurveyReportDetails(); - await result(mockReq, (null as unknown) as any, (null as unknown) as any); + await result(mockReq, null as unknown as any, null as unknown as any); expect.fail(); } catch (actualError) { expect((actualError as HTTPError).message).to.equal(expectedError.message); @@ -60,11 +60,11 @@ describe('getSurveyReportDetails', () => { const getSurveyReportAttachmentByIdStub = sinon .stub(AttachmentService.prototype, 'getSurveyReportAttachmentById') - .resolves(({ survey_report_attachment_id: 1 } as unknown) as ISurveyReportAttachment); + .resolves({ survey_report_attachment_id: 1 } as unknown as ISurveyReportAttachment); const getSurveyAttachmentAuthorsStub = sinon .stub(AttachmentService.prototype, 'getSurveyAttachmentAuthors') - .resolves([({ author: 2 } as unknown) as ISurveyReportAttachmentAuthor]); + .resolves([{ author: 2 } as unknown as ISurveyReportAttachmentAuthor]); const expectedResponse = { metadata: { survey_report_attachment_id: 1 }, @@ -83,7 +83,7 @@ describe('getSurveyReportDetails', () => { }; const result = get.getSurveyReportDetails(); - await result(mockReq, (sampleRes as unknown) as any, (null as unknown) as any); + await result(mockReq, sampleRes as unknown as any, null as unknown as any); expect(actualResult).to.eql(expectedResponse); expect(getSurveyReportAttachmentByIdStub).to.be.calledOnce; diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/attachments/{attachmentId}/metadata/update.test.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/attachments/{attachmentId}/metadata/update.test.ts index 131b9bfdf4..cc24ce449b 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/attachments/{attachmentId}/metadata/update.test.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/attachments/{attachmentId}/metadata/update.test.ts @@ -48,7 +48,7 @@ describe('updates metadata for a survey report', () => { try { const result = update_survey_metadata.updateSurveyReportMetadata(); - await result(sampleReq, (null as unknown) as any, (null as unknown) as any); + await result(sampleReq, null as unknown as any, null as unknown as any); expect.fail(); } catch (actualError) { expect((actualError as HTTPError).message).to.equal(expectedError.message); @@ -104,7 +104,7 @@ describe('updates metadata for a survey report', () => { }; const requestHandler = update_survey_metadata.updateSurveyReportMetadata(); - await requestHandler(sampleReq, (sampleRes as unknown) as any, (null as unknown) as any); + await requestHandler(sampleReq, sampleRes as unknown as any, null as unknown as any); expect(actualResult).to.equal(undefined); expect(updateSurveyReportAttachmentMetadataStub).to.be.calledOnce; diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}.test.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}.test.ts index 491afe1e3f..d4e972efa8 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}.test.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}.test.ts @@ -12,11 +12,11 @@ describe('critterId openapi schema', () => { const ajv = new Ajv(); it('PATCH is valid openapi v3 schema', () => { - expect(ajv.validateSchema((PATCH.apiDoc as unknown) as object)).to.be.true; + expect(ajv.validateSchema(PATCH.apiDoc as unknown as object)).to.be.true; }); it('DELETE is valid openapi v3 schema', () => { - expect(ajv.validateSchema((DELETE.apiDoc as unknown) as object)).to.be.true; + expect(ajv.validateSchema(DELETE.apiDoc as unknown as object)).to.be.true; }); }); diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}/deployments/index.test.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}/deployments/index.test.ts index aded5a4613..564c94c488 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}/deployments/index.test.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}/deployments/index.test.ts @@ -18,8 +18,8 @@ describe('critter deployments', () => { const ajv = new Ajv(); it('is valid openapi v3 schema', () => { - expect(ajv.validateSchema((POST.apiDoc as unknown) as object)).to.be.true; - expect(ajv.validateSchema((PATCH.apiDoc as unknown) as object)).to.be.true; + expect(ajv.validateSchema(POST.apiDoc as unknown as object)).to.be.true; + expect(ajv.validateSchema(PATCH.apiDoc as unknown as object)).to.be.true; }); }); diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}/deployments/{bctwDeploymentId}.test.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}/deployments/{bctwDeploymentId}.test.ts index a0943077d8..33aa5990ca 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}/deployments/{bctwDeploymentId}.test.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}/deployments/{bctwDeploymentId}.test.ts @@ -18,7 +18,7 @@ describe('critter deployments', () => { const ajv = new Ajv(); it('is valid openapi v3 schema', () => { - expect(ajv.validateSchema((DELETE.apiDoc as unknown) as object)).to.be.true; + expect(ajv.validateSchema(DELETE.apiDoc as unknown as object)).to.be.true; }); }); diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}/telemetry.test.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}/telemetry.test.ts index 6c69ecd99d..bc00790004 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}/telemetry.test.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}/telemetry.test.ts @@ -18,7 +18,7 @@ describe('critter telemetry', () => { const ajv = new Ajv(); it('is valid openapi v3 schema', () => { - expect(ajv.validateSchema((GET.apiDoc as unknown) as object)).to.be.true; + expect(ajv.validateSchema(GET.apiDoc as unknown as object)).to.be.true; }); }); diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}/telemetry.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}/telemetry.ts index 08e06638c0..feee2a63f9 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}/telemetry.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}/telemetry.ts @@ -12,8 +12,9 @@ import { SurveyCritterService } from '../../../../../../../services/survey-critt import { getLogger } from '../../../../../../../utils/logger'; const defaultLog = getLogger('paths/project/{projectId}/survey/{surveyId}/critters/{critterId}/telemetry'); -const GeoJSONFeatureCollectionFeaturesItems = (GeoJSONFeatureCollection.properties - ?.features as OpenAPIV3.ArraySchemaObject)?.items as OpenAPIV3.SchemaObject; +const GeoJSONFeatureCollectionFeaturesItems = ( + GeoJSONFeatureCollection.properties?.features as OpenAPIV3.ArraySchemaObject +)?.items as OpenAPIV3.SchemaObject; const GeoJSONTelemetryPointsAPISchema: OpenAPIV3.SchemaObject = { ...GeoJSONFeatureCollection, diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/delete.test.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/delete.test.ts index e5b0f65891..ac6a53b6ac 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/delete.test.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/delete.test.ts @@ -38,7 +38,7 @@ describe('deleteSurvey', () => { try { const result = del.deleteSurvey(); - await result(sampleReq, (null as unknown) as any, (null as unknown) as any); + await result(sampleReq, null as unknown as any, null as unknown as any); expect.fail(); } catch (actualError) { expect((actualError as HTTPError).message).to.equal(expectedError.message); @@ -60,13 +60,13 @@ describe('deleteSurvey', () => { const getSurveyAttachmentsStub = sinon .stub(AttachmentService.prototype, 'getSurveyAttachments') - .resolves([({ key: 'key' } as unknown) as ISurveyAttachment]); + .resolves([{ key: 'key' } as unknown as ISurveyAttachment]); const deleteSurveyStub = sinon.stub(SurveyService.prototype, 'deleteSurvey').resolves(); const fileUtilsStub = sinon .stub(file_utils, 'deleteFileFromS3') - .resolves((false as unknown) as S3.DeleteObjectOutput); + .resolves(false as unknown as S3.DeleteObjectOutput); let actualResult: any = null; const sampleRes = { @@ -81,7 +81,7 @@ describe('deleteSurvey', () => { const result = del.deleteSurvey(); - await result(sampleReq, (sampleRes as unknown) as any, (null as unknown) as any); + await result(sampleReq, sampleRes as unknown as any, null as unknown as any); expect(actualResult).to.eql(null); expect(getSurveyAttachmentsStub).to.be.calledOnce; expect(deleteSurveyStub).to.be.calledOnce; @@ -103,13 +103,11 @@ describe('deleteSurvey', () => { const getSurveyAttachmentsStub = sinon .stub(AttachmentService.prototype, 'getSurveyAttachments') - .resolves([({ key: 'key' } as unknown) as ISurveyAttachment]); + .resolves([{ key: 'key' } as unknown as ISurveyAttachment]); const deleteSurveyStub = sinon.stub(SurveyService.prototype, 'deleteSurvey').resolves(); - const fileUtilsStub = sinon - .stub(file_utils, 'deleteFileFromS3') - .resolves((true as unknown) as S3.DeleteObjectOutput); + const fileUtilsStub = sinon.stub(file_utils, 'deleteFileFromS3').resolves(true as unknown as S3.DeleteObjectOutput); let actualResult: any = null; const sampleRes = { @@ -124,7 +122,7 @@ describe('deleteSurvey', () => { const result = del.deleteSurvey(); - await result(sampleReq, (sampleRes as unknown) as any, (null as unknown) as any); + await result(sampleReq, sampleRes as unknown as any, null as unknown as any); expect(actualResult).to.eql(true); expect(getSurveyAttachmentsStub).to.be.calledOnce; expect(deleteSurveyStub).to.be.calledOnce; diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/observations/index.test.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/observations/index.test.ts index 947e99d3d2..8d0f0a0b66 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/observations/index.test.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/observations/index.test.ts @@ -171,10 +171,10 @@ describe('getSurveyObservations', () => { const getSurveyObservationsStub = sinon .stub(ObservationService.prototype, 'getSurveyObservationsWithSupplementaryAndSamplingDataAndAttributeData') .resolves({ - surveyObservations: ([ + surveyObservations: [ { survey_observation_id: 11 }, { survey_observation_id: 12 } - ] as unknown) as ObservationRecordWithSamplingAndSubcountData[], + ] as unknown as ObservationRecordWithSamplingAndSubcountData[], supplementaryObservationData: { observationCount: 59, qualitative_measurements: [], @@ -227,10 +227,10 @@ describe('getSurveyObservations', () => { const getSurveyObservationsStub = sinon .stub(ObservationService.prototype, 'getSurveyObservationsWithSupplementaryAndSamplingDataAndAttributeData') .resolves({ - surveyObservations: ([ + surveyObservations: [ { survey_observation_id: 16 }, { survey_observation_id: 17 } - ] as unknown) as ObservationRecordWithSamplingAndSubcountData[], + ] as unknown as ObservationRecordWithSamplingAndSubcountData[], supplementaryObservationData: { observationCount: 50, qualitative_measurements: [], @@ -281,10 +281,10 @@ describe('getSurveyObservations', () => { const getSurveyObservationsStub = sinon .stub(ObservationService.prototype, 'getSurveyObservationsWithSupplementaryAndSamplingDataAndAttributeData') .resolves({ - surveyObservations: ([ + surveyObservations: [ { survey_observation_id: 16 }, { survey_observation_id: 17 } - ] as unknown) as ObservationRecordWithSamplingAndSubcountData[], + ] as unknown as ObservationRecordWithSamplingAndSubcountData[], supplementaryObservationData: { observationCount: 2, qualitative_measurements: [], diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/observations/index.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/observations/index.ts index 89cc829626..1072d50b7e 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/observations/index.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/observations/index.ts @@ -630,10 +630,11 @@ export function getSurveyObservations(): RequestHandler { const observationService = new ObservationService(connection); - const observationData = await observationService.getSurveyObservationsWithSupplementaryAndSamplingDataAndAttributeData( - surveyId, - ensureCompletePaginationOptions(paginationOptions) - ); + const observationData = + await observationService.getSurveyObservationsWithSupplementaryAndSamplingDataAndAttributeData( + surveyId, + ensureCompletePaginationOptions(paginationOptions) + ); const observationCount = observationData.supplementaryObservationData.observationCount; diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/observations/process.test.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/observations/process.test.ts index 102c1113fa..b4b7719988 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/observations/process.test.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/observations/process.test.ts @@ -40,7 +40,7 @@ describe('processFile', () => { try { const result = process.processFile(); - await result(mockReq, (null as unknown) as any, (null as unknown) as any); + await result(mockReq, null as unknown as any, null as unknown as any); expect.fail(); } catch (actualError) { expect((actualError as HTTPError).message).to.equal(expectedError.message); diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/observations/upload.test.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/observations/upload.test.ts index edbca8cde4..7720b4e1eb 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/observations/upload.test.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/observations/upload.test.ts @@ -42,7 +42,7 @@ describe('uploadMedia', () => { try { const result = upload.uploadMedia(); - await result({ ...mockReq, files: [] }, (null as unknown) as any, (null as unknown) as any); + await result({ ...mockReq, files: [] }, null as unknown as any, null as unknown as any); expect.fail(); } catch (actualError) { expect((actualError as HTTPError).status).to.equal(400); @@ -63,8 +63,8 @@ describe('uploadMedia', () => { await result( { ...mockReq, files: [{ originalname: 'file.txt' }] }, - (null as unknown) as any, - (null as unknown) as any + null as unknown as any, + null as unknown as any ); expect.fail(); } catch (actualError) { @@ -86,7 +86,7 @@ describe('uploadMedia', () => { try { const result = upload.uploadMedia(); - await result(mockReq, (null as unknown) as any, (null as unknown) as any); + await result(mockReq, null as unknown as any, null as unknown as any); expect.fail(); } catch (actualError) { expect((actualError as HTTPError).status).to.equal(400); @@ -110,7 +110,7 @@ describe('uploadMedia', () => { try { const result = upload.uploadMedia(); - await result(mockReq, (null as unknown) as any, (null as unknown) as any); + await result(mockReq, null as unknown as any, null as unknown as any); expect.fail(); } catch (actualError) { expect((actualError as HTTPError).message).to.equal(expectedError.message); @@ -147,7 +147,7 @@ describe('uploadMedia', () => { const result = upload.uploadMedia(); - await result(mockReq, (sampleRes as unknown) as any, (null as unknown) as any); + await result(mockReq, sampleRes as unknown as any, null as unknown as any); expect(actualResult).to.eql(expectedResponse); expect(upsertSurveyAttachmentStub).to.be.calledOnce; }); diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/participants/index.test.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/participants/index.test.ts index 0611d0de70..767ab02c2d 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/participants/index.test.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/participants/index.test.ts @@ -143,8 +143,8 @@ describe('createSurveyParticipants', () => { const result = create_survey_participants.createSurveyParticipants(); await result( { ...sampleReq, params: { ...sampleReq.params, surveyId: null } }, - (null as unknown) as any, - (null as unknown) as any + null as unknown as any, + null as unknown as any ); expect.fail(); } catch (actualError) { @@ -160,8 +160,8 @@ describe('createSurveyParticipants', () => { const result = create_survey_participants.createSurveyParticipants(); await result( { ...sampleReq, body: { ...sampleReq.body, participants: [] } }, - (null as unknown) as any, - (null as unknown) as any + null as unknown as any, + null as unknown as any ); expect.fail(); } catch (actualError) { diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/delete.test.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/delete.test.ts index 33f52bbe38..49c64900e3 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/delete.test.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/delete.test.ts @@ -36,8 +36,8 @@ describe('deleteSurveySampleSiteRecords', () => { const result = delete_survey_sample_sites.deleteSurveySampleSiteRecords(); await result( { ...sampleReq, params: sampleReq.params, body: { surveySampleSiteIds: null } }, - (null as unknown) as any, - (null as unknown) as any + null as unknown as any, + null as unknown as any ); expect.fail(); } catch (actualError) { diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/index.test.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/index.test.ts index f1aa00ebae..88dac2fa7c 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/index.test.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/index.test.ts @@ -130,8 +130,8 @@ describe('createSurveySampleSiteRecord', () => { const result = create_survey_sample_site_record.createSurveySampleSiteRecord(); await result( { ...sampleReq, params: { ...sampleReq.params, surveyId: null } }, - (null as unknown) as any, - (null as unknown) as any + null as unknown as any, + null as unknown as any ); expect.fail(); } catch (actualError) { diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/{surveySampleSiteId}/index.test.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/{surveySampleSiteId}/index.test.ts index 03c5d720dc..34a22ac71a 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/{surveySampleSiteId}/index.test.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/{surveySampleSiteId}/index.test.ts @@ -199,8 +199,8 @@ describe('deleteSurveySampleSiteRecord', () => { const result = delete_survey_sample_site_record.deleteSurveySampleSiteRecord(); await result( { ...sampleReq, params: { ...sampleReq.params, surveySampleSiteId: null } }, - (null as unknown) as any, - (null as unknown) as any + null as unknown as any, + null as unknown as any ); expect.fail(); } catch (actualError) { diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/{surveySampleSiteId}/sample-method/index.test.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/{surveySampleSiteId}/sample-method/index.test.ts index e7780abe1d..61d6bd1a54 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/{surveySampleSiteId}/sample-method/index.test.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/{surveySampleSiteId}/sample-method/index.test.ts @@ -117,8 +117,8 @@ describe('createSurveySampleSiteRecord', () => { const result = create_survey_sample_method_record.createSurveySampleSiteRecord(); await result( { ...sampleReq, params: { ...sampleReq.params, surveySampleSiteId: null } }, - (null as unknown) as any, - (null as unknown) as any + null as unknown as any, + null as unknown as any ); expect.fail(); } catch (actualError) { diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/{surveySampleSiteId}/sample-method/{surveySampleMethodId}/index.test.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/{surveySampleSiteId}/sample-method/{surveySampleMethodId}/index.test.ts index 4e637f8871..3004ab8d16 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/{surveySampleSiteId}/sample-method/{surveySampleMethodId}/index.test.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/{surveySampleSiteId}/sample-method/{surveySampleMethodId}/index.test.ts @@ -167,8 +167,8 @@ describe('deleteSurveySampleMethodRecord', () => { const result = delete_survey_sample_method_record.deleteSurveySampleMethodRecord(); await result( { ...sampleReq, params: { ...sampleReq.params, surveySampleMethodId: null } }, - (null as unknown) as any, - (null as unknown) as any + null as unknown as any, + null as unknown as any ); expect.fail(); } catch (actualError) { diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/update/get.test.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/update/get.test.ts index 0792995975..98d4b7a284 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/update/get.test.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/update/get.test.ts @@ -40,7 +40,7 @@ describe('getSurveyForUpdate', () => { try { const result = get.getSurveyForUpdate(); - await result(sampleReq, (null as unknown) as any, (null as unknown) as any); + await result(sampleReq, null as unknown as any, null as unknown as any); expect.fail(); } catch (actualError) { expect((actualError as HTTPError).message).to.equal(expectedError.message); @@ -65,10 +65,10 @@ describe('getSurveyForUpdate', () => { } } as any; - const getSurveyByIdStub = sinon.stub(SurveyService.prototype, 'getSurveyById').resolves(({ + const getSurveyByIdStub = sinon.stub(SurveyService.prototype, 'getSurveyById').resolves({ id: 1, proprietor: {} - } as unknown) as SurveyObject); + } as unknown as SurveyObject); const expectedResponse = { surveyData: { @@ -103,7 +103,7 @@ describe('getSurveyForUpdate', () => { const result = get.getSurveyForUpdate(); - await result(sampleReq, (sampleRes as unknown) as any, (null as unknown) as any); + await result(sampleReq, sampleRes as unknown as any, null as unknown as any); expect(actualResult).to.eql(expectedResponse); expect(getSurveyByIdStub).to.be.calledOnce; }); @@ -126,10 +126,10 @@ describe('getSurveyForUpdate', () => { } } as any; - const getSurveyByIdStub = sinon.stub(SurveyService.prototype, 'getSurveyById').resolves(({ + const getSurveyByIdStub = sinon.stub(SurveyService.prototype, 'getSurveyById').resolves({ id: 1, proprietor: { proprietor_type_id: 1, first_nations_id: 1, disa_required: true } - } as unknown) as SurveyObject); + } as unknown as SurveyObject); const expectedResponse = { surveyData: { @@ -161,7 +161,7 @@ describe('getSurveyForUpdate', () => { const result = get.getSurveyForUpdate(); - await result(sampleReq, (sampleRes as unknown) as any, (null as unknown) as any); + await result(sampleReq, sampleRes as unknown as any, null as unknown as any); expect(actualResult).to.eql(expectedResponse); expect(getSurveyByIdStub).to.be.calledOnce; }); diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/view.test.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/view.test.ts index 7a208d0b6f..dc475a63e6 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/view.test.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/view.test.ts @@ -22,7 +22,7 @@ describe('survey/{surveyId}/view', () => { sinon.stub(db, 'getDBConnection').returns(dbConnectionObj); - sinon.stub(SurveyService.prototype, 'getSurveyById').resolves(({ id: 2 } as unknown) as SurveyObject); + sinon.stub(SurveyService.prototype, 'getSurveyById').resolves({ id: 2 } as unknown as SurveyObject); sinon.stub(SurveyService.prototype, 'getSurveySupplementaryDataById').resolves({ survey_metadata_publish: { diff --git a/api/src/paths/project/{projectId}/view.test.ts b/api/src/paths/project/{projectId}/view.test.ts index 36f41a5c98..a4eb7b4c07 100644 --- a/api/src/paths/project/{projectId}/view.test.ts +++ b/api/src/paths/project/{projectId}/view.test.ts @@ -17,7 +17,7 @@ describe('project/{projectId}/view', () => { const ajv = new Ajv(); it('is valid openapi v3 schema', () => { - expect(ajv.validateSchema((GET.apiDoc as unknown) as object)).to.be.true; + expect(ajv.validateSchema(GET.apiDoc as unknown as object)).to.be.true; }); }); diff --git a/api/src/paths/publish/attachment/resubmit.test.ts b/api/src/paths/publish/attachment/resubmit.test.ts index c65d50af46..e9982c7773 100644 --- a/api/src/paths/publish/attachment/resubmit.test.ts +++ b/api/src/paths/publish/attachment/resubmit.test.ts @@ -16,7 +16,7 @@ describe('resubmit', () => { const ajv = new Ajv(); it('is valid openapi v3 schema', () => { - expect(ajv.validateSchema((POST.apiDoc as unknown) as object)).to.be.true; + expect(ajv.validateSchema(POST.apiDoc as unknown as object)).to.be.true; }); }); @@ -64,7 +64,7 @@ describe('resubmit', () => { const requestHandler = resubmitAttachment(); - await requestHandler(sampleReq, (sampleRes as unknown) as any, mockNext); + await requestHandler(sampleReq, sampleRes as unknown as any, mockNext); expect(actualResult).to.eql(true); }); diff --git a/api/src/paths/publish/survey.test.ts b/api/src/paths/publish/survey.test.ts index 5f74e6208a..a1e62eacbc 100644 --- a/api/src/paths/publish/survey.test.ts +++ b/api/src/paths/publish/survey.test.ts @@ -16,7 +16,7 @@ describe('survey', () => { const ajv = new Ajv(); it('is valid openapi v3 schema', () => { - expect(ajv.validateSchema((POST.apiDoc as unknown) as object)).to.be.true; + expect(ajv.validateSchema(POST.apiDoc as unknown as object)).to.be.true; }); }); @@ -58,7 +58,7 @@ describe('survey', () => { const requestHandler = publishSurvey(); - await requestHandler(sampleReq, (sampleRes as unknown) as any, mockNext); + await requestHandler(sampleReq, sampleRes as unknown as any, mockNext); expect(actualResult).to.eql({ submission_uuid: '123-456-789' }); }); diff --git a/api/src/paths/telemetry/deployments.test.ts b/api/src/paths/telemetry/deployments.test.ts index 9766e989d9..a474d65ae6 100644 --- a/api/src/paths/telemetry/deployments.test.ts +++ b/api/src/paths/telemetry/deployments.test.ts @@ -4,14 +4,14 @@ import { BctwService, IManualTelemetry } from '../../services/bctw-service'; import { getRequestHandlerMocks } from '../../__mocks__/db'; import { getAllTelemetryByDeploymentIds } from './deployments'; -const mockTelemetry = ([ +const mockTelemetry = [ { telemetry_manual_id: 1 }, { telemetry_manual_id: 2 } -] as unknown[]) as IManualTelemetry[]; +] as unknown[] as IManualTelemetry[]; describe('getAllTelemetryByDeploymentIds', () => { afterEach(() => { diff --git a/api/src/paths/telemetry/device/index.test.ts b/api/src/paths/telemetry/device/index.test.ts index b029c480d1..106b471fc0 100644 --- a/api/src/paths/telemetry/device/index.test.ts +++ b/api/src/paths/telemetry/device/index.test.ts @@ -15,7 +15,7 @@ describe('upsertDevice', () => { const ajv = new Ajv(); it('is valid openapi v3 schema', () => { - expect(ajv.validateSchema((POST.apiDoc as unknown) as object)).to.be.true; + expect(ajv.validateSchema(POST.apiDoc as unknown as object)).to.be.true; }); }); diff --git a/api/src/paths/telemetry/device/{deviceId}.test.ts b/api/src/paths/telemetry/device/{deviceId}.test.ts index 00ea65b3d8..3addc860f5 100644 --- a/api/src/paths/telemetry/device/{deviceId}.test.ts +++ b/api/src/paths/telemetry/device/{deviceId}.test.ts @@ -14,7 +14,7 @@ describe('getDeviceDetails', () => { const ajv = new Ajv(); it('is valid openapi v3 schema', () => { - expect(ajv.validateSchema((GET.apiDoc as unknown) as object)).to.be.true; + expect(ajv.validateSchema(GET.apiDoc as unknown as object)).to.be.true; }); }); diff --git a/api/src/paths/telemetry/manual/delete.test.ts b/api/src/paths/telemetry/manual/delete.test.ts index cd2a0ba632..a0fa851e9c 100644 --- a/api/src/paths/telemetry/manual/delete.test.ts +++ b/api/src/paths/telemetry/manual/delete.test.ts @@ -4,14 +4,14 @@ import { BctwService, IManualTelemetry } from '../../../services/bctw-service'; import { getRequestHandlerMocks } from '../../../__mocks__/db'; import { deleteManualTelemetry } from './delete'; -const mockTelemetry = ([ +const mockTelemetry = [ { telemetry_manual_id: 1 }, { telemetry_manual_id: 2 } -] as unknown[]) as IManualTelemetry[]; +] as unknown[] as IManualTelemetry[]; describe('deleteManualTelemetry', () => { afterEach(() => { diff --git a/api/src/paths/telemetry/manual/deployments.test.ts b/api/src/paths/telemetry/manual/deployments.test.ts index ecf325a43c..6e7f831eda 100644 --- a/api/src/paths/telemetry/manual/deployments.test.ts +++ b/api/src/paths/telemetry/manual/deployments.test.ts @@ -4,14 +4,14 @@ import { BctwService, IManualTelemetry } from '../../../services/bctw-service'; import { getRequestHandlerMocks } from '../../../__mocks__/db'; import { getManualTelemetryByDeploymentIds } from './deployments'; -const mockTelemetry = ([ +const mockTelemetry = [ { telemetry_manual_id: 1 }, { telemetry_manual_id: 2 } -] as unknown[]) as IManualTelemetry[]; +] as unknown[] as IManualTelemetry[]; describe('getManualTelemetryByDeploymentIds', () => { afterEach(() => { diff --git a/api/src/paths/telemetry/manual/index.test.ts b/api/src/paths/telemetry/manual/index.test.ts index 2d9774b5bc..ca82e19789 100644 --- a/api/src/paths/telemetry/manual/index.test.ts +++ b/api/src/paths/telemetry/manual/index.test.ts @@ -5,14 +5,14 @@ import { createManualTelemetry, GET, getManualTelemetry, PATCH, POST, updateManu import { BctwService, IManualTelemetry } from '../../../services/bctw-service'; import { getRequestHandlerMocks } from '../../../__mocks__/db'; -const mockTelemetry = ([ +const mockTelemetry = [ { telemetry_manual_id: 1 }, { telemetry_manual_id: 2 } -] as unknown[]) as IManualTelemetry[]; +] as unknown[] as IManualTelemetry[]; describe('manual telemetry endpoints', () => { afterEach(() => { @@ -23,7 +23,7 @@ describe('manual telemetry endpoints', () => { describe('openapi schema', () => { it('is valid openapi v3 schema', () => { const ajv = new Ajv(); - expect(ajv.validateSchema((GET.apiDoc as unknown) as object)).to.be.true; + expect(ajv.validateSchema(GET.apiDoc as unknown as object)).to.be.true; }); }); it('should retrieve all manual telemetry', async () => { @@ -59,7 +59,7 @@ describe('manual telemetry endpoints', () => { describe('openapi schema', () => { it('is valid openapi v3 schema', () => { const ajv = new Ajv(); - expect(ajv.validateSchema((POST.apiDoc as unknown) as object)).to.be.true; + expect(ajv.validateSchema(POST.apiDoc as unknown as object)).to.be.true; }); }); it('should bulk create manual telemetry', async () => { @@ -94,7 +94,7 @@ describe('manual telemetry endpoints', () => { describe('openapi schema', () => { it('is valid openapi v3 schema', () => { const ajv = new Ajv(); - expect(ajv.validateSchema((PATCH.apiDoc as unknown) as object)).to.be.true; + expect(ajv.validateSchema(PATCH.apiDoc as unknown as object)).to.be.true; }); }); it('should bulk update manual telemetry', async () => { diff --git a/api/src/paths/telemetry/vendor/deployments.test.ts b/api/src/paths/telemetry/vendor/deployments.test.ts index b01dec5801..5f3f62f84b 100644 --- a/api/src/paths/telemetry/vendor/deployments.test.ts +++ b/api/src/paths/telemetry/vendor/deployments.test.ts @@ -4,14 +4,14 @@ import { BctwService, IManualTelemetry } from '../../../services/bctw-service'; import { getRequestHandlerMocks } from '../../../__mocks__/db'; import { getVendorTelemetryByDeploymentIds } from './deployments'; -const mockTelemetry = ([ +const mockTelemetry = [ { telemetry_manual_id: 1 }, { telemetry_manual_id: 2 } -] as unknown[]) as IManualTelemetry[]; +] as unknown[] as IManualTelemetry[]; describe('getVendorTelemetryByDeploymentIds', () => { afterEach(() => { diff --git a/api/src/paths/version.test.ts b/api/src/paths/version.test.ts index 2d6cf1f02e..9c15001db7 100644 --- a/api/src/paths/version.test.ts +++ b/api/src/paths/version.test.ts @@ -27,7 +27,7 @@ describe('version', () => { it('should return versionInfo on success', async () => { const result = version.getVersionInformation(); - await result((null as unknown) as any, sampleRes as any, (null as unknown) as any); + await result(null as unknown as any, sampleRes as any, null as unknown as any); expect(actualResult).to.eql({ version: process.env.VERSION, diff --git a/api/src/repositories/administrative-activity-repository.test.ts b/api/src/repositories/administrative-activity-repository.test.ts index 926535507a..92fef444d7 100644 --- a/api/src/repositories/administrative-activity-repository.test.ts +++ b/api/src/repositories/administrative-activity-repository.test.ts @@ -43,10 +43,10 @@ describe('AdministrativeActivityRepository', () => { const mockDBConnection = getMockDBConnection({ sql: async () => - Promise.resolve(({ + Promise.resolve({ rowCount: 1, rows: mockResponse - } as unknown) as Promise>) + } as unknown as Promise>) }); const aaRepo = new AdministrativeActivityRepository(mockDBConnection); @@ -87,10 +87,10 @@ describe('AdministrativeActivityRepository', () => { create_date: new Date() }; - const mockResponse = ({ + const mockResponse = { rowCount: 1, rows: [mockRecord] - } as any) as Promise>; + } as any as Promise>; const dbConnectionObj = getMockDBConnection({ sql: () => mockResponse }); @@ -105,10 +105,10 @@ describe('AdministrativeActivityRepository', () => { }); it('should throw an error if the repo fails to insert the administrative activity record', async () => { - const mockResponse = ({ + const mockResponse = { rowCount: 1, rows: [] - } as any) as Promise>; + } as any as Promise>; const dbConnectionObj = getMockDBConnection({ sql: () => mockResponse }); @@ -141,10 +141,10 @@ describe('AdministrativeActivityRepository', () => { create_date: new Date() }; - const mockResponse = ({ + const mockResponse = { rowCount: 1, rows: [mockRecord] - } as any) as Promise>; + } as any as Promise>; const dbConnectionObj = getMockDBConnection({ sql: () => mockResponse }); @@ -158,10 +158,10 @@ describe('AdministrativeActivityRepository', () => { }); it('returns null if no matching administrative record is found', async () => { - const mockResponse = ({ + const mockResponse = { rowCount: 0, rows: [] - } as any) as Promise>; + } as any as Promise>; const dbConnectionObj = getMockDBConnection({ sql: () => mockResponse }); @@ -185,10 +185,10 @@ describe('AdministrativeActivityRepository', () => { administrative_activity_id: 1 }; - const mockResponse = ({ + const mockResponse = { rowCount: 1, rows: [mockRecord] - } as any) as Promise>; + } as any as Promise>; const dbConnectionObj = getMockDBConnection({ sql: sinon.fake(() => mockResponse) }); @@ -206,10 +206,10 @@ describe('AdministrativeActivityRepository', () => { }); it('should throw an error if no matching administrative record is found', async () => { - const mockResponse = ({ + const mockResponse = { rowCount: 0, rows: [] - } as any) as Promise>; + } as any as Promise>; const dbConnectionObj = getMockDBConnection({ sql: () => mockResponse }); diff --git a/api/src/repositories/attachment-repository.test.ts b/api/src/repositories/attachment-repository.test.ts index af4bda684b..b1439c478f 100644 --- a/api/src/repositories/attachment-repository.test.ts +++ b/api/src/repositories/attachment-repository.test.ts @@ -18,7 +18,7 @@ describe('AttachmentRepository', () => { describe('Attachment', () => { describe('getProjectAttachments', () => { it('should return rows', async () => { - const mockResponse = ({ rows: [{ id: 1 }], rowCount: 1 } as any) as Promise>; + const mockResponse = { rows: [{ id: 1 }], rowCount: 1 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new AttachmentRepository(dbConnection); @@ -29,7 +29,7 @@ describe('AttachmentRepository', () => { }); it('should return empty rows', async () => { - const mockResponse = ({ rows: [], rowCount: 0 } as any) as Promise>; + const mockResponse = { rows: [], rowCount: 0 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new AttachmentRepository(dbConnection); @@ -42,7 +42,7 @@ describe('AttachmentRepository', () => { describe('getProjectAttachmentById', () => { it('should return row', async () => { - const mockResponse = ({ rows: [{ id: 1 }], rowCount: 1 } as any) as Promise>; + const mockResponse = { rows: [{ id: 1 }], rowCount: 1 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new AttachmentRepository(dbConnection); @@ -55,7 +55,7 @@ describe('AttachmentRepository', () => { describe('getProjectAttachmentsByIds', () => { it('should return rows', async () => { - const mockResponse = ({ rows: [{ id: 1 }, { id: 2 }], rowCount: 2 } as any) as Promise>; + const mockResponse = { rows: [{ id: 1 }, { id: 2 }], rowCount: 2 } as any as Promise>; const dbConnection = getMockDBConnection({ knex: () => mockResponse }); const repository = new AttachmentRepository(dbConnection); @@ -66,7 +66,7 @@ describe('AttachmentRepository', () => { }); it('should return empty rows', async () => { - const mockResponse = ({ rows: [], rowCount: 0 } as any) as Promise>; + const mockResponse = { rows: [], rowCount: 0 } as any as Promise>; const dbConnection = getMockDBConnection({ knex: () => mockResponse }); const repository = new AttachmentRepository(dbConnection); @@ -79,13 +79,13 @@ describe('AttachmentRepository', () => { describe('insertProjectAttachment', () => { it('should return row', async () => { - const mockResponse = ({ rows: [{ id: 1 }], rowCount: 1 } as any) as Promise>; + const mockResponse = { rows: [{ id: 1 }], rowCount: 1 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new AttachmentRepository(dbConnection); const response = await repository.insertProjectAttachment( - ({ file: 'name' } as unknown) as Express.Multer.File, + { file: 'name' } as unknown as Express.Multer.File, 1, 'string', 'string' @@ -95,14 +95,14 @@ describe('AttachmentRepository', () => { }); it('should throw an error', async () => { - const mockResponse = ({ rows: [], rowCount: 0 } as any) as Promise>; + const mockResponse = { rows: [], rowCount: 0 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new AttachmentRepository(dbConnection); try { await repository.insertProjectAttachment( - ({ file: 'name' } as unknown) as Express.Multer.File, + { file: 'name' } as unknown as Express.Multer.File, 1, 'string', 'string' @@ -116,7 +116,7 @@ describe('AttachmentRepository', () => { describe('updateProjectAttachment', () => { it('should return row', async () => { - const mockResponse = ({ rows: [{ id: 1 }], rowCount: 1 } as any) as Promise>; + const mockResponse = { rows: [{ id: 1 }], rowCount: 1 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new AttachmentRepository(dbConnection); @@ -127,7 +127,7 @@ describe('AttachmentRepository', () => { }); it('should throw an error', async () => { - const mockResponse = ({ rows: [], rowCount: 0 } as any) as Promise>; + const mockResponse = { rows: [], rowCount: 0 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new AttachmentRepository(dbConnection); @@ -143,7 +143,7 @@ describe('AttachmentRepository', () => { describe('getProjectAttachmentByFileName', () => { it('should return result', async () => { - const mockResponse = ({ rows: [{ id: 1 }], rowCount: 1 } as any) as Promise>; + const mockResponse = { rows: [{ id: 1 }], rowCount: 1 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new AttachmentRepository(dbConnection); @@ -154,7 +154,7 @@ describe('AttachmentRepository', () => { }); it('should return empty rows', async () => { - const mockResponse = ({ rows: [], rowCount: 0 } as any) as Promise>; + const mockResponse = { rows: [], rowCount: 0 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new AttachmentRepository(dbConnection); @@ -167,7 +167,7 @@ describe('AttachmentRepository', () => { describe('getProjectAttachmentS3Key', () => { it('should return result', async () => { - const mockResponse = ({ rows: [{ key: 1 }], rowCount: 1 } as any) as Promise>; + const mockResponse = { rows: [{ key: 1 }], rowCount: 1 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new AttachmentRepository(dbConnection); @@ -178,7 +178,7 @@ describe('AttachmentRepository', () => { }); it('should throw an error', async () => { - const mockResponse = ({ rows: [], rowCount: 0 } as any) as Promise>; + const mockResponse = { rows: [], rowCount: 0 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new AttachmentRepository(dbConnection); @@ -194,7 +194,7 @@ describe('AttachmentRepository', () => { describe('deleteProjectAttachmentRecord', () => { it('should return result', async () => { - const mockResponse = ({ rows: [{ id: 1 }], rowCount: 1 } as any) as Promise>; + const mockResponse = { rows: [{ id: 1 }], rowCount: 1 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new AttachmentRepository(dbConnection); @@ -205,7 +205,7 @@ describe('AttachmentRepository', () => { }); it('should throw an error', async () => { - const mockResponse = ({ rows: [], rowCount: 0 } as any) as Promise>; + const mockResponse = { rows: [], rowCount: 0 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new AttachmentRepository(dbConnection); @@ -223,7 +223,7 @@ describe('AttachmentRepository', () => { describe('Report Attachment', () => { describe('getProjectReportAttachments', () => { it('should return rows', async () => { - const mockResponse = ({ rows: [{ id: 1 }], rowCount: 1 } as any) as Promise>; + const mockResponse = { rows: [{ id: 1 }], rowCount: 1 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new AttachmentRepository(dbConnection); @@ -234,7 +234,7 @@ describe('AttachmentRepository', () => { }); it('should return empty rows', async () => { - const mockResponse = ({ rows: [], rowCount: 0 } as any) as Promise>; + const mockResponse = { rows: [], rowCount: 0 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new AttachmentRepository(dbConnection); @@ -247,7 +247,7 @@ describe('AttachmentRepository', () => { describe('getProjectReportAttachmentById', () => { it('should return row', async () => { - const mockResponse = ({ rows: [{ id: 1 }], rowCount: 1 } as any) as Promise>; + const mockResponse = { rows: [{ id: 1 }], rowCount: 1 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new AttachmentRepository(dbConnection); @@ -260,7 +260,7 @@ describe('AttachmentRepository', () => { describe('getProjectReportAttachmentsByIds', () => { it('should return rows', async () => { - const mockResponse = ({ rows: [{ id: 1 }, { id: 2 }], rowCount: 2 } as any) as Promise>; + const mockResponse = { rows: [{ id: 1 }, { id: 2 }], rowCount: 2 } as any as Promise>; const dbConnection = getMockDBConnection({ knex: () => mockResponse }); const repository = new AttachmentRepository(dbConnection); @@ -273,7 +273,7 @@ describe('AttachmentRepository', () => { describe('getProjectReportAttachmentAuthors', () => { it('should return rows', async () => { - const mockResponse = ({ rows: [{ id: 1 }], rowCount: 1 } as any) as Promise>; + const mockResponse = { rows: [{ id: 1 }], rowCount: 1 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new AttachmentRepository(dbConnection); @@ -284,7 +284,7 @@ describe('AttachmentRepository', () => { }); it('should return empty rows', async () => { - const mockResponse = ({ rows: [], rowCount: 0 } as any) as Promise>; + const mockResponse = { rows: [], rowCount: 0 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new AttachmentRepository(dbConnection); @@ -297,7 +297,7 @@ describe('AttachmentRepository', () => { describe('insertProjectReportAttachment', () => { it('should return row', async () => { - const mockResponse = ({ rows: [{ id: 1 }], rowCount: 1 } as any) as Promise>; + const mockResponse = { rows: [{ id: 1 }], rowCount: 1 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new AttachmentRepository(dbConnection); @@ -306,7 +306,7 @@ describe('AttachmentRepository', () => { 'string', 1, 1, - ({ title: 'string' } as unknown) as PostReportAttachmentMetadata, + { title: 'string' } as unknown as PostReportAttachmentMetadata, 'string' ); @@ -314,7 +314,7 @@ describe('AttachmentRepository', () => { }); it('should throw an error', async () => { - const mockResponse = ({ rows: [], rowCount: 0 } as any) as Promise>; + const mockResponse = { rows: [], rowCount: 0 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new AttachmentRepository(dbConnection); @@ -324,7 +324,7 @@ describe('AttachmentRepository', () => { 'string', 1, 1, - ({ title: 'string' } as unknown) as PostReportAttachmentMetadata, + { title: 'string' } as unknown as PostReportAttachmentMetadata, 'string' ); expect.fail(); @@ -336,28 +336,28 @@ describe('AttachmentRepository', () => { describe('updateProjectReportAttachment', () => { it('should return row', async () => { - const mockResponse = ({ rows: [{ id: 1 }], rowCount: 1 } as any) as Promise>; + const mockResponse = { rows: [{ id: 1 }], rowCount: 1 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new AttachmentRepository(dbConnection); - const response = await repository.updateProjectReportAttachment('string', 1, ({ + const response = await repository.updateProjectReportAttachment('string', 1, { title: 'string' - } as unknown) as PutReportAttachmentMetadata); + } as unknown as PutReportAttachmentMetadata); expect(response).to.eql({ id: 1 }); }); it('should throw an error', async () => { - const mockResponse = ({ rows: [], rowCount: 0 } as any) as Promise>; + const mockResponse = { rows: [], rowCount: 0 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new AttachmentRepository(dbConnection); try { - await repository.updateProjectReportAttachment('string', 1, ({ + await repository.updateProjectReportAttachment('string', 1, { title: 'string' - } as unknown) as PutReportAttachmentMetadata); + } as unknown as PutReportAttachmentMetadata); expect.fail(); } catch (error) { expect((error as Error).message).to.equal('Failed to update project attachment data'); @@ -367,7 +367,7 @@ describe('AttachmentRepository', () => { describe('getProjectReportAttachmentByFileName', () => { it('should return result', async () => { - const mockResponse = ({ rows: [{ id: 1 }], rowCount: 1 } as any) as Promise>; + const mockResponse = { rows: [{ id: 1 }], rowCount: 1 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new AttachmentRepository(dbConnection); @@ -380,7 +380,7 @@ describe('AttachmentRepository', () => { describe('deleteProjectReportAttachmentAuthors', () => { it('should return result', async () => { - const mockResponse = ({ rows: [{ id: 1 }], rowCount: 1 } as any) as Promise>; + const mockResponse = { rows: [{ id: 1 }], rowCount: 1 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new AttachmentRepository(dbConnection); @@ -393,7 +393,7 @@ describe('AttachmentRepository', () => { describe('insertProjectReportAttachmentAuthor', () => { it('should return result', async () => { - const mockResponse = ({ rows: [{ id: 1 }], rowCount: 1 } as any) as Promise>; + const mockResponse = { rows: [{ id: 1 }], rowCount: 1 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new AttachmentRepository(dbConnection); @@ -407,7 +407,7 @@ describe('AttachmentRepository', () => { }); it('should throw an error', async () => { - const mockResponse = ({ rows: [], rowCount: 0 } as any) as Promise>; + const mockResponse = { rows: [], rowCount: 0 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new AttachmentRepository(dbConnection); @@ -426,28 +426,28 @@ describe('AttachmentRepository', () => { describe('updateProjectReportAttachmentMetadata', () => { it('should return row', async () => { - const mockResponse = ({ rows: [{ id: 1 }], rowCount: 1 } as any) as Promise>; + const mockResponse = { rows: [{ id: 1 }], rowCount: 1 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new AttachmentRepository(dbConnection); - const response = await repository.updateProjectReportAttachmentMetadata(1, 1, ({ + const response = await repository.updateProjectReportAttachmentMetadata(1, 1, { title: 'string' - } as unknown) as PutReportAttachmentMetadata); + } as unknown as PutReportAttachmentMetadata); expect(response).to.eql(undefined); }); it('should throw an error', async () => { - const mockResponse = ({ rows: [], rowCount: 0 } as any) as Promise>; + const mockResponse = { rows: [], rowCount: 0 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new AttachmentRepository(dbConnection); try { - await repository.updateProjectReportAttachmentMetadata(1, 1, ({ + await repository.updateProjectReportAttachmentMetadata(1, 1, { title: 'string' - } as unknown) as PutReportAttachmentMetadata); + } as unknown as PutReportAttachmentMetadata); expect.fail(); } catch (error) { expect((error as Error).message).to.equal('Failed to update Project Report Attachment Metadata'); @@ -457,7 +457,7 @@ describe('AttachmentRepository', () => { describe('getProjectReportAttachmentS3Key', () => { it('should return result', async () => { - const mockResponse = ({ rows: [{ key: 1 }], rowCount: 1 } as any) as Promise>; + const mockResponse = { rows: [{ key: 1 }], rowCount: 1 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new AttachmentRepository(dbConnection); @@ -468,7 +468,7 @@ describe('AttachmentRepository', () => { }); it('should throw an error', async () => { - const mockResponse = ({ rows: [], rowCount: 0 } as any) as Promise>; + const mockResponse = { rows: [], rowCount: 0 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new AttachmentRepository(dbConnection); @@ -484,7 +484,7 @@ describe('AttachmentRepository', () => { describe('deleteProjectReportAttachmentRecord', () => { it('should return result', async () => { - const mockResponse = ({ rows: [{ id: 1 }], rowCount: 1 } as any) as Promise>; + const mockResponse = { rows: [{ id: 1 }], rowCount: 1 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new AttachmentRepository(dbConnection); @@ -495,7 +495,7 @@ describe('AttachmentRepository', () => { }); it('should throw an error', async () => { - const mockResponse = ({ rows: [], rowCount: 0 } as any) as Promise>; + const mockResponse = { rows: [], rowCount: 0 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new AttachmentRepository(dbConnection); @@ -515,7 +515,7 @@ describe('AttachmentRepository', () => { describe('Attachment', () => { describe('getSurveyAttachments', () => { it('should return rows', async () => { - const mockResponse = ({ rows: [{ id: 1 }], rowCount: 1 } as any) as Promise>; + const mockResponse = { rows: [{ id: 1 }], rowCount: 1 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new AttachmentRepository(dbConnection); @@ -526,7 +526,7 @@ describe('AttachmentRepository', () => { }); it('should return empty rows', async () => { - const mockResponse = ({ rows: [], rowCount: 0 } as any) as Promise>; + const mockResponse = { rows: [], rowCount: 0 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new AttachmentRepository(dbConnection); @@ -539,7 +539,7 @@ describe('AttachmentRepository', () => { describe('getSurveyAttachmentsByIds', () => { it('should return rows', async () => { - const mockResponse = ({ rows: [{ id: 1 }, { id: 2 }], rowCount: 2 } as any) as Promise>; + const mockResponse = { rows: [{ id: 1 }, { id: 2 }], rowCount: 2 } as any as Promise>; const dbConnection = getMockDBConnection({ knex: () => mockResponse }); const repository = new AttachmentRepository(dbConnection); @@ -552,7 +552,7 @@ describe('AttachmentRepository', () => { describe('deleteSurveyAttachmentRecord', () => { it('should return result', async () => { - const mockResponse = ({ rows: [{ id: 1 }], rowCount: 1 } as any) as Promise>; + const mockResponse = { rows: [{ id: 1 }], rowCount: 1 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new AttachmentRepository(dbConnection); @@ -563,7 +563,7 @@ describe('AttachmentRepository', () => { }); it('should throw an error', async () => { - const mockResponse = ({ rows: [], rowCount: 0 } as any) as Promise>; + const mockResponse = { rows: [], rowCount: 0 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new AttachmentRepository(dbConnection); @@ -579,7 +579,7 @@ describe('AttachmentRepository', () => { describe('getSurveyAttachmentS3Key', () => { it('should return result', async () => { - const mockResponse = ({ rows: [{ key: 1 }], rowCount: 1 } as any) as Promise>; + const mockResponse = { rows: [{ key: 1 }], rowCount: 1 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new AttachmentRepository(dbConnection); @@ -590,7 +590,7 @@ describe('AttachmentRepository', () => { }); it('should throw an error', async () => { - const mockResponse = ({ rows: [], rowCount: 0 } as any) as Promise>; + const mockResponse = { rows: [], rowCount: 0 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new AttachmentRepository(dbConnection); @@ -606,7 +606,7 @@ describe('AttachmentRepository', () => { describe('updateSurveyAttachment', () => { it('should return row', async () => { - const mockResponse = ({ rows: [{ id: 1 }], rowCount: 1 } as any) as Promise>; + const mockResponse = { rows: [{ id: 1 }], rowCount: 1 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new AttachmentRepository(dbConnection); @@ -617,7 +617,7 @@ describe('AttachmentRepository', () => { }); it('should throw an error', async () => { - const mockResponse = ({ rows: [], rowCount: 0 } as any) as Promise>; + const mockResponse = { rows: [], rowCount: 0 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new AttachmentRepository(dbConnection); @@ -633,7 +633,7 @@ describe('AttachmentRepository', () => { describe('insertSurveyAttachment', () => { it('should return row', async () => { - const mockResponse = ({ rows: [{ id: 1 }], rowCount: 1 } as any) as Promise>; + const mockResponse = { rows: [{ id: 1 }], rowCount: 1 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new AttachmentRepository(dbConnection); @@ -644,7 +644,7 @@ describe('AttachmentRepository', () => { }); it('should throw an error', async () => { - const mockResponse = ({ rows: [], rowCount: 0 } as any) as Promise>; + const mockResponse = { rows: [], rowCount: 0 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new AttachmentRepository(dbConnection); @@ -660,7 +660,7 @@ describe('AttachmentRepository', () => { describe('getSurveyAttachmentByFileName', () => { it('should return result', async () => { - const mockResponse = ({ rows: [{ id: 1 }], rowCount: 1 } as any) as Promise>; + const mockResponse = { rows: [{ id: 1 }], rowCount: 1 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new AttachmentRepository(dbConnection); @@ -675,7 +675,7 @@ describe('AttachmentRepository', () => { describe('Report Attachment', () => { describe('getSurveyReportAttachments', () => { it('should return rows', async () => { - const mockResponse = ({ rows: [{ id: 1 }], rowCount: 1 } as any) as Promise>; + const mockResponse = { rows: [{ id: 1 }], rowCount: 1 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new AttachmentRepository(dbConnection); @@ -686,7 +686,7 @@ describe('AttachmentRepository', () => { }); it('should return empty rows', async () => { - const mockResponse = ({ rows: [], rowCount: 0 } as any) as Promise>; + const mockResponse = { rows: [], rowCount: 0 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new AttachmentRepository(dbConnection); @@ -699,7 +699,7 @@ describe('AttachmentRepository', () => { describe('getSurveyReportAttachmentById', () => { it('should return row', async () => { - const mockResponse = ({ rows: [{ id: 1 }], rowCount: 1 } as any) as Promise>; + const mockResponse = { rows: [{ id: 1 }], rowCount: 1 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new AttachmentRepository(dbConnection); @@ -712,7 +712,7 @@ describe('AttachmentRepository', () => { describe('getSurveyReportAttachmentsByIds', () => { it('should return rows', async () => { - const mockResponse = ({ rows: [{ id: 1 }, { id: 2 }], rowCount: 2 } as any) as Promise>; + const mockResponse = { rows: [{ id: 1 }, { id: 2 }], rowCount: 2 } as any as Promise>; const dbConnection = getMockDBConnection({ knex: () => mockResponse }); const repository = new AttachmentRepository(dbConnection); @@ -725,7 +725,7 @@ describe('AttachmentRepository', () => { describe('getSurveyReportAttachmentAuthors', () => { it('should return rows', async () => { - const mockResponse = ({ rows: [{ id: 1 }], rowCount: 1 } as any) as Promise>; + const mockResponse = { rows: [{ id: 1 }], rowCount: 1 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new AttachmentRepository(dbConnection); @@ -738,7 +738,7 @@ describe('AttachmentRepository', () => { describe('insertSurveyReportAttachment', () => { it('should return row', async () => { - const mockResponse = ({ rows: [{ id: 1 }], rowCount: 1 } as any) as Promise>; + const mockResponse = { rows: [{ id: 1 }], rowCount: 1 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new AttachmentRepository(dbConnection); @@ -747,7 +747,7 @@ describe('AttachmentRepository', () => { 'string', 1, 1, - ({ title: 'string' } as unknown) as PostReportAttachmentMetadata, + { title: 'string' } as unknown as PostReportAttachmentMetadata, 'string' ); @@ -755,7 +755,7 @@ describe('AttachmentRepository', () => { }); it('should throw an error', async () => { - const mockResponse = ({ rows: [], rowCount: 0 } as any) as Promise>; + const mockResponse = { rows: [], rowCount: 0 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new AttachmentRepository(dbConnection); @@ -765,7 +765,7 @@ describe('AttachmentRepository', () => { 'string', 1, 1, - ({ title: 'string' } as unknown) as PostReportAttachmentMetadata, + { title: 'string' } as unknown as PostReportAttachmentMetadata, 'string' ); expect.fail(); @@ -777,28 +777,28 @@ describe('AttachmentRepository', () => { describe('updateSurveyReportAttachment', () => { it('should return row', async () => { - const mockResponse = ({ rows: [{ id: 1 }], rowCount: 1 } as any) as Promise>; + const mockResponse = { rows: [{ id: 1 }], rowCount: 1 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new AttachmentRepository(dbConnection); - const response = await repository.updateSurveyReportAttachment('string', 1, ({ + const response = await repository.updateSurveyReportAttachment('string', 1, { title: 'string' - } as unknown) as PutReportAttachmentMetadata); + } as unknown as PutReportAttachmentMetadata); expect(response).to.eql({ id: 1 }); }); it('should throw an error', async () => { - const mockResponse = ({ rows: [], rowCount: 0 } as any) as Promise>; + const mockResponse = { rows: [], rowCount: 0 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new AttachmentRepository(dbConnection); try { - await repository.updateSurveyReportAttachment('string', 1, ({ + await repository.updateSurveyReportAttachment('string', 1, { title: 'string' - } as unknown) as PutReportAttachmentMetadata); + } as unknown as PutReportAttachmentMetadata); expect.fail(); } catch (error) { expect((error as Error).message).to.equal('Failed to update survey report attachment'); @@ -808,7 +808,7 @@ describe('AttachmentRepository', () => { describe('getSurveyReportAttachmentByFileName', () => { it('should return result', async () => { - const mockResponse = ({ rows: [{ id: 1 }], rowCount: 1 } as any) as Promise>; + const mockResponse = { rows: [{ id: 1 }], rowCount: 1 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new AttachmentRepository(dbConnection); @@ -821,7 +821,7 @@ describe('AttachmentRepository', () => { describe('deleteSurveyReportAttachmentAuthors', () => { it('should return result', async () => { - const mockResponse = ({ rows: [{ id: 1 }], rowCount: 1 } as any) as Promise>; + const mockResponse = { rows: [{ id: 1 }], rowCount: 1 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new AttachmentRepository(dbConnection); @@ -834,7 +834,7 @@ describe('AttachmentRepository', () => { describe('insertSurveyReportAttachmentAuthor', () => { it('should return result', async () => { - const mockResponse = ({ rows: [{ id: 1 }], rowCount: 1 } as any) as Promise>; + const mockResponse = { rows: [{ id: 1 }], rowCount: 1 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new AttachmentRepository(dbConnection); @@ -848,7 +848,7 @@ describe('AttachmentRepository', () => { }); it('should throw an error', async () => { - const mockResponse = ({ rows: [], rowCount: 0 } as any) as Promise>; + const mockResponse = { rows: [], rowCount: 0 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new AttachmentRepository(dbConnection); @@ -867,7 +867,7 @@ describe('AttachmentRepository', () => { describe('deleteSurveyReportAttachmentRecord', () => { it('should return result', async () => { - const mockResponse = ({ rows: [{ id: 1 }], rowCount: 1 } as any) as Promise>; + const mockResponse = { rows: [{ id: 1 }], rowCount: 1 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new AttachmentRepository(dbConnection); @@ -878,7 +878,7 @@ describe('AttachmentRepository', () => { }); it('should throw an error', async () => { - const mockResponse = ({ rows: [], rowCount: 0 } as any) as Promise>; + const mockResponse = { rows: [], rowCount: 0 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new AttachmentRepository(dbConnection); @@ -894,7 +894,7 @@ describe('AttachmentRepository', () => { describe('getSurveyReportAttachmentS3Key', () => { it('should return result', async () => { - const mockResponse = ({ rows: [{ key: 1 }], rowCount: 1 } as any) as Promise>; + const mockResponse = { rows: [{ key: 1 }], rowCount: 1 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new AttachmentRepository(dbConnection); @@ -905,7 +905,7 @@ describe('AttachmentRepository', () => { }); it('should throw an error', async () => { - const mockResponse = ({ rows: [], rowCount: 0 } as any) as Promise>; + const mockResponse = { rows: [], rowCount: 0 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new AttachmentRepository(dbConnection); @@ -921,28 +921,28 @@ describe('AttachmentRepository', () => { describe('updateSurveyReportAttachmentMetadata', () => { it('should return row', async () => { - const mockResponse = ({ rows: [{ id: 1 }], rowCount: 1 } as any) as Promise>; + const mockResponse = { rows: [{ id: 1 }], rowCount: 1 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new AttachmentRepository(dbConnection); - const response = await repository.updateSurveyReportAttachmentMetadata(1, 1, ({ + const response = await repository.updateSurveyReportAttachmentMetadata(1, 1, { title: 'string' - } as unknown) as PutReportAttachmentMetadata); + } as unknown as PutReportAttachmentMetadata); expect(response).to.eql(undefined); }); it('should throw an error', async () => { - const mockResponse = ({ rows: [], rowCount: 0 } as any) as Promise>; + const mockResponse = { rows: [], rowCount: 0 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new AttachmentRepository(dbConnection); try { - await repository.updateSurveyReportAttachmentMetadata(1, 1, ({ + await repository.updateSurveyReportAttachmentMetadata(1, 1, { title: 'string' - } as unknown) as PutReportAttachmentMetadata); + } as unknown as PutReportAttachmentMetadata); expect.fail(); } catch (error) { expect((error as Error).message).to.equal('Failed to update Survey Report Attachment metadata'); diff --git a/api/src/repositories/funding-source-repository.test.ts b/api/src/repositories/funding-source-repository.test.ts index 4e9da49ecc..1d9c5f4daa 100644 --- a/api/src/repositories/funding-source-repository.test.ts +++ b/api/src/repositories/funding-source-repository.test.ts @@ -19,7 +19,7 @@ describe('FundingSourceRepository', () => { it('returns an empty array of funding source items', async () => { const expectedResult: FundingSource[] = []; - const mockResponse = ({ rowCount: 1, rows: expectedResult } as unknown) as Promise>; + const mockResponse = { rowCount: 1, rows: expectedResult } as unknown as Promise>; const dbConnection = getMockDBConnection({ knex: async () => mockResponse }); @@ -41,7 +41,7 @@ describe('FundingSourceRepository', () => { } ]; - const mockResponse = ({ rowCount: 1, rows: expectedResult } as unknown) as Promise>; + const mockResponse = { rowCount: 1, rows: expectedResult } as unknown as Promise>; const dbConnection = getMockDBConnection({ knex: async () => mockResponse }); @@ -55,7 +55,7 @@ describe('FundingSourceRepository', () => { describe('hasFundingSourceNameBeenUsed', () => { it('returns true if name exists', async () => { - const mockResponse = ({ rowCount: 1, rows: [] } as unknown) as Promise>; + const mockResponse = { rowCount: 1, rows: [] } as unknown as Promise>; const dbConnection = getMockDBConnection({ sql: async () => mockResponse }); @@ -67,7 +67,7 @@ describe('FundingSourceRepository', () => { }); it('returns false if name doesnt exists', async () => { - const mockResponse = ({ rowCount: 0, rows: [] } as unknown) as Promise>; + const mockResponse = { rowCount: 0, rows: [] } as unknown as Promise>; const dbConnection = getMockDBConnection({ sql: async () => mockResponse }); @@ -88,7 +88,7 @@ describe('FundingSourceRepository', () => { description: 'description' }; - const mockResponse = ({ rowCount: 1, rows: [{ funding_source_id: 1 }] } as unknown) as Promise>; + const mockResponse = { rowCount: 1, rows: [{ funding_source_id: 1 }] } as unknown as Promise>; const dbConnection = getMockDBConnection({ sql: async () => mockResponse }); @@ -107,7 +107,7 @@ describe('FundingSourceRepository', () => { description: 'description' }; - const mockResponse = ({ rowCount: 0, rows: [] } as unknown) as Promise>; + const mockResponse = { rowCount: 0, rows: [] } as unknown as Promise>; const dbConnection = getMockDBConnection({ sql: async () => mockResponse }); @@ -133,7 +133,7 @@ describe('FundingSourceRepository', () => { description: 'description' }; - const mockResponse = ({ rowCount: 1, rows: [expectedResult] } as unknown) as Promise>; + const mockResponse = { rowCount: 1, rows: [expectedResult] } as unknown as Promise>; const dbConnection = getMockDBConnection({ sql: async () => mockResponse }); @@ -147,7 +147,7 @@ describe('FundingSourceRepository', () => { }); it('throws an error if rowCount is 0', async () => { - const mockResponse = ({ rowCount: 0, rows: [] } as unknown) as Promise>; + const mockResponse = { rowCount: 0, rows: [] } as unknown as Promise>; const dbConnection = getMockDBConnection({ sql: async () => mockResponse }); @@ -165,7 +165,7 @@ describe('FundingSourceRepository', () => { }); it('throws an error if rowCount is greater than 1', async () => { - const mockResponse = ({ rowCount: 2, rows: [] } as unknown) as Promise>; + const mockResponse = { rowCount: 2, rows: [] } as unknown as Promise>; const dbConnection = getMockDBConnection({ sql: async () => mockResponse }); @@ -185,15 +185,15 @@ describe('FundingSourceRepository', () => { describe('getFundingSourceSurveyReferences', () => { it('returns an array of funding sources with reference', async () => { - const expectedResult: SurveyFundingSource = ({ + const expectedResult: SurveyFundingSource = { funding_source_id: 1, funding_source_name: 'name', start_date: '2020-01-01', end_date: '2020-01-01', description: 'description' - } as unknown) as SurveyFundingSource; + } as unknown as SurveyFundingSource; - const mockResponse = ({ rowCount: 1, rows: [expectedResult] } as unknown) as Promise>; + const mockResponse = { rowCount: 1, rows: [expectedResult] } as unknown as Promise>; const dbConnection = getMockDBConnection({ sql: async () => mockResponse }); @@ -212,7 +212,7 @@ describe('FundingSourceRepository', () => { const fundingSourceId = 1; const expectedResult = { funding_source_id: fundingSourceId }; - const mockResponse = ({ rowCount: 1, rows: [expectedResult] } as unknown) as Promise>; + const mockResponse = { rowCount: 1, rows: [expectedResult] } as unknown as Promise>; const dbConnection = getMockDBConnection({ sql: async () => mockResponse }); @@ -233,7 +233,7 @@ describe('FundingSourceRepository', () => { }); it('throws an error if rowCount is 0', async () => { - const mockResponse = ({ rowCount: 0, rows: [] } as unknown) as Promise>; + const mockResponse = { rowCount: 0, rows: [] } as unknown as Promise>; const dbConnection = getMockDBConnection({ sql: async () => mockResponse }); @@ -259,7 +259,7 @@ describe('FundingSourceRepository', () => { }); it('throws an error if rowCount is greater than 1', async () => { - const mockResponse = ({ rowCount: 2, rows: [] } as unknown) as Promise>; + const mockResponse = { rowCount: 2, rows: [] } as unknown as Promise>; const dbConnection = getMockDBConnection({ sql: async () => mockResponse }); @@ -289,7 +289,7 @@ describe('FundingSourceRepository', () => { it('deletes a single funding source', async () => { const expectedResult = { funding_source_id: 1 }; - const mockResponse = ({ rowCount: 1, rows: [expectedResult] } as unknown) as Promise>; + const mockResponse = { rowCount: 1, rows: [expectedResult] } as unknown as Promise>; const dbConnection = getMockDBConnection({ sql: async () => mockResponse }); @@ -303,7 +303,7 @@ describe('FundingSourceRepository', () => { }); it('throws an error if rowCount is 0', async () => { - const mockResponse = ({ rowCount: 0, rows: [] } as unknown) as Promise>; + const mockResponse = { rowCount: 0, rows: [] } as unknown as Promise>; const dbConnection = getMockDBConnection({ sql: async () => mockResponse }); @@ -321,7 +321,7 @@ describe('FundingSourceRepository', () => { }); it('throws an error if rowCount is greater than 1', async () => { - const mockResponse = ({ rowCount: 2, rows: [] } as unknown) as Promise>; + const mockResponse = { rowCount: 2, rows: [] } as unknown as Promise>; const dbConnection = getMockDBConnection({ sql: async () => mockResponse }); @@ -343,7 +343,7 @@ describe('FundingSourceRepository', () => { it('returns a single funding source basic supplementary data', async () => { const expectedResult = { survey_reference_count: 1, survey_reference_amount_total: 1 }; - const mockResponse = ({ rowCount: 1, rows: [expectedResult] } as unknown) as Promise>; + const mockResponse = { rowCount: 1, rows: [expectedResult] } as unknown as Promise>; const dbConnection = getMockDBConnection({ sql: async () => mockResponse }); @@ -357,7 +357,7 @@ describe('FundingSourceRepository', () => { }); it('throws an error if rowCount is 0', async () => { - const mockResponse = ({ rowCount: 0, rows: [] } as unknown) as Promise>; + const mockResponse = { rowCount: 0, rows: [] } as unknown as Promise>; const dbConnection = getMockDBConnection({ sql: async () => mockResponse }); @@ -385,7 +385,7 @@ describe('FundingSourceRepository', () => { revision_count: 1 }; - const mockResponse = ({ rowCount: 1, rows: [expectedResult] } as unknown) as Promise>; + const mockResponse = { rowCount: 1, rows: [expectedResult] } as unknown as Promise>; const dbConnection = getMockDBConnection({ sql: async () => mockResponse }); @@ -397,7 +397,7 @@ describe('FundingSourceRepository', () => { }); it('throws an error if rowCount is 0', async () => { - const mockResponse = ({ rowCount: 0, rows: [] } as unknown) as Promise>; + const mockResponse = { rowCount: 0, rows: [] } as unknown as Promise>; const dbConnection = getMockDBConnection({ sql: async () => mockResponse }); @@ -425,7 +425,7 @@ describe('FundingSourceRepository', () => { } ]; - const mockResponse = ({ rowCount: 1, rows: expectedResult } as unknown) as Promise>; + const mockResponse = { rowCount: 1, rows: expectedResult } as unknown as Promise>; const dbConnection = getMockDBConnection({ sql: async () => mockResponse }); @@ -439,7 +439,7 @@ describe('FundingSourceRepository', () => { describe('postSurveyFundingSource', () => { it('inserts new survey fundng source', async () => { - const mockResponse = ({ rowCount: 1, rows: [] } as unknown) as Promise>; + const mockResponse = { rowCount: 1, rows: [] } as unknown as Promise>; const dbConnection = getMockDBConnection({ sql: async () => mockResponse }); @@ -451,7 +451,7 @@ describe('FundingSourceRepository', () => { }); it('throws an error if rowCount is 0', async () => { - const mockResponse = ({ rowCount: 0, rows: [] } as unknown) as Promise>; + const mockResponse = { rowCount: 0, rows: [] } as unknown as Promise>; const dbConnection = getMockDBConnection({ sql: async () => mockResponse }); @@ -469,7 +469,7 @@ describe('FundingSourceRepository', () => { describe('putSurveyFundingSource', () => { it('updates survey funding source', async () => { - const mockResponse = ({ rowCount: 1, rows: [] } as unknown) as Promise>; + const mockResponse = { rowCount: 1, rows: [] } as unknown as Promise>; const dbConnection = getMockDBConnection({ sql: async () => mockResponse }); @@ -481,7 +481,7 @@ describe('FundingSourceRepository', () => { }); it('throws an error if rowCount is 0', async () => { - const mockResponse = ({ rowCount: 0, rows: [] } as unknown) as Promise>; + const mockResponse = { rowCount: 0, rows: [] } as unknown as Promise>; const dbConnection = getMockDBConnection({ sql: async () => mockResponse }); @@ -499,7 +499,7 @@ describe('FundingSourceRepository', () => { describe('deleteSurveyFundingSource', () => { it('deletes survey funding source', async () => { - const mockResponse = ({ rowCount: 1, rows: [] } as unknown) as Promise>; + const mockResponse = { rowCount: 1, rows: [] } as unknown as Promise>; const dbConnection = getMockDBConnection({ sql: async () => mockResponse }); @@ -511,7 +511,7 @@ describe('FundingSourceRepository', () => { }); it('throws an error if rowCount is 0', async () => { - const mockResponse = ({ rowCount: 0, rows: [] } as unknown) as Promise>; + const mockResponse = { rowCount: 0, rows: [] } as unknown as Promise>; const dbConnection = getMockDBConnection({ sql: async () => mockResponse }); diff --git a/api/src/repositories/history-publish-repository.test.ts b/api/src/repositories/history-publish-repository.test.ts index 5f33c44f63..a7a2306d62 100644 --- a/api/src/repositories/history-publish-repository.test.ts +++ b/api/src/repositories/history-publish-repository.test.ts @@ -18,7 +18,7 @@ describe('HistoryPublishRepository', () => { it('should insert a record and return an id', async () => { const mockConnection = getMockDBConnection({ sql: async () => { - return ({ rowCount: 1, rows: [{ project_metadata_publish_id: 1 }] } as any) as Promise>; + return { rowCount: 1, rows: [{ project_metadata_publish_id: 1 }] } as any as Promise>; } }); @@ -31,7 +31,7 @@ describe('HistoryPublishRepository', () => { it('should throw a `Failed insert` error', async () => { const mockConnection = getMockDBConnection({ sql: async () => { - return ({ rowCount: 0, rows: [] } as any) as Promise>; + return { rowCount: 0, rows: [] } as any as Promise>; } }); @@ -49,7 +49,7 @@ describe('HistoryPublishRepository', () => { it('should insert a record and return an id', async () => { const mockConnection = getMockDBConnection({ sql: async () => { - return ({ rowCount: 1, rows: [{ survey_attachment_publish_id: 1 }] } as any) as Promise>; + return { rowCount: 1, rows: [{ survey_attachment_publish_id: 1 }] } as any as Promise>; } }); @@ -65,7 +65,7 @@ describe('HistoryPublishRepository', () => { it('should throw a `Failed insert` error', async () => { const mockConnection = getMockDBConnection({ sql: async () => { - return ({ rowCount: 0, rows: [] } as any) as Promise>; + return { rowCount: 0, rows: [] } as any as Promise>; } }); @@ -83,7 +83,7 @@ describe('HistoryPublishRepository', () => { it('should insert a record and return an id', async () => { const mockConnection = getMockDBConnection({ sql: async () => { - return ({ rowCount: 1, rows: [{ survey_report_publish_id: 1 }] } as any) as Promise>; + return { rowCount: 1, rows: [{ survey_report_publish_id: 1 }] } as any as Promise>; } }); @@ -99,7 +99,7 @@ describe('HistoryPublishRepository', () => { it('should throw a `Failed insert` error', async () => { const mockConnection = getMockDBConnection({ sql: async () => { - return ({ rowCount: 0, rows: [] } as any) as Promise>; + return { rowCount: 0, rows: [] } as any as Promise>; } }); @@ -116,8 +116,7 @@ describe('HistoryPublishRepository', () => { describe('getSurveyMetadataPublishRecord', () => { it('should return a history publish record if one exists', async () => { const mockConnection = getMockDBConnection({ - sql: async () => - (({ rowCount: 1, rows: [{ survey_report_publish_id: 1 }] } as any) as Promise>) + sql: async () => ({ rowCount: 1, rows: [{ survey_report_publish_id: 1 }] } as any as Promise>) }); const repository = new HistoryPublishRepository(mockConnection); @@ -131,7 +130,7 @@ describe('HistoryPublishRepository', () => { it('should return undefined if no history publish record exists', async () => { const mockConnection = getMockDBConnection({ sql: async () => { - return ({ rowCount: 0, rows: [] } as any) as Promise>; + return { rowCount: 0, rows: [] } as any as Promise>; } }); @@ -148,7 +147,7 @@ describe('HistoryPublishRepository', () => { it('should return a history publish record if one exists', async () => { const mockConnection = getMockDBConnection({ sql: async () => - (({ rowCount: 1, rows: [{ survey_attachment_publish_id: 1 }] } as any) as Promise>) + ({ rowCount: 1, rows: [{ survey_attachment_publish_id: 1 }] } as any as Promise>) }); const repository = new HistoryPublishRepository(mockConnection); @@ -162,7 +161,7 @@ describe('HistoryPublishRepository', () => { it('should return undefined if no history publish record exists', async () => { const mockConnection = getMockDBConnection({ sql: async () => { - return ({ rowCount: 0, rows: [] } as any) as Promise>; + return { rowCount: 0, rows: [] } as any as Promise>; } }); @@ -178,8 +177,7 @@ describe('HistoryPublishRepository', () => { describe('getSurveyReportPublishRecord', () => { it('should return a history publish record if one exists', async () => { const mockConnection = getMockDBConnection({ - sql: async () => - (({ rowCount: 1, rows: [{ survey_report_publish_id: 1 }] } as any) as Promise>) + sql: async () => ({ rowCount: 1, rows: [{ survey_report_publish_id: 1 }] } as any as Promise>) }); const repository = new HistoryPublishRepository(mockConnection); @@ -193,7 +191,7 @@ describe('HistoryPublishRepository', () => { it('should return undefined if no history publish record exists', async () => { const mockConnection = getMockDBConnection({ sql: async () => { - return ({ rowCount: 0, rows: [] } as any) as Promise>; + return { rowCount: 0, rows: [] } as any as Promise>; } }); diff --git a/api/src/repositories/observation-repository.test.ts b/api/src/repositories/observation-repository.test.ts index b2aa489fa2..bead4f3e38 100644 --- a/api/src/repositories/observation-repository.test.ts +++ b/api/src/repositories/observation-repository.test.ts @@ -16,7 +16,7 @@ describe('ObservationRepository', () => { describe('deleteObservationsNotInArray', () => { it('should delete all records except for the ids in the provided array', async () => { - const mockQueryResponse = ({ rows: [], rowCount: 3 } as unknown) as QueryResult; + const mockQueryResponse = { rows: [], rowCount: 3 } as unknown as QueryResult; const mockDBConnection = getMockDBConnection({ sql: sinon.stub().resolves(mockQueryResponse) @@ -38,7 +38,7 @@ describe('ObservationRepository', () => { }); it('should delete all records when provided array of ids is empty', async () => { - const mockQueryResponse = ({ rows: [], rowCount: 3 } as unknown) as QueryResult; + const mockQueryResponse = { rows: [], rowCount: 3 } as unknown as QueryResult; const mockDBConnection = getMockDBConnection({ sql: sinon.stub().resolves(mockQueryResponse) @@ -64,7 +64,7 @@ describe('ObservationRepository', () => { describe('insertUpdateSurveyObservations', () => { it('should upsert records and return the affected rows', async () => { const mockRows = [{}, {}]; - const mockQueryResponse = ({ rows: mockRows, rowCount: 2 } as unknown) as QueryResult; + const mockQueryResponse = { rows: mockRows, rowCount: 2 } as unknown as QueryResult; const mockDBConnection = getMockDBConnection({ sql: sinon.stub().resolves(mockQueryResponse) @@ -105,7 +105,7 @@ describe('ObservationRepository', () => { describe('getSurveyObservationsWithSamplingDataWithAttributesData', () => { it('get all observations for a survey when some observation records exist', async () => { const mockRows = [{}, {}]; - const mockQueryResponse = ({ rows: mockRows, rowCount: 2 } as unknown) as QueryResult; + const mockQueryResponse = { rows: mockRows, rowCount: 2 } as unknown as QueryResult; const mockDBConnection = getMockDBConnection({ knex: sinon.stub().resolves(mockQueryResponse) @@ -122,7 +122,7 @@ describe('ObservationRepository', () => { it('get all observations for a survey when no observation records exist', async () => { const mockRows: any[] = []; - const mockQueryResponse = ({ rows: mockRows, rowCount: 2 } as unknown) as QueryResult; + const mockQueryResponse = { rows: mockRows, rowCount: 2 } as unknown as QueryResult; const mockDBConnection = getMockDBConnection({ knex: sinon.stub().resolves(mockQueryResponse) @@ -140,7 +140,7 @@ describe('ObservationRepository', () => { describe('getSurveyObservationCount', () => { it('gets the count of survey observations for the given survey', async () => { - const mockQueryResponse = ({ rows: [{ rowCount: 1 }] } as unknown) as QueryResult; + const mockQueryResponse = { rows: [{ rowCount: 1 }] } as unknown as QueryResult; const mockDBConnection = getMockDBConnection({ knex: sinon.stub().resolves(mockQueryResponse) @@ -156,7 +156,7 @@ describe('ObservationRepository', () => { describe('insertSurveyObservationSubmission', () => { it('inserts a survey observation submission record', async () => { - const mockQueryResponse = ({ rows: [1] } as unknown) as QueryResult; + const mockQueryResponse = { rows: [1] } as unknown as QueryResult; const mockDBConnection = getMockDBConnection({ sql: sinon.stub().resolves(mockQueryResponse) @@ -177,7 +177,7 @@ describe('ObservationRepository', () => { describe('getNextSubmissionId', () => { it('gets the next submission id', async () => { - const mockQueryResponse = ({ rows: [{ submission_id: 1 }], rowCount: 1 } as unknown) as QueryResult; + const mockQueryResponse = { rows: [{ submission_id: 1 }], rowCount: 1 } as unknown as QueryResult; const mockDBConnection = getMockDBConnection({ sql: sinon.stub().resolves(mockQueryResponse) @@ -193,7 +193,7 @@ describe('ObservationRepository', () => { describe('getObservationSubmissionById', () => { it('gets a submission by ID', async () => { - const mockQueryResponse = ({ rows: [{ submission_id: 5 }], rowCount: 1 } as unknown) as QueryResult; + const mockQueryResponse = { rows: [{ submission_id: 5 }], rowCount: 1 } as unknown as QueryResult; const mockDBConnection = getMockDBConnection({ knex: sinon.stub().resolves(mockQueryResponse) @@ -207,7 +207,7 @@ describe('ObservationRepository', () => { }); it('throws an error when no submission is found', async () => { - const mockQueryResponse = ({ rows: [], rowCount: 0 } as unknown) as QueryResult; + const mockQueryResponse = { rows: [], rowCount: 0 } as unknown as QueryResult; const mockDBConnection = getMockDBConnection({ knex: sinon.stub().resolves(mockQueryResponse) @@ -226,7 +226,7 @@ describe('ObservationRepository', () => { describe('getObservationsCountBySampleSiteIds', () => { it('gets the observation count by sample site ids', async () => { - const mockQueryResponse = ({ rows: [{ observation_count: 50 }], rowCount: 1 } as unknown) as QueryResult; + const mockQueryResponse = { rows: [{ observation_count: 50 }], rowCount: 1 } as unknown as QueryResult; const mockDBConnection = getMockDBConnection({ knex: sinon.stub().resolves(mockQueryResponse) diff --git a/api/src/repositories/permit-repository.test.ts b/api/src/repositories/permit-repository.test.ts index fe436e8b32..598b8ae48a 100644 --- a/api/src/repositories/permit-repository.test.ts +++ b/api/src/repositories/permit-repository.test.ts @@ -16,10 +16,10 @@ describe('PermitRepository', () => { }); it('should return an array of survey permits by survey id', async () => { - const mockQueryResponse = ({ + const mockQueryResponse = { rowCount: 1, rows: [{ permit_id: 2 }] - } as unknown) as QueryResult; + } as unknown as QueryResult; const mockDBConnection = getMockDBConnection({ sql: sinon.stub().resolves(mockQueryResponse) @@ -33,7 +33,7 @@ describe('PermitRepository', () => { }); it('should return empty rows', async () => { - const mockResponse = ({ rows: [], rowCount: 1 } as any) as Promise>; + const mockResponse = { rows: [], rowCount: 1 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new PermitRepository(dbConnection); @@ -50,10 +50,10 @@ describe('PermitRepository', () => { }); it('should return an array of survey permits by user', async () => { - const mockQueryResponse = ({ + const mockQueryResponse = { rowCount: 1, rows: [{ permit_id: 2 }] - } as unknown) as QueryResult; + } as unknown as QueryResult; const mockDBConnection = getMockDBConnection({ sql: sinon.stub().resolves(mockQueryResponse) @@ -67,7 +67,7 @@ describe('PermitRepository', () => { }); it('should throw an error if no permits were found', async () => { - const mockQueryResponse = ({} as unknown) as QueryResult; + const mockQueryResponse = {} as unknown as QueryResult; const mockDBConnection = getMockDBConnection({ sql: sinon.stub().resolves(mockQueryResponse) @@ -90,10 +90,10 @@ describe('PermitRepository', () => { }); it('should return an array containing all survey permits', async () => { - const mockQueryResponse = ({ + const mockQueryResponse = { rowCount: 1, rows: [{ permit_id: 2 }] - } as unknown) as QueryResult; + } as unknown as QueryResult; const mockDBConnection = getMockDBConnection({ sql: sinon.stub().resolves(mockQueryResponse) @@ -107,7 +107,7 @@ describe('PermitRepository', () => { }); it('should return empty rows', async () => { - const mockResponse = ({ rows: [], rowCount: 1 } as any) as Promise>; + const mockResponse = { rows: [], rowCount: 1 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new PermitRepository(dbConnection); @@ -124,10 +124,10 @@ describe('PermitRepository', () => { }); it('should return an array of survey permits by user', async () => { - const mockQueryResponse = ({ + const mockQueryResponse = { rowCount: 1, rows: [{ permit_id: 2 }] - } as unknown) as QueryResult; + } as unknown as QueryResult; const mockDBConnection = getMockDBConnection({ sql: sinon.stub().resolves(mockQueryResponse) @@ -141,10 +141,10 @@ describe('PermitRepository', () => { }); it('should throw an error if update failed', async () => { - const mockQueryResponse = ({ + const mockQueryResponse = { rowCount: 0, rows: [] - } as unknown) as QueryResult; + } as unknown as QueryResult; const mockDBConnection = getMockDBConnection({ sql: sinon.stub().resolves(mockQueryResponse) @@ -167,10 +167,10 @@ describe('PermitRepository', () => { }); it('should return an array of survey permits by user', async () => { - const mockQueryResponse = ({ + const mockQueryResponse = { rowCount: 1, rows: [{ permit_id: 2 }] - } as unknown) as QueryResult; + } as unknown as QueryResult; const mockDBConnection = getMockDBConnection({ sql: sinon.stub().resolves(mockQueryResponse) @@ -184,10 +184,10 @@ describe('PermitRepository', () => { }); it('should throw an error if create failed', async () => { - const mockQueryResponse = ({ + const mockQueryResponse = { rowCount: 0, rows: [] - } as unknown) as QueryResult; + } as unknown as QueryResult; const mockDBConnection = getMockDBConnection({ sql: sinon.stub().resolves(mockQueryResponse) @@ -210,10 +210,10 @@ describe('PermitRepository', () => { }); it('should return an array of survey permits by user', async () => { - const mockQueryResponse = ({ + const mockQueryResponse = { rowCount: 1, rows: [{ permit_id: 2 }] - } as unknown) as QueryResult; + } as unknown as QueryResult; const mockDBConnection = getMockDBConnection({ sql: sinon.stub().resolves(mockQueryResponse) @@ -227,10 +227,10 @@ describe('PermitRepository', () => { }); it('should throw an error if delete failed', async () => { - const mockQueryResponse = ({ + const mockQueryResponse = { rowCount: 0, rows: [] - } as unknown) as QueryResult; + } as unknown as QueryResult; const mockDBConnection = getMockDBConnection({ sql: sinon.stub().resolves(mockQueryResponse) diff --git a/api/src/repositories/project-participation-repository.test.ts b/api/src/repositories/project-participation-repository.test.ts index 4997d332ab..d07bb1ea2f 100644 --- a/api/src/repositories/project-participation-repository.test.ts +++ b/api/src/repositories/project-participation-repository.test.ts @@ -10,7 +10,7 @@ chai.use(sinonChai); describe('ProjectParticipationRepository', () => { describe('deleteProjectParticipationRecord', () => { it('should return result', async () => { - const mockResponse = ({ rows: [{ id: 1 }], rowCount: 1 } as any) as Promise>; + const mockResponse = { rows: [{ id: 1 }], rowCount: 1 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new ProjectParticipationRepository(dbConnection); @@ -21,7 +21,7 @@ describe('ProjectParticipationRepository', () => { }); it('should throw an error', async () => { - const mockResponse = (undefined as any) as Promise>; + const mockResponse = undefined as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new ProjectParticipationRepository(dbConnection); @@ -37,14 +37,14 @@ describe('ProjectParticipationRepository', () => { describe('getProjectParticipant', () => { it('should return result', async () => { - const mockResponse = ({ + const mockResponse = { rows: [ { system_user_id: 1 } ], rowCount: 1 - } as any) as Promise>; + } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new ProjectParticipationRepository(dbConnection); @@ -55,7 +55,7 @@ describe('ProjectParticipationRepository', () => { }); it('should return null', async () => { - const mockResponse = ({ rows: [], rowCount: 0 } as any) as Promise>; + const mockResponse = { rows: [], rowCount: 0 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new ProjectParticipationRepository(dbConnection); @@ -68,14 +68,14 @@ describe('ProjectParticipationRepository', () => { describe('getProjectParticipantByProjectIdAndUserGuid', () => { it('should return result', async () => { - const mockResponse = ({ + const mockResponse = { rows: [ { user_guid: '123-456-789' } ], rowCount: 1 - } as any) as Promise>; + } as any as Promise>; const dbConnection = getMockDBConnection({ knex: () => mockResponse }); const repository = new ProjectParticipationRepository(dbConnection); @@ -89,7 +89,7 @@ describe('ProjectParticipationRepository', () => { }); it('should return null', async () => { - const mockResponse = ({ rows: [], rowCount: 0 } as any) as Promise>; + const mockResponse = { rows: [], rowCount: 0 } as any as Promise>; const dbConnection = getMockDBConnection({ knex: () => mockResponse }); const repository = new ProjectParticipationRepository(dbConnection); @@ -105,14 +105,14 @@ describe('ProjectParticipationRepository', () => { describe('getProjectParticipantBySurveyIdAndUserGuid', () => { it('should return result', async () => { - const mockResponse = ({ + const mockResponse = { rows: [ { user_guid: '123-456-789' } ], rowCount: 1 - } as any) as Promise>; + } as any as Promise>; const dbConnection = getMockDBConnection({ knex: () => mockResponse }); const repository = new ProjectParticipationRepository(dbConnection); @@ -126,7 +126,7 @@ describe('ProjectParticipationRepository', () => { }); it('should return null', async () => { - const mockResponse = ({ rows: [], rowCount: 0 } as any) as Promise>; + const mockResponse = { rows: [], rowCount: 0 } as any as Promise>; const dbConnection = getMockDBConnection({ knex: () => mockResponse }); const repository = new ProjectParticipationRepository(dbConnection); @@ -142,7 +142,7 @@ describe('ProjectParticipationRepository', () => { describe('getProjectParticipants', () => { it('should return result', async () => { - const mockResponse = ({ rows: [{ id: 1 }], rowCount: 1 } as any) as Promise>; + const mockResponse = { rows: [{ id: 1 }], rowCount: 1 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new ProjectParticipationRepository(dbConnection); @@ -153,7 +153,7 @@ describe('ProjectParticipationRepository', () => { }); it('should throw an error when no rows returned', async () => { - const mockResponse = ({ rows: [], rowCount: 0 } as any) as Promise>; + const mockResponse = { rows: [], rowCount: 0 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new ProjectParticipationRepository(dbConnection); @@ -170,7 +170,7 @@ describe('ProjectParticipationRepository', () => { describe('postProjectParticipant', () => { describe('with role id', () => { it('should return result', async () => { - const mockResponse = ({ rows: [{ id: 1 }], rowCount: 1 } as any) as Promise>; + const mockResponse = { rows: [{ id: 1 }], rowCount: 1 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new ProjectParticipationRepository(dbConnection); @@ -181,7 +181,7 @@ describe('ProjectParticipationRepository', () => { }); it('should throw an error when no rows returned', async () => { - const mockResponse = ({ rows: [], rowCount: 0 } as any) as Promise>; + const mockResponse = { rows: [], rowCount: 0 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new ProjectParticipationRepository(dbConnection); @@ -197,7 +197,7 @@ describe('ProjectParticipationRepository', () => { describe('with role name', () => { it('should throw an error when no user found', async () => { - const mockResponse = ({ rows: [], rowCount: 0 } as any) as Promise>; + const mockResponse = { rows: [], rowCount: 0 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new ProjectParticipationRepository(dbConnection); @@ -211,7 +211,7 @@ describe('ProjectParticipationRepository', () => { }); it('should return result', async () => { - const mockResponse = ({ rows: [{ id: 1 }], rowCount: 1 } as any) as Promise>; + const mockResponse = { rows: [{ id: 1 }], rowCount: 1 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse, systemUserId: () => 1 }); const repository = new ProjectParticipationRepository(dbConnection); @@ -222,7 +222,7 @@ describe('ProjectParticipationRepository', () => { }); it('should throw an error', async () => { - const mockResponse = ({ rows: [], rowCount: 0 } as any) as Promise>; + const mockResponse = { rows: [], rowCount: 0 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse, systemUserId: () => 1 }); const repository = new ProjectParticipationRepository(dbConnection); diff --git a/api/src/repositories/project-repository.test.ts b/api/src/repositories/project-repository.test.ts index f9536c20e8..42d24324c7 100644 --- a/api/src/repositories/project-repository.test.ts +++ b/api/src/repositories/project-repository.test.ts @@ -19,7 +19,7 @@ chai.use(sinonChai); describe('ProjectRepository', () => { describe('getProjectList', () => { it('should return a list of projects', async () => { - const mockResponse = ({ rows: [{ id: 1 }], rowCount: 1 } as any) as Promise>; + const mockResponse = { rows: [{ id: 1 }], rowCount: 1 } as any as Promise>; const dbConnection = getMockDBConnection({ knex: () => mockResponse }); const repository = new ProjectRepository(dbConnection); @@ -40,7 +40,7 @@ describe('ProjectRepository', () => { }); it('should return a list of projects using different filter fields', async () => { - const mockResponse = ({ rows: [{ id: 1 }], rowCount: 1 } as any) as Promise>; + const mockResponse = { rows: [{ id: 1 }], rowCount: 1 } as any as Promise>; const dbConnection = getMockDBConnection({ knex: () => mockResponse }); const repository = new ProjectRepository(dbConnection); @@ -62,7 +62,7 @@ describe('ProjectRepository', () => { }); it('should return result with both data fields', async () => { - const mockResponse = ({ rows: [], rowCount: 0 } as any) as Promise>; + const mockResponse = { rows: [], rowCount: 0 } as any as Promise>; const dbConnection = getMockDBConnection({ knex: () => mockResponse }); const repository = new ProjectRepository(dbConnection); @@ -80,7 +80,7 @@ describe('ProjectRepository', () => { describe('getProjectCount', () => { it('should return a project count', async () => { - const mockResponse = ({ rows: [{ project_count: 69 }], rowCount: 1 } as any) as Promise>; + const mockResponse = { rows: [{ project_count: 69 }], rowCount: 1 } as any as Promise>; const dbConnection = getMockDBConnection({ knex: () => mockResponse }); const repository = new ProjectRepository(dbConnection); @@ -91,7 +91,7 @@ describe('ProjectRepository', () => { }); it('should throw an error', async () => { - const mockResponse = ({ rows: [], rowCount: 0 } as any) as Promise>; + const mockResponse = { rows: [], rowCount: 0 } as any as Promise>; const dbConnection = getMockDBConnection({ knex: () => mockResponse }); const repository = new ProjectRepository(dbConnection); @@ -107,7 +107,7 @@ describe('ProjectRepository', () => { describe('getProjectData', () => { it('should return result', async () => { - const mockResponse = ({ rows: [{ project_id: 1 }], rowCount: 1 } as any) as Promise>; + const mockResponse = { rows: [{ project_id: 1 }], rowCount: 1 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new ProjectRepository(dbConnection); @@ -118,7 +118,7 @@ describe('ProjectRepository', () => { }); it('should throw an error', async () => { - const mockResponse = ({ rows: [], rowCount: 0 } as any) as Promise>; + const mockResponse = { rows: [], rowCount: 0 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new ProjectRepository(dbConnection); @@ -134,7 +134,7 @@ describe('ProjectRepository', () => { describe('getObjectivesData', () => { it('should return result', async () => { - const mockResponse = ({ rows: [{ objectives: 'obj' }], rowCount: 1 } as any) as Promise>; + const mockResponse = { rows: [{ objectives: 'obj' }], rowCount: 1 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new ProjectRepository(dbConnection); @@ -145,7 +145,7 @@ describe('ProjectRepository', () => { }); it('should throw an error', async () => { - const mockResponse = ({ rows: [], rowCount: 0 } as any) as Promise>; + const mockResponse = { rows: [], rowCount: 0 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new ProjectRepository(dbConnection); @@ -161,10 +161,10 @@ describe('ProjectRepository', () => { describe('getIUCNClassificationData', () => { it('should return result', async () => { - const mockResponse = ({ + const mockResponse = { rows: [{ iucn_conservation_action_level_1_classification_id: 1 }], rowCount: 1 - } as any) as Promise>; + } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new ProjectRepository(dbConnection); @@ -177,7 +177,7 @@ describe('ProjectRepository', () => { }); it('should return empty rows', async () => { - const mockResponse = ({ rows: [], rowCount: 1 } as any) as Promise>; + const mockResponse = { rows: [], rowCount: 1 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new ProjectRepository(dbConnection); @@ -190,7 +190,7 @@ describe('ProjectRepository', () => { describe('getAttachmentsData', () => { it('should return result', async () => { - const mockResponse = ({ rows: [{ id: 1 }], rowCount: 1 } as any) as Promise>; + const mockResponse = { rows: [{ id: 1 }], rowCount: 1 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new ProjectRepository(dbConnection); @@ -201,7 +201,7 @@ describe('ProjectRepository', () => { }); it('should return empty rows', async () => { - const mockResponse = ({ rows: [], rowCount: 1 } as any) as Promise>; + const mockResponse = { rows: [], rowCount: 1 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new ProjectRepository(dbConnection); @@ -215,7 +215,7 @@ describe('ProjectRepository', () => { describe('getReportAttachmentsData', () => { it('should return result', async () => { - const mockResponse = ({ rows: [{ id: 1 }], rowCount: 1 } as any) as Promise>; + const mockResponse = { rows: [{ id: 1 }], rowCount: 1 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new ProjectRepository(dbConnection); @@ -226,7 +226,7 @@ describe('ProjectRepository', () => { }); it('should return null', async () => { - const mockResponse = ({ rows: [], rowCount: 0 } as any) as Promise>; + const mockResponse = { rows: [], rowCount: 0 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new ProjectRepository(dbConnection); @@ -239,12 +239,12 @@ describe('ProjectRepository', () => { describe('insertProject', () => { it('should return result', async () => { - const mockResponse = ({ rows: [{ id: 1 }], rowCount: 1 } as any) as Promise>; + const mockResponse = { rows: [{ id: 1 }], rowCount: 1 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new ProjectRepository(dbConnection); - const input = ({ + const input = { project: { project_programs: [1], name: 'name', @@ -253,7 +253,7 @@ describe('ProjectRepository', () => { comments: 'comments' }, objectives: { objectives: '' } - } as unknown) as PostProjectObject; + } as unknown as PostProjectObject; const response = await repository.insertProject(input); @@ -261,12 +261,12 @@ describe('ProjectRepository', () => { }); it('should return result when no geometry given', async () => { - const mockResponse = ({ rows: [{ id: 1 }], rowCount: 1 } as any) as Promise>; + const mockResponse = { rows: [{ id: 1 }], rowCount: 1 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new ProjectRepository(dbConnection); - const input = ({ + const input = { project: { project_programs: [1], name: 'name', @@ -275,7 +275,7 @@ describe('ProjectRepository', () => { comments: 'comments' }, objectives: { objectives: '' } - } as unknown) as PostProjectObject; + } as unknown as PostProjectObject; const response = await repository.insertProject(input); @@ -283,12 +283,12 @@ describe('ProjectRepository', () => { }); it('should throw an error', async () => { - const mockResponse = ({ rows: [], rowCount: 0 } as any) as Promise>; + const mockResponse = { rows: [], rowCount: 0 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new ProjectRepository(dbConnection); - const input = ({ + const input = { project: { project_programs: [1], name: 'name', @@ -297,7 +297,7 @@ describe('ProjectRepository', () => { comments: 'comments' }, objectives: { objectives: '' } - } as unknown) as PostProjectObject; + } as unknown as PostProjectObject; try { await repository.insertProject(input); @@ -310,7 +310,7 @@ describe('ProjectRepository', () => { describe('insertClassificationDetail', () => { it('should return result', async () => { - const mockResponse = ({ rows: [{ id: 1 }], rowCount: 1 } as any) as Promise>; + const mockResponse = { rows: [{ id: 1 }], rowCount: 1 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new ProjectRepository(dbConnection); @@ -321,7 +321,7 @@ describe('ProjectRepository', () => { }); it('should throw an error', async () => { - const mockResponse = ({ rows: [], rowCount: 0 } as any) as Promise>; + const mockResponse = { rows: [], rowCount: 0 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new ProjectRepository(dbConnection); @@ -337,7 +337,7 @@ describe('ProjectRepository', () => { describe('deleteIUCNData', () => { it('should return result', async () => { - const mockResponse = ({ rows: [{ id: 1 }], rowCount: 1 } as any) as Promise>; + const mockResponse = { rows: [{ id: 1 }], rowCount: 1 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new ProjectRepository(dbConnection); @@ -350,7 +350,7 @@ describe('ProjectRepository', () => { describe('deleteProject', () => { it('should return result', async () => { - const mockResponse = ({ rows: [{ id: 1 }], rowCount: 1 } as any) as Promise>; + const mockResponse = { rows: [{ id: 1 }], rowCount: 1 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new ProjectRepository(dbConnection); diff --git a/api/src/repositories/region-repository.test.ts b/api/src/repositories/region-repository.test.ts index 6246d8d98d..6131b775b0 100644 --- a/api/src/repositories/region-repository.test.ts +++ b/api/src/repositories/region-repository.test.ts @@ -18,7 +18,7 @@ describe('RegionRepository', () => { it('should return early when no regions passed in', async () => { const mockDBConnection = getMockDBConnection(); const repo = new RegionRepository(mockDBConnection); - const insertSQL = sinon.stub(mockDBConnection, 'sql').returns(({} as unknown) as any); + const insertSQL = sinon.stub(mockDBConnection, 'sql').returns({} as unknown as any); await repo.addRegionsToProject(1, []); expect(insertSQL).to.not.be.called; @@ -40,7 +40,7 @@ describe('RegionRepository', () => { it('should run without issue', async () => { const mockDBConnection = getMockDBConnection(); const repo = new RegionRepository(mockDBConnection); - const insertSQL = sinon.stub(mockDBConnection, 'sql').returns(({} as unknown) as any); + const insertSQL = sinon.stub(mockDBConnection, 'sql').returns({} as unknown as any); await repo.addRegionsToProject(1, [1]); expect(insertSQL).to.be.called; @@ -51,7 +51,7 @@ describe('RegionRepository', () => { it('should return early when no regions passed in', async () => { const mockDBConnection = getMockDBConnection(); const repo = new RegionRepository(mockDBConnection); - const insertSQL = sinon.stub(mockDBConnection, 'sql').returns(({} as unknown) as any); + const insertSQL = sinon.stub(mockDBConnection, 'sql').returns({} as unknown as any); await repo.addRegionsToSurvey(1, []); expect(insertSQL).to.not.be.called; @@ -73,7 +73,7 @@ describe('RegionRepository', () => { it('should run without issue', async () => { const mockDBConnection = getMockDBConnection(); const repo = new RegionRepository(mockDBConnection); - const insertSQL = sinon.stub(mockDBConnection, 'sql').returns(({} as unknown) as any); + const insertSQL = sinon.stub(mockDBConnection, 'sql').returns({} as unknown as any); await repo.addRegionsToSurvey(1, [1]); expect(insertSQL).to.be.called; @@ -84,7 +84,7 @@ describe('RegionRepository', () => { it('should run without issue', async () => { const mockDBConnection = getMockDBConnection(); const repo = new RegionRepository(mockDBConnection); - const sqlStub = sinon.stub(mockDBConnection, 'sql').returns(({} as unknown) as any); + const sqlStub = sinon.stub(mockDBConnection, 'sql').returns({} as unknown as any); await repo.deleteRegionsForProject(1); expect(sqlStub).to.be.called; @@ -108,7 +108,7 @@ describe('RegionRepository', () => { it('should run without issue', async () => { const mockDBConnection = getMockDBConnection(); const repo = new RegionRepository(mockDBConnection); - const sqlStub = sinon.stub(mockDBConnection, 'sql').returns(({} as unknown) as any); + const sqlStub = sinon.stub(mockDBConnection, 'sql').returns({} as unknown as any); await repo.deleteRegionsForSurvey(1); expect(sqlStub).to.be.called; @@ -132,7 +132,7 @@ describe('RegionRepository', () => { it('should return list of regions', async () => { const mockDBConnection = getMockDBConnection({ knex: async () => - (({ + ({ rowCount: 1, rows: [ { @@ -147,7 +147,7 @@ describe('RegionRepository', () => { geography: '{}' } ] - } as any) as Promise>) + } as any as Promise>) }); const repo = new RegionRepository(mockDBConnection); diff --git a/api/src/repositories/sample-blocks-repository.test.ts b/api/src/repositories/sample-blocks-repository.test.ts index b084fd397c..57c71648e8 100644 --- a/api/src/repositories/sample-blocks-repository.test.ts +++ b/api/src/repositories/sample-blocks-repository.test.ts @@ -17,7 +17,7 @@ describe('SampleBlockRepository', () => { describe('getSampleBlocksForSurveySampleSiteId', () => { it('should return non-empty rows', async () => { const mockRows: any[] = [{}, {}]; - const mockResponse = ({ rows: mockRows, rowCount: 2 } as any) as Promise>; + const mockResponse = { rows: mockRows, rowCount: 2 } as any as Promise>; const dbConnectionObj = getMockDBConnection({ sql: sinon.stub().resolves(mockResponse) }); const surveySampleSiteId = 1; @@ -30,7 +30,7 @@ describe('SampleBlockRepository', () => { it('should return empty rows', async () => { const mockRows: any[] = []; - const mockResponse = ({ rows: mockRows, rowCount: 0 } as any) as Promise>; + const mockResponse = { rows: mockRows, rowCount: 0 } as any as Promise>; const dbConnectionObj = getMockDBConnection({ sql: sinon.stub().resolves(mockResponse) }); const surveySampleSiteId = 1; @@ -45,7 +45,7 @@ describe('SampleBlockRepository', () => { describe('getSampleBlocksForSurveyBlockId', () => { it('should return non-empty rows', async () => { const mockRows: any[] = [{}, {}]; - const mockResponse = ({ rows: mockRows, rowCount: 2 } as any) as Promise>; + const mockResponse = { rows: mockRows, rowCount: 2 } as any as Promise>; const dbConnectionObj = getMockDBConnection({ sql: sinon.stub().resolves(mockResponse) }); const surveyBlockId = 1; @@ -58,7 +58,7 @@ describe('SampleBlockRepository', () => { it('should return empty rows', async () => { const mockRows: any[] = []; - const mockResponse = ({ rows: mockRows, rowCount: 0 } as any) as Promise>; + const mockResponse = { rows: mockRows, rowCount: 0 } as any as Promise>; const dbConnectionObj = getMockDBConnection({ sql: sinon.stub().resolves(mockResponse) }); const surveyBlockId = 1; @@ -73,7 +73,7 @@ describe('SampleBlockRepository', () => { describe('getSampleBlocksCountForSurveyBlockId', () => { it('should return a count of 2 records', async () => { const mockRows: any[] = [{}, {}]; - const mockResponse = ({ rows: mockRows, rowCount: 2 } as any) as Promise>; + const mockResponse = { rows: mockRows, rowCount: 2 } as any as Promise>; const dbConnectionObj = getMockDBConnection({ sql: sinon.stub().resolves(mockResponse) }); const surveyBlockId = 1; @@ -86,7 +86,7 @@ describe('SampleBlockRepository', () => { it('should return a count of 0 records', async () => { const mockRows: any[] = []; - const mockResponse = ({ rows: mockRows, rowCount: 0 } as any) as Promise>; + const mockResponse = { rows: mockRows, rowCount: 0 } as any as Promise>; const dbConnectionObj = getMockDBConnection({ sql: sinon.stub().resolves(mockResponse) }); const surveyBlockId = 1; @@ -101,7 +101,7 @@ describe('SampleBlockRepository', () => { describe('insertSampleBlock', () => { it('should insert a record and return a single row', async () => { const mockRow = {}; - const mockResponse = ({ rows: [mockRow], rowCount: 1 } as any) as Promise>; + const mockResponse = { rows: [mockRow], rowCount: 1 } as any as Promise>; const dbConnectionObj = getMockDBConnection({ sql: sinon.stub().resolves(mockResponse) }); const sampleBlock: UpdateSampleBlockRecord = { @@ -117,7 +117,7 @@ describe('SampleBlockRepository', () => { }); it('throws an error if rowCount is falsy', async () => { - const mockResponse = ({ rows: [], rowCount: 0 } as any) as Promise>; + const mockResponse = { rows: [], rowCount: 0 } as any as Promise>; const dbConnectionObj = getMockDBConnection({ sql: sinon.stub().resolves(mockResponse) }); const sampleBlock: UpdateSampleBlockRecord = { @@ -139,7 +139,7 @@ describe('SampleBlockRepository', () => { describe('deleteSampleBlockRecords', () => { it('should delete one or more records and return multiple rows', async () => { const mockRow = {}; - const mockResponse = ({ rows: [mockRow], rowCount: 1 } as any) as Promise>; + const mockResponse = { rows: [mockRow], rowCount: 1 } as any as Promise>; const dbConnectionObj = getMockDBConnection({ knex: sinon.stub().resolves(mockResponse) }); const surveySampleBlockIds = [1]; @@ -151,7 +151,7 @@ describe('SampleBlockRepository', () => { }); it('throws an error if rowCount is falsy', async () => { - const mockResponse = ({ rows: [], rowCount: 0 } as any) as Promise>; + const mockResponse = { rows: [], rowCount: 0 } as any as Promise>; const dbConnectionObj = getMockDBConnection({ knex: sinon.stub().resolves(mockResponse) }); const surveySampleBlockId = 1; @@ -169,7 +169,7 @@ describe('SampleBlockRepository', () => { describe('deleteSampleBlockRecordsByBlockIds', () => { it('should delete one or more record and return a single row', async () => { const mockRow = {}; - const mockResponse = ({ rows: [mockRow], rowCount: 1 } as any) as Promise>; + const mockResponse = { rows: [mockRow], rowCount: 1 } as any as Promise>; const dbConnectionObj = getMockDBConnection({ knex: sinon.stub().resolves(mockResponse) }); const surveyBlockIds = [1, 2]; @@ -181,7 +181,7 @@ describe('SampleBlockRepository', () => { }); it('throws an error if rowCount is falsy', async () => { - const mockResponse = ({ rows: [], rowCount: 0 } as any) as Promise>; + const mockResponse = { rows: [], rowCount: 0 } as any as Promise>; const dbConnectionObj = getMockDBConnection({ knex: sinon.stub().resolves(mockResponse) }); const surveyBlockIds = 1; diff --git a/api/src/repositories/sample-location-repository.test.ts b/api/src/repositories/sample-location-repository.test.ts index 48944688c2..e8dba965f7 100644 --- a/api/src/repositories/sample-location-repository.test.ts +++ b/api/src/repositories/sample-location-repository.test.ts @@ -17,7 +17,7 @@ describe('SampleLocationRepository', () => { describe('getSampleLocationsForSurveyId', () => { it('should return non-empty rows', async () => { const mockRows: any[] = [{}, {}]; - const mockResponse = ({ rows: mockRows, rowCount: 2 } as any) as Promise>; + const mockResponse = { rows: mockRows, rowCount: 2 } as any as Promise>; const dbConnectionObj = getMockDBConnection({ knex: () => mockResponse }); const surveySampleSiteId = 1; @@ -29,7 +29,7 @@ describe('SampleLocationRepository', () => { it('should return empty rows', async () => { const mockRows: any[] = []; - const mockResponse = ({ rows: mockRows, rowCount: 0 } as any) as Promise>; + const mockResponse = { rows: mockRows, rowCount: 0 } as any as Promise>; const dbConnectionObj = getMockDBConnection({ knex: () => mockResponse }); const surveySampleSiteId = 1; @@ -42,7 +42,7 @@ describe('SampleLocationRepository', () => { describe('getSampleLocationsCountBySurveyId', () => { it('should return the sample location count successfully', async () => { - const mockResponse = ({ rows: [{ sample_site_count: 69 }], rowCount: 1 } as any) as Promise>; + const mockResponse = { rows: [{ sample_site_count: 69 }], rowCount: 1 } as any as Promise>; const dbConnectionObj = getMockDBConnection({ sql: () => mockResponse }); const repo = new SampleLocationRepository(dbConnectionObj); @@ -52,7 +52,7 @@ describe('SampleLocationRepository', () => { }); it('should throw an exception if row count is 0', async () => { - const mockResponse = ({ rows: [], rowCount: 0 } as any) as Promise>; + const mockResponse = { rows: [], rowCount: 0 } as any as Promise>; const dbConnectionObj = getMockDBConnection({ sql: sinon.stub().resolves(mockResponse) }); const repo = new SampleLocationRepository(dbConnectionObj); @@ -69,7 +69,7 @@ describe('SampleLocationRepository', () => { describe('updateSampleSite', () => { it('should update the record and return a single row', async () => { const mockRow = {}; - const mockResponse = ({ rows: [mockRow], rowCount: 1 } as any) as Promise>; + const mockResponse = { rows: [mockRow], rowCount: 1 } as any as Promise>; const dbConnectionObj = getMockDBConnection({ sql: sinon.stub().resolves(mockResponse) }); const sampleLocation: UpdateSampleSiteRecord = { @@ -87,7 +87,7 @@ describe('SampleLocationRepository', () => { }); it('throws an error if rowCount is falsy', async () => { - const mockResponse = ({ rows: [], rowCount: 0 } as any) as Promise>; + const mockResponse = { rows: [], rowCount: 0 } as any as Promise>; const dbConnectionObj = getMockDBConnection({ sql: sinon.stub().resolves(mockResponse) }); const sampleLocation: UpdateSampleSiteRecord = { @@ -111,7 +111,7 @@ describe('SampleLocationRepository', () => { describe('insertSampleSite', () => { it('should insert a record and return a single row', async () => { const mockRow = {}; - const mockResponse = ({ rows: [mockRow], rowCount: 1 } as any) as Promise>; + const mockResponse = { rows: [mockRow], rowCount: 1 } as any as Promise>; const dbConnectionObj = getMockDBConnection({ sql: sinon.stub().resolves(mockResponse) }); const sampleLocation: InsertSampleSiteRecord = { @@ -127,7 +127,7 @@ describe('SampleLocationRepository', () => { }); it('throws an error if rowCount is falsy', async () => { - const mockResponse = ({ rows: [], rowCount: 0 } as any) as Promise>; + const mockResponse = { rows: [], rowCount: 0 } as any as Promise>; const dbConnectionObj = getMockDBConnection({ sql: sinon.stub().resolves(mockResponse) }); const sampleLocation: InsertSampleSiteRecord = { @@ -149,7 +149,7 @@ describe('SampleLocationRepository', () => { describe('deleteSampleSiteRecord', () => { it('should delete a record and return a single row', async () => { const mockRow = {}; - const mockResponse = ({ rows: [mockRow], rowCount: 1 } as any) as Promise>; + const mockResponse = { rows: [mockRow], rowCount: 1 } as any as Promise>; const dbConnectionObj = getMockDBConnection({ sql: sinon.stub().resolves(mockResponse) }); const mockSurveyId = 1; @@ -162,7 +162,7 @@ describe('SampleLocationRepository', () => { }); it('throws an error if rowCount is falsy', async () => { - const mockResponse = ({ rows: [], rowCount: 0 } as any) as Promise>; + const mockResponse = { rows: [], rowCount: 0 } as any as Promise>; const dbConnectionObj = getMockDBConnection({ sql: sinon.stub().resolves(mockResponse) }); const mockSurveyId = 1; diff --git a/api/src/repositories/sample-method-repository.test.ts b/api/src/repositories/sample-method-repository.test.ts index 258edc06e0..70513dfbc3 100644 --- a/api/src/repositories/sample-method-repository.test.ts +++ b/api/src/repositories/sample-method-repository.test.ts @@ -17,7 +17,7 @@ describe('SampleMethodRepository', () => { describe('getSampleMethodsForSurveySampleSiteId', () => { it('should return non-empty rows', async () => { const mockRows: any[] = [{}, {}]; - const mockResponse = ({ rows: mockRows, rowCount: 2 } as any) as Promise>; + const mockResponse = { rows: mockRows, rowCount: 2 } as any as Promise>; const dbConnectionObj = getMockDBConnection({ sql: sinon.stub().resolves(mockResponse) }); const mockSurveyId = 1; @@ -31,7 +31,7 @@ describe('SampleMethodRepository', () => { it('should return empty rows', async () => { const mockRows: any[] = []; - const mockResponse = ({ rows: mockRows, rowCount: 0 } as any) as Promise>; + const mockResponse = { rows: mockRows, rowCount: 0 } as any as Promise>; const dbConnectionObj = getMockDBConnection({ sql: sinon.stub().resolves(mockResponse) }); const mockSurveyId = 1; @@ -47,7 +47,7 @@ describe('SampleMethodRepository', () => { describe('updateSampleMethod', () => { it('should update the record and return a single row', async () => { const mockRow = {}; - const mockResponse = ({ rows: [mockRow], rowCount: 1 } as any) as Promise>; + const mockResponse = { rows: [mockRow], rowCount: 1 } as any as Promise>; const dbConnectionObj = getMockDBConnection({ sql: sinon.stub().resolves(mockResponse) }); const surveyId = 1; @@ -84,7 +84,7 @@ describe('SampleMethodRepository', () => { }); it('throws an error if rowCount is falsy', async () => { - const mockResponse = ({ rows: [], rowCount: 0 } as any) as Promise>; + const mockResponse = { rows: [], rowCount: 0 } as any as Promise>; const dbConnectionObj = getMockDBConnection({ sql: sinon.stub().resolves(mockResponse) }); const surveyId = 1; @@ -127,7 +127,7 @@ describe('SampleMethodRepository', () => { describe('insertSampleMethod', () => { it('should insert a record and return a single row', async () => { const mockRow = {}; - const mockResponse = ({ rows: [mockRow], rowCount: 1 } as any) as Promise>; + const mockResponse = { rows: [mockRow], rowCount: 1 } as any as Promise>; const dbConnectionObj = getMockDBConnection({ sql: sinon.stub().resolves(mockResponse) }); const sampleMethod: InsertSampleMethodRecord = { @@ -160,7 +160,7 @@ describe('SampleMethodRepository', () => { }); it('throws an error if rowCount is falsy', async () => { - const mockResponse = ({ rows: [], rowCount: 0 } as any) as Promise>; + const mockResponse = { rows: [], rowCount: 0 } as any as Promise>; const dbConnectionObj = getMockDBConnection({ sql: sinon.stub().resolves(mockResponse) }); const sampleMethod: InsertSampleMethodRecord = { @@ -199,7 +199,7 @@ describe('SampleMethodRepository', () => { describe('deleteSampleMethodRecord', () => { it('should delete a record and return a single row', async () => { const mockRow = {}; - const mockResponse = ({ rows: [mockRow], rowCount: 1 } as any) as Promise>; + const mockResponse = { rows: [mockRow], rowCount: 1 } as any as Promise>; const dbConnectionObj = getMockDBConnection({ sql: sinon.stub().resolves(mockResponse) }); const surveySampleMethodId = 1; @@ -212,7 +212,7 @@ describe('SampleMethodRepository', () => { }); it('throws an error if rowCount is falsy', async () => { - const mockResponse = ({ rows: [], rowCount: 0 } as any) as Promise>; + const mockResponse = { rows: [], rowCount: 0 } as any as Promise>; const dbConnectionObj = getMockDBConnection({ sql: sinon.stub().resolves(mockResponse) }); const mockSurveyId = 1001; diff --git a/api/src/repositories/sample-period-repository.test.ts b/api/src/repositories/sample-period-repository.test.ts index 73c308a3ae..c07a3b0e17 100644 --- a/api/src/repositories/sample-period-repository.test.ts +++ b/api/src/repositories/sample-period-repository.test.ts @@ -17,7 +17,7 @@ describe('SamplePeriodRepository', () => { describe('getSamplePeriodsForSurveyMethodId', () => { it('should return non-empty rows', async () => { const mockRows: any[] = [{}, {}]; - const mockResponse = ({ rows: mockRows, rowCount: 2 } as any) as Promise>; + const mockResponse = { rows: mockRows, rowCount: 2 } as any as Promise>; const dbConnectionObj = getMockDBConnection({ sql: sinon.stub().resolves(mockResponse) }); const mockSurveyId = 1; @@ -31,7 +31,7 @@ describe('SamplePeriodRepository', () => { it('should return empty rows', async () => { const mockRows: any[] = []; - const mockResponse = ({ rows: mockRows, rowCount: 0 } as any) as Promise>; + const mockResponse = { rows: mockRows, rowCount: 0 } as any as Promise>; const dbConnectionObj = getMockDBConnection({ sql: sinon.stub().resolves(mockResponse) }); const mockSurveyId = 1; @@ -47,7 +47,7 @@ describe('SamplePeriodRepository', () => { describe('updateSamplePeriod', () => { it('should update the record and return a single row', async () => { const mockRow = {}; - const mockResponse = ({ rows: [mockRow], rowCount: 1 } as any) as Promise>; + const mockResponse = { rows: [mockRow], rowCount: 1 } as any as Promise>; const dbConnectionObj = getMockDBConnection({ sql: sinon.stub().resolves(mockResponse) }); const mockSurveyId = 1; @@ -67,7 +67,7 @@ describe('SamplePeriodRepository', () => { }); it('throws an error if rowCount is falsy', async () => { - const mockResponse = ({ rows: [], rowCount: 0 } as any) as Promise>; + const mockResponse = { rows: [], rowCount: 0 } as any as Promise>; const dbConnectionObj = getMockDBConnection({ sql: sinon.stub().resolves(mockResponse) }); const mockSurveyId = 1; @@ -93,7 +93,7 @@ describe('SamplePeriodRepository', () => { describe('insertSamplePeriod', () => { it('should insert a record and return a single row', async () => { const mockRow = {}; - const mockResponse = ({ rows: [mockRow], rowCount: 1 } as any) as Promise>; + const mockResponse = { rows: [mockRow], rowCount: 1 } as any as Promise>; const dbConnectionObj = getMockDBConnection({ sql: sinon.stub().resolves(mockResponse) }); const samplePeriod: InsertSamplePeriodRecord = { @@ -111,7 +111,7 @@ describe('SamplePeriodRepository', () => { }); it('throws an error if rowCount is falsy', async () => { - const mockResponse = ({ rows: [], rowCount: 0 } as any) as Promise>; + const mockResponse = { rows: [], rowCount: 0 } as any as Promise>; const dbConnectionObj = getMockDBConnection({ sql: sinon.stub().resolves(mockResponse) }); const samplePeriod: InsertSamplePeriodRecord = { @@ -135,7 +135,7 @@ describe('SamplePeriodRepository', () => { describe('deleteSamplePeriodRecord', () => { it('should delete a record and return a single row', async () => { const mockRow = {}; - const mockResponse = ({ rows: [mockRow], rowCount: 1 } as any) as Promise>; + const mockResponse = { rows: [mockRow], rowCount: 1 } as any as Promise>; const dbConnectionObj = getMockDBConnection({ sql: sinon.stub().resolves(mockResponse) }); const mockSurveyId = 1; @@ -148,7 +148,7 @@ describe('SamplePeriodRepository', () => { }); it('throws an error if rowCount is falsy', async () => { - const mockResponse = ({ rows: [], rowCount: 0 } as any) as Promise>; + const mockResponse = { rows: [], rowCount: 0 } as any as Promise>; const dbConnectionObj = getMockDBConnection({ sql: sinon.stub().resolves(mockResponse) }); const mockSurveyId = 1; diff --git a/api/src/repositories/sample-stratums-repository.test.ts b/api/src/repositories/sample-stratums-repository.test.ts index fc45e0096d..b76593c4a5 100644 --- a/api/src/repositories/sample-stratums-repository.test.ts +++ b/api/src/repositories/sample-stratums-repository.test.ts @@ -17,7 +17,7 @@ describe('SampleStratumRepository', () => { describe('getSampleStratumsForSurveySampleSiteId', () => { it('should return non-empty rows', async () => { const mockRows: any[] = [{}, {}]; - const mockResponse = ({ rows: mockRows, rowCount: 2 } as any) as Promise>; + const mockResponse = { rows: mockRows, rowCount: 2 } as any as Promise>; const dbConnectionObj = getMockDBConnection({ sql: sinon.stub().resolves(mockResponse) }); const surveySampleSiteId = 1; @@ -30,7 +30,7 @@ describe('SampleStratumRepository', () => { it('should return empty rows', async () => { const mockRows: any[] = []; - const mockResponse = ({ rows: mockRows, rowCount: 0 } as any) as Promise>; + const mockResponse = { rows: mockRows, rowCount: 0 } as any as Promise>; const dbConnectionObj = getMockDBConnection({ sql: sinon.stub().resolves(mockResponse) }); const surveySampleSiteId = 1; @@ -45,7 +45,7 @@ describe('SampleStratumRepository', () => { describe('getSampleStratumsForSurveyStratumId', () => { it('should return non-empty rows', async () => { const mockRows: any[] = [{}, {}]; - const mockResponse = ({ rows: mockRows, rowCount: 2 } as any) as Promise>; + const mockResponse = { rows: mockRows, rowCount: 2 } as any as Promise>; const dbConnectionObj = getMockDBConnection({ sql: sinon.stub().resolves(mockResponse) }); const surveyStratumId = 1; @@ -58,7 +58,7 @@ describe('SampleStratumRepository', () => { it('should return empty rows', async () => { const mockRows: any[] = []; - const mockResponse = ({ rows: mockRows, rowCount: 0 } as any) as Promise>; + const mockResponse = { rows: mockRows, rowCount: 0 } as any as Promise>; const dbConnectionObj = getMockDBConnection({ sql: sinon.stub().resolves(mockResponse) }); const surveyStratumId = 1; @@ -73,7 +73,7 @@ describe('SampleStratumRepository', () => { describe('getSampleStratumsCountForSurveyStratumId', () => { it('should return a count of 2 records', async () => { const mockRows: any[] = [{}, {}]; - const mockResponse = ({ rows: mockRows, rowCount: 2 } as any) as Promise>; + const mockResponse = { rows: mockRows, rowCount: 2 } as any as Promise>; const dbConnectionObj = getMockDBConnection({ sql: sinon.stub().resolves(mockResponse) }); const surveyStratumId = 1; @@ -86,7 +86,7 @@ describe('SampleStratumRepository', () => { it('should return a count of 0 records', async () => { const mockRows: any[] = []; - const mockResponse = ({ rows: mockRows, rowCount: 0 } as any) as Promise>; + const mockResponse = { rows: mockRows, rowCount: 0 } as any as Promise>; const dbConnectionObj = getMockDBConnection({ sql: sinon.stub().resolves(mockResponse) }); const surveyStratumId = 1; @@ -101,7 +101,7 @@ describe('SampleStratumRepository', () => { describe('insertSampleStratum', () => { it('should insert a record and return a single row', async () => { const mockRow = {}; - const mockResponse = ({ rows: [mockRow], rowCount: 1 } as any) as Promise>; + const mockResponse = { rows: [mockRow], rowCount: 1 } as any as Promise>; const dbConnectionObj = getMockDBConnection({ sql: sinon.stub().resolves(mockResponse) }); const sampleStratum: UpdateSampleStratumRecord = { @@ -117,7 +117,7 @@ describe('SampleStratumRepository', () => { }); it('throws an error if rowCount is falsy', async () => { - const mockResponse = ({ rows: [], rowCount: 0 } as any) as Promise>; + const mockResponse = { rows: [], rowCount: 0 } as any as Promise>; const dbConnectionObj = getMockDBConnection({ sql: sinon.stub().resolves(mockResponse) }); const sampleStratum: UpdateSampleStratumRecord = { @@ -139,7 +139,7 @@ describe('SampleStratumRepository', () => { describe('deleteSampleStratumRecords', () => { it('should delete one or more records and return multiple rows', async () => { const mockRow = {}; - const mockResponse = ({ rows: [mockRow], rowCount: 1 } as any) as Promise>; + const mockResponse = { rows: [mockRow], rowCount: 1 } as any as Promise>; const dbConnectionObj = getMockDBConnection({ knex: sinon.stub().resolves(mockResponse) }); const surveySampleStratumIds = [1]; @@ -151,7 +151,7 @@ describe('SampleStratumRepository', () => { }); it('throws an error if rowCount is falsy', async () => { - const mockResponse = ({ rows: [], rowCount: 0 } as any) as Promise>; + const mockResponse = { rows: [], rowCount: 0 } as any as Promise>; const dbConnectionObj = getMockDBConnection({ knex: sinon.stub().resolves(mockResponse) }); const surveySampleStratumId = 1; @@ -169,7 +169,7 @@ describe('SampleStratumRepository', () => { describe('deleteSampleStratumRecordsByStratumIds', () => { it('should delete one or more record and return a single row', async () => { const mockRow = {}; - const mockResponse = ({ rows: [mockRow], rowCount: 1 } as any) as Promise>; + const mockResponse = { rows: [mockRow], rowCount: 1 } as any as Promise>; const dbConnectionObj = getMockDBConnection({ knex: sinon.stub().resolves(mockResponse) }); const surveyStratumIds = [1, 2]; @@ -181,7 +181,7 @@ describe('SampleStratumRepository', () => { }); it('throws an error if rowCount is falsy', async () => { - const mockResponse = ({ rows: [], rowCount: 0 } as any) as Promise>; + const mockResponse = { rows: [], rowCount: 0 } as any as Promise>; const dbConnectionObj = getMockDBConnection({ knex: sinon.stub().resolves(mockResponse) }); const surveyStratumIds = 1; diff --git a/api/src/repositories/site-selection-strategy-repository.test.ts b/api/src/repositories/site-selection-strategy-repository.test.ts index 36f9f7d527..3929fd3939 100644 --- a/api/src/repositories/site-selection-strategy-repository.test.ts +++ b/api/src/repositories/site-selection-strategy-repository.test.ts @@ -22,7 +22,7 @@ describe('SiteSelectionStrategyRepository', () => { describe('getSiteSelectionDataBySurveyId', () => { it('should return non-empty data', async () => { const mockStrategiesRows: { name: string }[] = [{ name: 'strategy1' }, { name: 'strategy2' }]; - const mockStrategiesResponse = ({ rows: mockStrategiesRows, rowCount: 2 } as any) as Promise>; + const mockStrategiesResponse = { rows: mockStrategiesRows, rowCount: 2 } as any as Promise>; const mockStratumsRows: SurveyStratumDetails[] = [ { @@ -44,7 +44,7 @@ describe('SiteSelectionStrategyRepository', () => { sample_stratum_count: 1 } ]; - const mockStratumsResponse = ({ rows: mockStratumsRows, rowCount: 2 } as any) as Promise>; + const mockStratumsResponse = { rows: mockStratumsRows, rowCount: 2 } as any as Promise>; const dbConnectionObj = getMockDBConnection({ knex: sinon.stub().onFirstCall().resolves(mockStrategiesResponse).onSecondCall().resolves(mockStratumsResponse) @@ -62,10 +62,10 @@ describe('SiteSelectionStrategyRepository', () => { it('should return empty data', async () => { const mockStrategiesRows: { name: string }[] = []; - const mockStrategiesResponse = ({ rows: mockStrategiesRows, rowCount: 0 } as any) as Promise>; + const mockStrategiesResponse = { rows: mockStrategiesRows, rowCount: 0 } as any as Promise>; const mockStratumsRows: SurveyStratumDetails[] = []; - const mockStratumsResponse = ({ rows: mockStratumsRows, rowCount: 0 } as any) as Promise>; + const mockStratumsResponse = { rows: mockStratumsRows, rowCount: 0 } as any as Promise>; const dbConnectionObj = getMockDBConnection({ knex: sinon.stub().onFirstCall().resolves(mockStrategiesResponse).onSecondCall().resolves(mockStratumsResponse) @@ -86,7 +86,7 @@ describe('SiteSelectionStrategyRepository', () => { it('should return non-zero rowCount', async () => { const mockRows: any[] = [{}]; const rowCount = 1; - const mockResponse = ({ rows: mockRows, rowCount: rowCount } as any) as Promise>; + const mockResponse = { rows: mockRows, rowCount: rowCount } as any as Promise>; const dbConnectionObj = getMockDBConnection({ sql: sinon.stub().resolves(mockResponse) }); @@ -103,7 +103,7 @@ describe('SiteSelectionStrategyRepository', () => { it('should return zero rowCount', async () => { const mockRows: any[] = []; const rowCount = 0; - const mockResponse = ({ rows: mockRows, rowCount: rowCount } as any) as Promise>; + const mockResponse = { rows: mockRows, rowCount: rowCount } as any as Promise>; const dbConnectionObj = getMockDBConnection({ sql: sinon.stub().resolves(mockResponse) }); @@ -121,7 +121,7 @@ describe('SiteSelectionStrategyRepository', () => { describe('insertSurveySiteSelectionStrategies', () => { it('should insert a record and return a single row', async () => { const mockRows: any[] = [{}, {}]; - const mockResponse = ({ rows: mockRows, rowCount: 2 } as any) as Promise>; + const mockResponse = { rows: mockRows, rowCount: 2 } as any as Promise>; const dbConnectionObj = getMockDBConnection({ sql: sinon.stub().resolves(mockResponse) }); @@ -138,7 +138,7 @@ describe('SiteSelectionStrategyRepository', () => { it('throws an error if rowCount does not equal strategies length', async () => { const mockRows: any[] = [{}]; - const mockResponse = ({ rows: mockRows, rowCount: 1 } as any) as Promise>; + const mockResponse = { rows: mockRows, rowCount: 1 } as any as Promise>; const dbConnectionObj = getMockDBConnection({ sql: sinon.stub().resolves(mockResponse) }); @@ -162,7 +162,7 @@ describe('SiteSelectionStrategyRepository', () => { it('should delete records and return non-zero rowCount', async () => { const mockRows: any[] = []; const rowCount = 3; - const mockResponse = ({ rows: mockRows, rowCount: rowCount } as any) as Promise>; + const mockResponse = { rows: mockRows, rowCount: rowCount } as any as Promise>; const dbConnectionObj = getMockDBConnection({ knex: sinon.stub().resolves(mockResponse) }); const repo = new SiteSelectionStrategyRepository(dbConnectionObj); @@ -178,7 +178,7 @@ describe('SiteSelectionStrategyRepository', () => { it('should delete records and return zero rowCount', async () => { const mockRows: any[] = []; const rowCount = 0; - const mockResponse = ({ rows: mockRows, rowCount: rowCount } as any) as Promise>; + const mockResponse = { rows: mockRows, rowCount: rowCount } as any as Promise>; const dbConnectionObj = getMockDBConnection({ knex: sinon.stub().resolves(mockResponse) }); const repo = new SiteSelectionStrategyRepository(dbConnectionObj); @@ -195,7 +195,7 @@ describe('SiteSelectionStrategyRepository', () => { describe('insertSurveyStratums', () => { it('should insert records and return rows', async () => { const mockRows: any[] = [{}, {}]; - const mockResponse = ({ rows: mockRows, rowCount: 2 } as any) as Promise>; + const mockResponse = { rows: mockRows, rowCount: 2 } as any as Promise>; const dbConnectionObj = getMockDBConnection({ knex: sinon.stub().resolves(mockResponse) }); @@ -216,7 +216,7 @@ describe('SiteSelectionStrategyRepository', () => { it('throws an error if rowCount does not equal stratums length', async () => { const mockRows: any[] = [{}]; const rowCount = 1; - const mockResponse = ({ rows: mockRows, rowCount: rowCount } as any) as Promise>; + const mockResponse = { rows: mockRows, rowCount: rowCount } as any as Promise>; const dbConnectionObj = getMockDBConnection({ knex: sinon.stub().resolves(mockResponse) }); @@ -261,8 +261,8 @@ describe('SiteSelectionStrategyRepository', () => { update_date: '2023-10-23' } ]; - const mockResponse1 = ({ rows: mockRows1, rowCount: 1 } as any) as Promise>; - const mockResponse2 = ({ rows: mockRows2, rowCount: 1 } as any) as Promise>; + const mockResponse1 = { rows: mockRows1, rowCount: 1 } as any as Promise>; + const mockResponse2 = { rows: mockRows2, rowCount: 1 } as any as Promise>; const dbConnectionObj = getMockDBConnection({ knex: sinon.stub().onFirstCall().resolves(mockResponse1).onSecondCall().resolves(mockResponse2) @@ -293,8 +293,8 @@ describe('SiteSelectionStrategyRepository', () => { update_date: '2023-10-23' } ]; - const mockResponse1 = ({ rows: mockRows1, rowCount: 1 } as any) as Promise>; - const mockResponse2 = ({ rows: [], rowCount: 0 } as any) as Promise>; + const mockResponse1 = { rows: mockRows1, rowCount: 1 } as any as Promise>; + const mockResponse2 = { rows: [], rowCount: 0 } as any as Promise>; const dbConnectionObj = getMockDBConnection({ knex: sinon.stub().onFirstCall().resolves(mockResponse1).onSecondCall().resolves(mockResponse2) diff --git a/api/src/repositories/subcount-repository.test.ts b/api/src/repositories/subcount-repository.test.ts index 2fe38d9f6e..e91ffae9f9 100644 --- a/api/src/repositories/subcount-repository.test.ts +++ b/api/src/repositories/subcount-repository.test.ts @@ -34,10 +34,10 @@ describe('SubCountRepository', () => { revision_count: 1 }; - const mockResponse = ({ + const mockResponse = { rows: [mockSubcount], rowCount: 1 - } as any) as Promise>; + } as any as Promise>; const dbConnection = getMockDBConnection({ knex: () => mockResponse @@ -50,20 +50,20 @@ describe('SubCountRepository', () => { }); it('should catch query errors and throw an ApiExecuteSQLError', async () => { - const mockResponse = ({ + const mockResponse = { rows: [], rowCount: 0 - } as any) as Promise>; + } as any as Promise>; const dbConnection = getMockDBConnection({ knex: () => mockResponse }); const repo = new SubCountRepository(dbConnection); try { - await repo.insertObservationSubCount((null as unknown) as InsertObservationSubCount); + await repo.insertObservationSubCount(null as unknown as InsertObservationSubCount); expect.fail(); } catch (error) { - expect(((error as any) as ApiExecuteSQLError).message).to.be.eq('Failed to insert observation subcount'); + expect((error as any as ApiExecuteSQLError).message).to.be.eq('Failed to insert observation subcount'); } }); }); @@ -86,10 +86,10 @@ describe('SubCountRepository', () => { critterbase_event_id: 'aaaa' }; - const mockResponse = ({ + const mockResponse = { rows: [mockSubcountEvent], rowCount: 1 - } as any) as Promise>; + } as any as Promise>; const dbConnection = getMockDBConnection({ knex: () => mockResponse @@ -102,20 +102,20 @@ describe('SubCountRepository', () => { }); it('should catch query errors and throw an ApiExecuteSQLError', async () => { - const mockResponse = ({ + const mockResponse = { rows: [], rowCount: 0 - } as any) as Promise>; + } as any as Promise>; const dbConnection = getMockDBConnection({ knex: () => mockResponse }); const repo = new SubCountRepository(dbConnection); try { - await repo.insertSubCountEvent((null as unknown) as InsertSubCountEvent); + await repo.insertSubCountEvent(null as unknown as InsertSubCountEvent); expect.fail(); } catch (error) { - expect(((error as any) as ApiExecuteSQLError).message).to.be.eq('Failed to insert subcount event'); + expect((error as any as ApiExecuteSQLError).message).to.be.eq('Failed to insert subcount event'); } }); }); @@ -133,10 +133,10 @@ describe('SubCountRepository', () => { revision_count: 1 }; - const mockResponse = ({ + const mockResponse = { rows: [mockSubcountCritterRecord], rowCount: 1 - } as any) as Promise>; + } as any as Promise>; const dbConnection = getMockDBConnection({ knex: () => mockResponse @@ -149,20 +149,20 @@ describe('SubCountRepository', () => { }); it('should catch query errors and throw an ApiExecuteSQLError', async () => { - const mockResponse = ({ + const mockResponse = { rows: [], rowCount: 0 - } as any) as Promise>; + } as any as Promise>; const dbConnection = getMockDBConnection({ knex: () => mockResponse }); const repo = new SubCountRepository(dbConnection); try { - await repo.insertSubCountCritter((null as unknown) as SubCountCritterRecord); + await repo.insertSubCountCritter(null as unknown as SubCountCritterRecord); expect.fail(); } catch (error) { - expect(((error as any) as ApiExecuteSQLError).message).to.be.eq('Failed to insert subcount critter'); + expect((error as any as ApiExecuteSQLError).message).to.be.eq('Failed to insert subcount critter'); } }); }); diff --git a/api/src/repositories/survey-block-repository.test.ts b/api/src/repositories/survey-block-repository.test.ts index 59838972d4..e8920174f9 100644 --- a/api/src/repositories/survey-block-repository.test.ts +++ b/api/src/repositories/survey-block-repository.test.ts @@ -16,7 +16,7 @@ describe('SurveyBlockRepository', () => { describe('getSurveyBlocksForSurveyId', () => { it('should succeed with valid data', async () => { - const mockResponse = ({ + const mockResponse = { rows: [ { survey_block_id: 1, @@ -31,7 +31,7 @@ describe('SurveyBlockRepository', () => { } ], rowCount: 1 - } as any) as Promise>; + } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); @@ -45,10 +45,10 @@ describe('SurveyBlockRepository', () => { }); it('should succeed with empty data', async () => { - const mockResponse = ({ + const mockResponse = { rows: [], rowCount: 0 - } as any) as Promise>; + } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); @@ -61,7 +61,7 @@ describe('SurveyBlockRepository', () => { describe('updateSurveyBlock', () => { it('should succeed with valid data', async () => { - const mockResponse = ({ + const mockResponse = { rows: [ { survey_block_id: 1, @@ -76,7 +76,7 @@ describe('SurveyBlockRepository', () => { } ], rowCount: 1 - } as any) as Promise>; + } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); @@ -89,10 +89,10 @@ describe('SurveyBlockRepository', () => { }); it('should failed with erroneous data', async () => { - const mockResponse = ({ + const mockResponse = { rows: [], rowCount: 0 - } as any) as Promise>; + } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); @@ -103,14 +103,14 @@ describe('SurveyBlockRepository', () => { await repo.updateSurveyBlock(block); expect.fail(); } catch (error) { - expect(((error as any) as ApiExecuteSQLError).message).to.be.eq('Failed to update survey block'); + 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 = ({ + const mockResponse = { rows: [ { survey_block_id: 1, @@ -125,7 +125,7 @@ describe('SurveyBlockRepository', () => { } ], rowCount: 1 - } as any) as Promise>; + } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); @@ -139,32 +139,32 @@ describe('SurveyBlockRepository', () => { }); it('should fail with erroneous data', async () => { - const mockResponse = ({ + const mockResponse = { rows: [], rowCount: 0 - } as any) as Promise>; + } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repo = new SurveyBlockRepository(dbConnection); try { - const block = ({ + const block = { survey_block_id: null, survey_id: 1, name: null, description: null - } as any) as PostSurveyBlock; + } 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'); + 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 = ({ + const mockResponse = { rows: [ { survey_block_id: 1, @@ -179,7 +179,7 @@ describe('SurveyBlockRepository', () => { } ], rowCount: 1 - } as any) as Promise>; + } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); @@ -190,10 +190,10 @@ describe('SurveyBlockRepository', () => { }); it('should failed with erroneous data', async () => { - const mockResponse = ({ + const mockResponse = { rows: [], rowCount: 0 - } as any) as Promise>; + } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); @@ -203,7 +203,7 @@ describe('SurveyBlockRepository', () => { await repo.deleteSurveyBlockRecord(1); expect.fail(); } catch (error) { - expect(((error as any) as ApiExecuteSQLError).message).to.be.eq('Failed to delete survey block record'); + expect((error as any as ApiExecuteSQLError).message).to.be.eq('Failed to delete survey block record'); } }); }); diff --git a/api/src/repositories/survey-critter-repository.test.ts b/api/src/repositories/survey-critter-repository.test.ts index 7714dbebf1..3d8a40cc8d 100644 --- a/api/src/repositories/survey-critter-repository.test.ts +++ b/api/src/repositories/survey-critter-repository.test.ts @@ -15,7 +15,7 @@ describe('SurveyRepository', () => { describe('getCrittersInSurvey', () => { it('should return result', async () => { const mockSurveyCritter = { critter_id: 1, survey_id: 1, critterbase_critter_id: 1 }; - const mockResponse = ({ rows: [mockSurveyCritter], rowCount: 1 } as any) as Promise>; + const mockResponse = { rows: [mockSurveyCritter], rowCount: 1 } as any as Promise>; const dbConnection = getMockDBConnection({ knex: () => mockResponse }); const repository = new SurveyCritterRepository(dbConnection); @@ -28,7 +28,7 @@ describe('SurveyRepository', () => { describe('addCritterToSurvey', () => { it('should return result', async () => { - const mockResponse = ({ rows: [{ submissionId: 1 }], rowCount: 1 } as any) as Promise>; + const mockResponse = { rows: [{ submissionId: 1 }], rowCount: 1 } as any as Promise>; const dbConnection = getMockDBConnection({ knex: () => mockResponse }); const repository = new SurveyCritterRepository(dbConnection); @@ -41,7 +41,7 @@ describe('SurveyRepository', () => { describe('removeCritterFromSurvey', () => { it('should return result', async () => { - const mockResponse = ({ rows: [{ submissionId: 1 }], rowCount: 1 } as any) as Promise>; + const mockResponse = { rows: [{ submissionId: 1 }], rowCount: 1 } as any as Promise>; const dbConnection = getMockDBConnection({ knex: () => mockResponse }); const repository = new SurveyCritterRepository(dbConnection); @@ -54,7 +54,7 @@ describe('SurveyRepository', () => { describe('upsertDeployment', () => { it('should update existing row', async () => { - const mockResponse = ({ rows: [{ submissionId: 1 }], rowCount: 1 } as any) as Promise>; + const mockResponse = { rows: [{ submissionId: 1 }], rowCount: 1 } as any as Promise>; const dbConnection = getMockDBConnection({ knex: () => mockResponse }); const repository = new SurveyCritterRepository(dbConnection); @@ -67,7 +67,7 @@ describe('SurveyRepository', () => { describe('updateCritter', () => { it('should update existing row', async () => { - const mockResponse = ({ rows: [], rowCount: 0 } as any) as Promise>; + const mockResponse = { rows: [], rowCount: 0 } as any as Promise>; const dbConnection = getMockDBConnection({ knex: () => mockResponse }); const repository = new SurveyCritterRepository(dbConnection); const response = await repository.updateCritter(1, 'asdf'); @@ -77,7 +77,7 @@ describe('SurveyRepository', () => { describe('deleteDeployment', () => { it('should delete existing row', async () => { - const mockResponse = ({ rows: [], rowCount: 0 } as any) as Promise>; + const mockResponse = { rows: [], rowCount: 0 } as any as Promise>; const dbConnection = getMockDBConnection({ knex: () => mockResponse }); const repository = new SurveyCritterRepository(dbConnection); diff --git a/api/src/repositories/survey-location-repository.test.ts b/api/src/repositories/survey-location-repository.test.ts index ff04763a3f..89f2416d14 100644 --- a/api/src/repositories/survey-location-repository.test.ts +++ b/api/src/repositories/survey-location-repository.test.ts @@ -15,7 +15,7 @@ describe('SurveyLocationRepository', () => { describe('insertSurveyLocation', () => { it('should insert a survey location', async () => { - const mockResponse = ({ rows: [], rowCount: 0 } as any) as Promise>; + const mockResponse = { rows: [], rowCount: 0 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: sinon.stub().resolves(mockResponse) }); const repository = new SurveyLocationRepository(dbConnection); @@ -46,7 +46,7 @@ describe('SurveyLocationRepository', () => { describe('updateSurveyLocation', () => { it('should update a survey location', async () => { - const mockResponse = ({ rows: [], rowCount: 0 } as any) as Promise>; + const mockResponse = { rows: [], rowCount: 0 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: sinon.stub().resolves(mockResponse) }); const repository = new SurveyLocationRepository(dbConnection); @@ -78,7 +78,7 @@ describe('SurveyLocationRepository', () => { describe('getSurveyLocationsData', () => { it('should return a list of survey locations', async () => { const mockSurveyLocation = { survey_location_id: 1, name: 'Test Location', description: 'Test Description' }; - const mockResponse = ({ rows: [mockSurveyLocation], rowCount: 1 } as any) as Promise>; + const mockResponse = { rows: [mockSurveyLocation], rowCount: 1 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: sinon.stub().resolves(mockResponse) }); const repository = new SurveyLocationRepository(dbConnection); @@ -93,7 +93,7 @@ describe('SurveyLocationRepository', () => { describe('deleteSurveyLocation', () => { it('should delete a survey location', async () => { const mockSurveyLocation = { survey_location_id: 1, name: 'Test Location', description: 'Test Description' }; - const mockResponse = ({ rows: [mockSurveyLocation], rowCount: 1 } as any) as Promise>; + const mockResponse = { rows: [mockSurveyLocation], rowCount: 1 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: sinon.stub().resolves(mockResponse) }); const repository = new SurveyLocationRepository(dbConnection); @@ -104,7 +104,7 @@ describe('SurveyLocationRepository', () => { }); it('should throw an error when unable to delete the survey location', async () => { - const mockResponse = ({ rows: [], rowCount: 0 } as any) as Promise>; + const mockResponse = { rows: [], rowCount: 0 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: sinon.stub().resolves(mockResponse) }); const repository = new SurveyLocationRepository(dbConnection); diff --git a/api/src/repositories/survey-participation-repository.test.ts b/api/src/repositories/survey-participation-repository.test.ts index ca1d085b78..b14acc7883 100644 --- a/api/src/repositories/survey-participation-repository.test.ts +++ b/api/src/repositories/survey-participation-repository.test.ts @@ -10,7 +10,7 @@ chai.use(sinonChai); describe('SurveyParticipationRepository', () => { describe('getSurveyJobs', () => { it('should return result', async () => { - const mockResponse = ({ rows: [{ id: 1 }], rowCount: 1 } as any) as Promise>; + const mockResponse = { rows: [{ id: 1 }], rowCount: 1 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new SurveyParticipationRepository(dbConnection); @@ -21,7 +21,7 @@ describe('SurveyParticipationRepository', () => { }); it('should throw an error when no rows returned', async () => { - const mockResponse = ({ rows: [], rowCount: 0 } as any) as Promise>; + const mockResponse = { rows: [], rowCount: 0 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new SurveyParticipationRepository(dbConnection); @@ -37,14 +37,14 @@ describe('SurveyParticipationRepository', () => { describe('getSurveyParticipant', () => { it('should return result', async () => { - const mockResponse = ({ + const mockResponse = { rows: [ { system_user_id: 1 } ], rowCount: 1 - } as any) as Promise>; + } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new SurveyParticipationRepository(dbConnection); @@ -55,7 +55,7 @@ describe('SurveyParticipationRepository', () => { }); it('should return null', async () => { - const mockResponse = ({ rows: [], rowCount: 0 } as any) as Promise>; + const mockResponse = { rows: [], rowCount: 0 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new SurveyParticipationRepository(dbConnection); @@ -68,7 +68,7 @@ describe('SurveyParticipationRepository', () => { describe('getSurveyParticipants', () => { it('should return result', async () => { - const mockResponse = ({ rows: [{ id: 1 }], rowCount: 1 } as any) as Promise>; + const mockResponse = { rows: [{ id: 1 }], rowCount: 1 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new SurveyParticipationRepository(dbConnection); @@ -79,7 +79,7 @@ describe('SurveyParticipationRepository', () => { }); it('should return no rows', async () => { - const mockResponse = ({ rows: [], rowCount: 0 } as any) as Promise>; + const mockResponse = { rows: [], rowCount: 0 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new SurveyParticipationRepository(dbConnection); @@ -93,7 +93,7 @@ describe('SurveyParticipationRepository', () => { describe('insertSurveyParticipant', () => { describe('with job name', () => { it('should return result', async () => { - const mockResponse = ({ rows: [{ id: 1 }], rowCount: 1 } as any) as Promise>; + const mockResponse = { rows: [{ id: 1 }], rowCount: 1 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new SurveyParticipationRepository(dbConnection); @@ -104,7 +104,7 @@ describe('SurveyParticipationRepository', () => { }); it('should throw an error', async () => { - const mockResponse = ({ rows: [], rowCount: 0 } as any) as Promise>; + const mockResponse = { rows: [], rowCount: 0 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new SurveyParticipationRepository(dbConnection); @@ -122,7 +122,7 @@ describe('SurveyParticipationRepository', () => { describe('updateSurveyParticipantJob', () => { describe('with job name', () => { it('should return result', async () => { - const mockResponse = ({ rows: [{ id: 1 }], rowCount: 1 } as any) as Promise>; + const mockResponse = { rows: [{ id: 1 }], rowCount: 1 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new SurveyParticipationRepository(dbConnection); @@ -133,7 +133,7 @@ describe('SurveyParticipationRepository', () => { }); it('should throw an error', async () => { - const mockResponse = ({ rows: [], rowCount: 0 } as any) as Promise>; + const mockResponse = { rows: [], rowCount: 0 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new SurveyParticipationRepository(dbConnection); @@ -150,7 +150,7 @@ describe('SurveyParticipationRepository', () => { describe('deleteSurveyParticipationRecord', () => { it('should return result', async () => { - const mockResponse = ({ rows: [{ id: 1 }], rowCount: 1 } as any) as Promise>; + const mockResponse = { rows: [{ id: 1 }], rowCount: 1 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new SurveyParticipationRepository(dbConnection); @@ -161,7 +161,7 @@ describe('SurveyParticipationRepository', () => { }); it('should throw an error', async () => { - const mockResponse = (undefined as any) as Promise>; + const mockResponse = undefined as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new SurveyParticipationRepository(dbConnection); diff --git a/api/src/repositories/survey-repository.test.ts b/api/src/repositories/survey-repository.test.ts index 7926f32112..632478a0d5 100644 --- a/api/src/repositories/survey-repository.test.ts +++ b/api/src/repositories/survey-repository.test.ts @@ -19,7 +19,7 @@ describe('SurveyRepository', () => { }); describe('deleteSurvey', () => { it('should return result', async () => { - const mockResponse = ({ rows: [{ id: 1 }], rowCount: 1 } as any) as Promise>; + const mockResponse = { rows: [{ id: 1 }], rowCount: 1 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new SurveyRepository(dbConnection); @@ -32,7 +32,7 @@ describe('SurveyRepository', () => { describe('getSurveyCountByProjectId', () => { it('should return the survey count successfully', async () => { - const mockResponse = ({ rows: [{ survey_count: 69 }], rowCount: 1 } as any) as Promise>; + const mockResponse = { rows: [{ survey_count: 69 }], rowCount: 1 } as any as Promise>; const dbConnectionObj = getMockDBConnection({ sql: () => mockResponse }); const repo = new SurveyRepository(dbConnectionObj); @@ -42,7 +42,7 @@ describe('SurveyRepository', () => { }); it('should throw an exception if row count is 0', async () => { - const mockResponse = ({ rows: [], rowCount: 0 } as any) as Promise>; + const mockResponse = { rows: [], rowCount: 0 } as any as Promise>; const dbConnectionObj = getMockDBConnection({ sql: sinon.stub().resolves(mockResponse) }); const repo = new SurveyRepository(dbConnectionObj); @@ -58,7 +58,7 @@ describe('SurveyRepository', () => { describe('getSurveyIdsByProjectId', () => { it('should return result', async () => { - const mockResponse = ({ rows: [{ id: 1 }], rowCount: 1 } as any) as Promise>; + const mockResponse = { rows: [{ id: 1 }], rowCount: 1 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new SurveyRepository(dbConnection); @@ -69,7 +69,7 @@ describe('SurveyRepository', () => { }); it('should return empty rows', async () => { - const mockResponse = ({ rows: [], rowCount: 1 } as any) as Promise>; + const mockResponse = { rows: [], rowCount: 1 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new SurveyRepository(dbConnection); @@ -82,9 +82,9 @@ describe('SurveyRepository', () => { describe('getSurveyData', () => { it('should return result', async () => { - const mockRow = ({ survey_id: 1 } as unknown) as SurveyRecord; + const mockRow = { survey_id: 1 } as unknown as SurveyRecord; - const mockResponse = ({ rows: [mockRow], rowCount: 1 } as any) as Promise>; + const mockResponse = { rows: [mockRow], rowCount: 1 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new SurveyRepository(dbConnection); @@ -95,7 +95,7 @@ describe('SurveyRepository', () => { }); it('should throw an error', async () => { - const mockResponse = ({ rows: [], rowCount: 0 } as any) as Promise>; + const mockResponse = { rows: [], rowCount: 0 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new SurveyRepository(dbConnection); @@ -111,12 +111,12 @@ describe('SurveyRepository', () => { describe('getSurveyTypesData', () => { it('returns rows', async () => { - const mockRows = ([ + const mockRows = [ { survey_id: 1, type_id: 1, progress_id: 1 }, { survey_id: 1, type_id: 2, progress_id: 1 } - ] as unknown) as SurveyTypeRecord[]; + ] as unknown as SurveyTypeRecord[]; - const mockResponse = ({ rows: mockRows, rowCount: 2 } as any) as Promise>; + const mockResponse = { rows: mockRows, rowCount: 2 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new SurveyRepository(dbConnection); @@ -127,7 +127,7 @@ describe('SurveyRepository', () => { }); it('returns empty rows', async () => { - const mockResponse = ({ rows: [], rowCount: 0 } as any) as Promise>; + const mockResponse = { rows: [], rowCount: 0 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new SurveyRepository(dbConnection); @@ -140,7 +140,7 @@ describe('SurveyRepository', () => { describe('getSpeciesData', () => { it('should return result', async () => { - const mockResponse = ({ rows: [{ id: 1 }], rowCount: 1 } as any) as Promise>; + const mockResponse = { rows: [{ id: 1 }], rowCount: 1 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new SurveyRepository(dbConnection); @@ -151,7 +151,7 @@ describe('SurveyRepository', () => { }); it('should return empty rows', async () => { - const mockResponse = ({ rows: [], rowCount: 1 } as any) as Promise>; + const mockResponse = { rows: [], rowCount: 1 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new SurveyRepository(dbConnection); @@ -165,7 +165,7 @@ describe('SurveyRepository', () => { describe('getSurveyPurposeAndMethodology', () => { it('should return result', async () => { - const mockResponse = ({ rows: [{ id: 1 }], rowCount: 1 } as any) as Promise>; + const mockResponse = { rows: [{ id: 1 }], rowCount: 1 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new SurveyRepository(dbConnection); @@ -176,7 +176,7 @@ describe('SurveyRepository', () => { }); it('should throw an error', async () => { - const mockResponse = ({ rows: undefined, rowCount: 0 } as any) as Promise>; + const mockResponse = { rows: undefined, rowCount: 0 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new SurveyRepository(dbConnection); @@ -192,7 +192,7 @@ describe('SurveyRepository', () => { describe('getSurveyProprietorDataForView', () => { it('should return result', async () => { - const mockResponse = ({ rows: [{ id: 1 }], rowCount: 1 } as any) as Promise>; + const mockResponse = { rows: [{ id: 1 }], rowCount: 1 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new SurveyRepository(dbConnection); @@ -203,7 +203,7 @@ describe('SurveyRepository', () => { }); it('should return Null', async () => { - const mockResponse = ({ rows: undefined, rowCount: 0 } as any) as Promise>; + const mockResponse = { rows: undefined, rowCount: 0 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new SurveyRepository(dbConnection); @@ -216,7 +216,7 @@ describe('SurveyRepository', () => { describe('getStakeholderPartnershipsBySurveyId', () => { it('should return stakeholder partnerships', async () => { - const mockResponse = ({ rows: [{ id: 1 }], rowCount: 1 } as any) as Promise>; + const mockResponse = { rows: [{ id: 1 }], rowCount: 1 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new SurveyRepository(dbConnection); @@ -227,7 +227,7 @@ describe('SurveyRepository', () => { }); it('should throw an error when rows == null', async () => { - const mockResponse = ({ rows: undefined, rowCount: 0 } as any) as Promise>; + const mockResponse = { rows: undefined, rowCount: 0 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new SurveyRepository(dbConnection); @@ -243,7 +243,7 @@ describe('SurveyRepository', () => { describe('getIndigenousPartnershipsBySurveyId', () => { it('should return indigenous partnerships', async () => { - const mockResponse = ({ rows: [{ id: 1 }], rowCount: 1 } as any) as Promise>; + const mockResponse = { rows: [{ id: 1 }], rowCount: 1 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new SurveyRepository(dbConnection); @@ -254,7 +254,7 @@ describe('SurveyRepository', () => { }); it('should throw an error when rows == null', async () => { - const mockResponse = ({ rows: undefined, rowCount: 0 } as any) as Promise>; + const mockResponse = { rows: undefined, rowCount: 0 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new SurveyRepository(dbConnection); @@ -270,7 +270,7 @@ describe('SurveyRepository', () => { describe('insertIndigenousPartnerships', () => { it('should return indigenous partnerships upon insertion', async () => { - const mockResponse = ({ rows: [{ id: 1 }], rowCount: 1 } as any) as Promise>; + const mockResponse = { rows: [{ id: 1 }], rowCount: 1 } as any as Promise>; const dbConnection = getMockDBConnection({ knex: () => mockResponse }); const repository = new SurveyRepository(dbConnection); @@ -281,7 +281,7 @@ describe('SurveyRepository', () => { }); it('should throw an error when rowCount = 0', async () => { - const mockResponse = ({ rows: [], rowCount: 0 } as any) as Promise>; + const mockResponse = { rows: [], rowCount: 0 } as any as Promise>; const dbConnection = getMockDBConnection({ knex: () => mockResponse }); const repository = new SurveyRepository(dbConnection); @@ -297,7 +297,7 @@ describe('SurveyRepository', () => { describe('insertStakeholderPartnerships', () => { it('should return stakeholder partnerships upon insertion', async () => { - const mockResponse = ({ rows: [{ id: 1 }], rowCount: 1 } as any) as Promise>; + const mockResponse = { rows: [{ id: 1 }], rowCount: 1 } as any as Promise>; const dbConnection = getMockDBConnection({ knex: () => mockResponse }); const repository = new SurveyRepository(dbConnection); @@ -308,7 +308,7 @@ describe('SurveyRepository', () => { }); it('should throw an error when rowCount = 0', async () => { - const mockResponse = ({ rows: [], rowCount: 0 } as any) as Promise>; + const mockResponse = { rows: [], rowCount: 0 } as any as Promise>; const dbConnection = getMockDBConnection({ knex: () => mockResponse }); const repository = new SurveyRepository(dbConnection); @@ -324,7 +324,7 @@ describe('SurveyRepository', () => { describe('deleteIndigenousPartnershipsData', () => { it('should return row count upon deleting indigenous partnerships data', async () => { - const mockResponse = ({ rows: [{ id: 1 }], rowCount: 1 } as any) as Promise>; + const mockResponse = { rows: [{ id: 1 }], rowCount: 1 } as any as Promise>; const dbConnection = getMockDBConnection({ knex: () => mockResponse }); const repository = new SurveyRepository(dbConnection); @@ -337,7 +337,7 @@ describe('SurveyRepository', () => { describe('deleteStakeholderPartnershipsData', () => { it('should return row count upon deleting stakeholder partnerships data', async () => { - const mockResponse = ({ rows: [{ id: 1 }], rowCount: 1 } as any) as Promise>; + const mockResponse = { rows: [{ id: 1 }], rowCount: 1 } as any as Promise>; const dbConnection = getMockDBConnection({ knex: () => mockResponse }); const repository = new SurveyRepository(dbConnection); @@ -350,7 +350,7 @@ describe('SurveyRepository', () => { describe('getAttachmentsData', () => { it('should return result', async () => { - const mockResponse = ({ rows: [{ id: 1 }], rowCount: 1 } as any) as Promise>; + const mockResponse = { rows: [{ id: 1 }], rowCount: 1 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new SurveyRepository(dbConnection); @@ -361,7 +361,7 @@ describe('SurveyRepository', () => { }); it('should return Null', async () => { - const mockResponse = ({ rows: undefined, rowCount: 0 } as any) as Promise>; + const mockResponse = { rows: undefined, rowCount: 0 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new SurveyRepository(dbConnection); @@ -374,7 +374,7 @@ describe('SurveyRepository', () => { describe('getReportAttachmentsData', () => { it('should return result', async () => { - const mockResponse = ({ rows: [{ id: 1 }], rowCount: 1 } as any) as Promise>; + const mockResponse = { rows: [{ id: 1 }], rowCount: 1 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new SurveyRepository(dbConnection); @@ -385,7 +385,7 @@ describe('SurveyRepository', () => { }); it('should return empty rows', async () => { - const mockResponse = ({ rows: [], rowCount: 1 } as any) as Promise>; + const mockResponse = { rows: [], rowCount: 1 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new SurveyRepository(dbConnection); @@ -398,12 +398,12 @@ describe('SurveyRepository', () => { describe('insertSurveyData', () => { it('should return result and add the geometry', async () => { - const mockResponse = ({ rows: [{ id: 1 }], rowCount: 1 } as any) as Promise>; + const mockResponse = { rows: [{ id: 1 }], rowCount: 1 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new SurveyRepository(dbConnection); - const input = ({ + const input = { survey_details: { survey_name: 'name', start_date: 'start', @@ -417,7 +417,7 @@ describe('SurveyRepository', () => { surveyed_all_areas: 'Y' }, locations: [{ geometry: [{ id: 1 }] }] - } as unknown) as PostSurveyObject; + } as unknown as PostSurveyObject; const response = await repository.insertSurveyData(1, input); @@ -425,12 +425,12 @@ describe('SurveyRepository', () => { }); it('should return result', async () => { - const mockResponse = ({ rows: [{ id: 1 }], rowCount: 1 } as any) as Promise>; + const mockResponse = { rows: [{ id: 1 }], rowCount: 1 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new SurveyRepository(dbConnection); - const input = ({ + const input = { survey_details: { survey_name: 'name', start_date: 'start', @@ -444,7 +444,7 @@ describe('SurveyRepository', () => { surveyed_all_areas: 'Y' }, locations: [{ geometry: [] }] - } as unknown) as PostSurveyObject; + } as unknown as PostSurveyObject; const response = await repository.insertSurveyData(1, input); @@ -452,12 +452,12 @@ describe('SurveyRepository', () => { }); it('should throw an error', async () => { - const mockResponse = ({ rows: undefined, rowCount: 0 } as any) as Promise>; + const mockResponse = { rows: undefined, rowCount: 0 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new SurveyRepository(dbConnection); - const input = ({ + const input = { survey_details: { survey_name: 'name', start_date: 'start', @@ -471,7 +471,7 @@ describe('SurveyRepository', () => { surveyed_all_areas: 'Y' }, locations: [{ geometry: [{ id: 1 }] }] - } as unknown) as PostSurveyObject; + } as unknown as PostSurveyObject; try { await repository.insertSurveyData(1, input); @@ -484,7 +484,7 @@ describe('SurveyRepository', () => { describe('insertSurveyTypes', () => { it('should insert records', async () => { - const mockResponse = ({ rows: [{ id: 1 }, { id: 2 }], rowCount: 2 } as any) as Promise>; + const mockResponse = { rows: [{ id: 1 }, { id: 2 }], rowCount: 2 } as any as Promise>; const dbConnection = getMockDBConnection({ knex: () => mockResponse }); const repository = new SurveyRepository(dbConnection); @@ -498,7 +498,7 @@ describe('SurveyRepository', () => { }); it('should throw an error if fewer records inserted then expected', async () => { - const mockResponse = ({ rows: [{ id: 1 }, { id: 2 }], rowCount: 2 } as any) as Promise>; + const mockResponse = { rows: [{ id: 1 }, { id: 2 }], rowCount: 2 } as any as Promise>; const dbConnection = getMockDBConnection({ knex: () => mockResponse }); const repository = new SurveyRepository(dbConnection); @@ -517,7 +517,7 @@ describe('SurveyRepository', () => { describe('insertFocalSpecies', () => { it('should return result', async () => { - const mockResponse = ({ rows: [{ id: 1 }], rowCount: 1 } as any) as Promise>; + const mockResponse = { rows: [{ id: 1 }], rowCount: 1 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new SurveyRepository(dbConnection); @@ -528,7 +528,7 @@ describe('SurveyRepository', () => { }); it('should throw an error', async () => { - const mockResponse = ({ rows: undefined, rowCount: 0 } as any) as Promise>; + const mockResponse = { rows: undefined, rowCount: 0 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new SurveyRepository(dbConnection); @@ -544,7 +544,7 @@ describe('SurveyRepository', () => { describe('insertAncillarySpecies', () => { it('should return result', async () => { - const mockResponse = ({ rows: [{ id: 1 }], rowCount: 1 } as any) as Promise>; + const mockResponse = { rows: [{ id: 1 }], rowCount: 1 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new SurveyRepository(dbConnection); @@ -555,7 +555,7 @@ describe('SurveyRepository', () => { }); it('should throw an error', async () => { - const mockResponse = ({ rows: undefined, rowCount: 0 } as any) as Promise>; + const mockResponse = { rows: undefined, rowCount: 0 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new SurveyRepository(dbConnection); @@ -571,7 +571,7 @@ describe('SurveyRepository', () => { describe('insertVantageCodes', () => { it('should return result', async () => { - const mockResponse = ({ rows: [{ id: 1 }], rowCount: 1 } as any) as Promise>; + const mockResponse = { rows: [{ id: 1 }], rowCount: 1 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new SurveyRepository(dbConnection); @@ -582,7 +582,7 @@ describe('SurveyRepository', () => { }); it('should throw an error', async () => { - const mockResponse = ({ rows: undefined, rowCount: 0 } as any) as Promise>; + const mockResponse = { rows: undefined, rowCount: 0 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new SurveyRepository(dbConnection); @@ -598,14 +598,14 @@ describe('SurveyRepository', () => { describe('insertSurveyProprietor', () => { it('should return undefined if data is not proprietary', async () => { - const mockResponse = ({ rows: [{ id: 1 }], rowCount: 1 } as any) as Promise>; + const mockResponse = { rows: [{ id: 1 }], rowCount: 1 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new SurveyRepository(dbConnection); - const input = ({ + const input = { survey_data_proprietary: false - } as unknown) as PostProprietorData; + } as unknown as PostProprietorData; const response = await repository.insertSurveyProprietor(input, 1); @@ -613,19 +613,19 @@ describe('SurveyRepository', () => { }); it('should return result', async () => { - const mockResponse = ({ rows: [{ id: 1 }], rowCount: 1 } as any) as Promise>; + const mockResponse = { rows: [{ id: 1 }], rowCount: 1 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new SurveyRepository(dbConnection); - const input = ({ + const input = { survey_data_proprietary: true, prt_id: 1, fn_id: 1, rationale: 'ratio', proprietor_name: 'name', disa_required: false - } as unknown) as PostProprietorData; + } as unknown as PostProprietorData; const response = await repository.insertSurveyProprietor(input, 1); @@ -633,19 +633,19 @@ describe('SurveyRepository', () => { }); it('should throw an error', async () => { - const mockResponse = ({ rows: undefined, rowCount: 0 } as any) as Promise>; + const mockResponse = { rows: undefined, rowCount: 0 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new SurveyRepository(dbConnection); - const input = ({ + const input = { survey_data_proprietary: true, prt_id: 1, fn_id: 1, rationale: 'ratio', proprietor_name: 'name', disa_required: false - } as unknown) as PostProprietorData; + } as unknown as PostProprietorData; try { await repository.insertSurveyProprietor(input, 1); @@ -658,7 +658,7 @@ describe('SurveyRepository', () => { describe('associateSurveyToPermit', () => { it('should return result', async () => { - const mockResponse = ({ rows: [{ id: 1 }], rowCount: 1 } as any) as Promise>; + const mockResponse = { rows: [{ id: 1 }], rowCount: 1 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new SurveyRepository(dbConnection); @@ -669,7 +669,7 @@ describe('SurveyRepository', () => { }); it('should throw an error', async () => { - const mockResponse = ({ rowCount: 0 } as any) as Promise>; + const mockResponse = { rowCount: 0 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new SurveyRepository(dbConnection); @@ -685,7 +685,7 @@ describe('SurveyRepository', () => { describe('insertSurveyPermit', () => { it('should return result', async () => { - const mockResponse = ({ rows: [{ id: 1 }], rowCount: 1 } as any) as Promise>; + const mockResponse = { rows: [{ id: 1 }], rowCount: 1 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new SurveyRepository(dbConnection); @@ -696,7 +696,7 @@ describe('SurveyRepository', () => { }); it('should throw an error', async () => { - const mockResponse = ({ rowCount: 0 } as any) as Promise>; + const mockResponse = { rowCount: 0 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new SurveyRepository(dbConnection); @@ -712,7 +712,7 @@ describe('SurveyRepository', () => { describe('deleteSurveyTypesData', () => { it('should return result', async () => { - const mockResponse = ({ rows: [], rowCount: 1 } as any) as Promise>; + const mockResponse = { rows: [], rowCount: 1 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new SurveyRepository(dbConnection); @@ -725,7 +725,7 @@ describe('SurveyRepository', () => { describe('deleteSurveySpeciesData', () => { it('should return result', async () => { - const mockResponse = ({ rows: [{ id: 1 }], rowCount: 1 } as any) as Promise>; + const mockResponse = { rows: [{ id: 1 }], rowCount: 1 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new SurveyRepository(dbConnection); @@ -738,7 +738,7 @@ describe('SurveyRepository', () => { describe('unassociatePermitFromSurvey', () => { it('should return result', async () => { - const mockResponse = ({ rows: [{ id: 1 }], rowCount: 1 } as any) as Promise>; + const mockResponse = { rows: [{ id: 1 }], rowCount: 1 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new SurveyRepository(dbConnection); @@ -751,7 +751,7 @@ describe('SurveyRepository', () => { describe('deleteSurveyProprietorData', () => { it('should return result', async () => { - const mockResponse = ({ rows: [{ id: 1 }], rowCount: 1 } as any) as Promise>; + const mockResponse = { rows: [{ id: 1 }], rowCount: 1 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new SurveyRepository(dbConnection); @@ -764,7 +764,7 @@ describe('SurveyRepository', () => { describe('deleteSurveyVantageCodes', () => { it('should return result', async () => { - const mockResponse = ({ rows: [{ id: 1 }], rowCount: 1 } as any) as Promise>; + const mockResponse = { rows: [{ id: 1 }], rowCount: 1 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new SurveyRepository(dbConnection); @@ -777,12 +777,12 @@ describe('SurveyRepository', () => { describe('updateSurveyDetailsData', () => { it('should return undefined and ue all inputs', async () => { - const mockResponse = ({ rows: [{ id: 1 }], rowCount: 1 } as any) as Promise>; + const mockResponse = { rows: [{ id: 1 }], rowCount: 1 } as any as Promise>; const dbConnection = getMockDBConnection({ knex: () => mockResponse }); const repository = new SurveyRepository(dbConnection); - const input = ({ + const input = { survey_details: { name: 'name', start_date: 'start', @@ -796,7 +796,7 @@ describe('SurveyRepository', () => { revision_count: 1 }, locations: [{ geometry: [{ id: 1 }] }] - } as unknown) as PutSurveyObject; + } as unknown as PutSurveyObject; const response = await repository.updateSurveyDetailsData(1, input); @@ -804,12 +804,12 @@ describe('SurveyRepository', () => { }); it('should return undefined and ue all inputs', async () => { - const mockResponse = ({ rows: [{ id: 1 }], rowCount: 1 } as any) as Promise>; + const mockResponse = { rows: [{ id: 1 }], rowCount: 1 } as any as Promise>; const dbConnection = getMockDBConnection({ knex: () => mockResponse }); const repository = new SurveyRepository(dbConnection); - const input = ({ + const input = { survey_details: { name: 'name', start_date: 'start', @@ -823,7 +823,7 @@ describe('SurveyRepository', () => { revision_count: 1 }, locations: [{ geometry: [] }] - } as unknown) as PutSurveyObject; + } as unknown as PutSurveyObject; const response = await repository.updateSurveyDetailsData(1, input); @@ -831,12 +831,12 @@ describe('SurveyRepository', () => { }); it('should throw an error', async () => { - const mockResponse = ({ rowCount: 0 } as any) as Promise>; + const mockResponse = { rowCount: 0 } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); const repository = new SurveyRepository(dbConnection); - const input = ({ + const input = { survey_details: { name: 'name', start_date: 'start', @@ -850,7 +850,7 @@ describe('SurveyRepository', () => { revision_count: 1 }, locations: [{ geometry: [] }] - } as unknown) as PutSurveyObject; + } as unknown as PutSurveyObject; try { await repository.updateSurveyDetailsData(1, input); @@ -863,7 +863,7 @@ describe('SurveyRepository', () => { describe('insertManySurveyIntendedOutcomes', () => { it('should insert intended outcome ids', async () => { - const mockResponse = ({ rowCount: 0 } as any) as Promise>; + const mockResponse = { rowCount: 0 } as any as Promise>; const dbConnection = getMockDBConnection({ knex: () => mockResponse }); const repository = new SurveyRepository(dbConnection); @@ -874,7 +874,7 @@ describe('SurveyRepository', () => { describe('deleteManySurveyIntendedOutcomes', () => { it('should delete intended outcome ids', async () => { - const mockResponse = ({ rowCount: 0 } as any) as Promise>; + const mockResponse = { rowCount: 0 } as any as Promise>; const dbConnection = getMockDBConnection({ knex: () => mockResponse }); const repository = new SurveyRepository(dbConnection); diff --git a/api/src/repositories/user-repository.test.ts b/api/src/repositories/user-repository.test.ts index c8b4a1c5e5..bcb5d961e1 100644 --- a/api/src/repositories/user-repository.test.ts +++ b/api/src/repositories/user-repository.test.ts @@ -17,7 +17,7 @@ describe('UserRepository', () => { it('should get all roles', async () => { const mockResponse = [{ system_role_id: 1, name: 'admin' }]; - const mockQueryResponse = ({ rowCount: 1, rows: mockResponse } as any) as Promise>; + const mockQueryResponse = { rowCount: 1, rows: mockResponse } as any as Promise>; const mockDBConnection = getMockDBConnection({ sql: async () => { @@ -38,7 +38,7 @@ describe('UserRepository', () => { sinon.restore(); }); it('should throw an error when no user is found', async () => { - const mockQueryResponse = ({ rowCount: 0, rows: [] } as any) as Promise>; + const mockQueryResponse = { rowCount: 0, rows: [] } as any as Promise>; const mockDBConnection = getMockDBConnection({ sql: async () => { @@ -60,7 +60,7 @@ describe('UserRepository', () => { const mockResponse = [ { system_user_id: 1, user_identifier: 1, record_end_date: 'data', role_ids: [1], role_names: ['admin'] } ]; - const mockQueryResponse = ({ rowCount: 1, rows: mockResponse } as any) as Promise>; + const mockQueryResponse = { rowCount: 1, rows: mockResponse } as any as Promise>; const mockDBConnection = getMockDBConnection({ sql: async () => { @@ -81,7 +81,7 @@ describe('UserRepository', () => { sinon.restore(); }); it('should return empty array when no user found', async () => { - const mockQueryResponse = ({ rowCount: 1, rows: [] } as any) as Promise>; + const mockQueryResponse = { rowCount: 1, rows: [] } as any as Promise>; const mockDBConnection = getMockDBConnection({ sql: async () => { @@ -111,7 +111,7 @@ describe('UserRepository', () => { agency: null } ]; - const mockQueryResponse = ({ rowCount: 1, rows: mockResponse } as any) as Promise>; + const mockQueryResponse = { rowCount: 1, rows: mockResponse } as any as Promise>; const mockDBConnection = getMockDBConnection({ sql: async () => { @@ -132,7 +132,7 @@ describe('UserRepository', () => { sinon.restore(); }); it('should return empty array when no user found', async () => { - const mockQueryResponse = ({ rowCount: 1, rows: [] } as any) as Promise>; + const mockQueryResponse = { rowCount: 1, rows: [] } as any as Promise>; const mockDBConnection = getMockDBConnection({ sql: async () => { @@ -162,7 +162,7 @@ describe('UserRepository', () => { agency: null } ]; - const mockQueryResponse = ({ rowCount: 1, rows: mockResponse } as any) as Promise>; + const mockQueryResponse = { rowCount: 1, rows: mockResponse } as any as Promise>; const mockDBConnection = getMockDBConnection({ sql: async () => { @@ -183,7 +183,7 @@ describe('UserRepository', () => { sinon.restore(); }); it('should throw an error when insert fails', async () => { - const mockQueryResponse = ({ rowCount: 0, rows: [] } as any) as Promise>; + const mockQueryResponse = { rowCount: 0, rows: [] } as any as Promise>; const mockDBConnection = getMockDBConnection({ sql: async () => { @@ -212,7 +212,7 @@ describe('UserRepository', () => { record_effective_date: 'date' } ]; - const mockQueryResponse = ({ rowCount: 1, rows: mockResponse } as any) as Promise>; + const mockQueryResponse = { rowCount: 1, rows: mockResponse } as any as Promise>; const mockDBConnection = getMockDBConnection({ sql: async () => { @@ -233,7 +233,7 @@ describe('UserRepository', () => { sinon.restore(); }); it('should return empty array when no users found', async () => { - const mockQueryResponse = ({ rowCount: 1, rows: [] } as any) as Promise>; + const mockQueryResponse = { rowCount: 1, rows: [] } as any as Promise>; const mockDBConnection = getMockDBConnection({ sql: async () => { @@ -261,7 +261,7 @@ describe('UserRepository', () => { agency: null } ]; - const mockQueryResponse = ({ rowCount: 1, rows: mockResponse } as any) as Promise>; + const mockQueryResponse = { rowCount: 1, rows: mockResponse } as any as Promise>; const mockDBConnection = getMockDBConnection({ sql: async () => { @@ -282,7 +282,7 @@ describe('UserRepository', () => { sinon.restore(); }); it('should throw an error when activate fails', async () => { - const mockQueryResponse = ({ rowCount: 0, rows: [] } as any) as Promise>; + const mockQueryResponse = { rowCount: 0, rows: [] } as any as Promise>; const mockDBConnection = getMockDBConnection({ sql: async () => { @@ -310,7 +310,7 @@ describe('UserRepository', () => { record_effective_date: 'date' } ]; - const mockQueryResponse = ({ rowCount: 1, rows: mockResponse } as any) as Promise>; + const mockQueryResponse = { rowCount: 1, rows: mockResponse } as any as Promise>; const mockDBConnection = getMockDBConnection({ sql: async () => { @@ -331,7 +331,7 @@ describe('UserRepository', () => { sinon.restore(); }); it('should throw an error when deactivate fails', async () => { - const mockQueryResponse = ({ rowCount: 0, rows: [] } as any) as Promise>; + const mockQueryResponse = { rowCount: 0, rows: [] } as any as Promise>; const mockDBConnection = getMockDBConnection({ sql: async () => { @@ -359,7 +359,7 @@ describe('UserRepository', () => { record_effective_date: 'date' } ]; - const mockQueryResponse = ({ rowCount: 1, rows: mockResponse } as any) as Promise>; + const mockQueryResponse = { rowCount: 1, rows: mockResponse } as any as Promise>; const mockDBConnection = getMockDBConnection({ sql: async () => { @@ -390,7 +390,7 @@ describe('UserRepository', () => { record_effective_date: 'date' } ]; - const mockQueryResponse = ({ rowCount: 1, rows: mockResponse } as any) as Promise>; + const mockQueryResponse = { rowCount: 1, rows: mockResponse } as any as Promise>; const mockDBConnection = getMockDBConnection({ sql: async () => { @@ -411,7 +411,7 @@ describe('UserRepository', () => { sinon.restore(); }); it('should throw an error when adding role fails', async () => { - const mockQueryResponse = ({ rowCount: 0, rows: [] } as any) as Promise>; + const mockQueryResponse = { rowCount: 0, rows: [] } as any as Promise>; const mockDBConnection = getMockDBConnection({ sql: async () => { @@ -439,7 +439,7 @@ describe('UserRepository', () => { record_effective_date: 'date' } ]; - const mockQueryResponse = ({ rowCount: 1, rows: mockResponse } as any) as Promise>; + const mockQueryResponse = { rowCount: 1, rows: mockResponse } as any as Promise>; const mockDBConnection = getMockDBConnection({ sql: async () => { diff --git a/api/src/request-handlers/security/authentication.test.ts b/api/src/request-handlers/security/authentication.test.ts index 80aa956d0c..8e853831ad 100644 --- a/api/src/request-handlers/security/authentication.test.ts +++ b/api/src/request-handlers/security/authentication.test.ts @@ -7,23 +7,23 @@ import * as authentication from './authentication'; describe('authenticateRequest', function () { it('throws HTTP401 when authorization headers were null or missing', async function () { try { - await authentication.authenticateRequest((undefined as unknown) as Request); + await authentication.authenticateRequest(undefined as unknown as Request); expect.fail(); } catch (actualError) { expect(actualError).instanceOf(HTTP401); } try { - await authentication.authenticateRequest(({} as unknown) as Request); + await authentication.authenticateRequest({} as unknown as Request); expect.fail(); } catch (actualError) { expect(actualError).instanceOf(HTTP401); } try { - await authentication.authenticateRequest(({ + await authentication.authenticateRequest({ headers: {} - } as unknown) as Request); + } as unknown as Request); expect.fail(); } catch (actualError) { expect(actualError).instanceOf(HTTP401); @@ -32,46 +32,46 @@ describe('authenticateRequest', function () { it('throws HTTP401 when authorization header contains an invalid bearer token', async function () { try { - await authentication.authenticateRequest(({ + await authentication.authenticateRequest({ headers: { authorization: 'Not a bearer token' } - } as unknown) as Request); + } as unknown as Request); expect.fail(); } catch (actualError) { expect(actualError).instanceOf(HTTP401); } try { - await authentication.authenticateRequest(({ + await authentication.authenticateRequest({ headers: { authorization: 'Bearer ' } - } as unknown) as Request); + } as unknown as Request); expect.fail(); } catch (actualError) { expect(actualError).instanceOf(HTTP401); } try { - await authentication.authenticateRequest(({ + await authentication.authenticateRequest({ headers: { authorization: 'Bearer not-encoded' } - } as unknown) as Request); + } as unknown as Request); expect.fail(); } catch (actualError) { expect(actualError).instanceOf(HTTP401); } try { - await authentication.authenticateRequest(({ + await authentication.authenticateRequest({ headers: { // sample encoded json web token from jwt.io (without kid header) authorization: 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c' } - } as unknown) as Request); + } as unknown as Request); expect.fail(); } catch (actualError) { expect(actualError).instanceOf(HTTP401); diff --git a/api/src/request-handlers/security/authorization.test.ts b/api/src/request-handlers/security/authorization.test.ts index c5aaf7b706..1be2b33659 100644 --- a/api/src/request-handlers/security/authorization.test.ts +++ b/api/src/request-handlers/security/authorization.test.ts @@ -67,10 +67,10 @@ describe('authorizeRequest', function () { it('returns false if systemUserObject is null', async function () { registerMockDBConnection(); - const mockSystemUserObject = (undefined as unknown) as SystemUser; + const mockSystemUserObject = undefined as unknown as SystemUser; sinon.stub(AuthorizationService.prototype, 'getSystemUserObject').resolves(mockSystemUserObject); - const mockReq = ({ authorization_scheme: {} } as unknown) as Request; + const mockReq = { authorization_scheme: {} } as unknown as Request; const isAuthorized = await authorization.authorizeRequest(mockReq); expect(isAuthorized).to.equal(false); @@ -79,12 +79,12 @@ describe('authorizeRequest', function () { it('returns true if the user is a system administrator', async function () { registerMockDBConnection(); - const mockSystemUserObject = ({ role_names: [] } as unknown) as SystemUser; + const mockSystemUserObject = { role_names: [] } as unknown as SystemUser; sinon.stub(AuthorizationService.prototype, 'getSystemUserObject').resolves(mockSystemUserObject); sinon.stub(AuthorizationService.prototype, 'authorizeSystemAdministrator').resolves(true); - const mockReq = ({ authorization_scheme: {} } as unknown) as Request; + const mockReq = { authorization_scheme: {} } as unknown as Request; const isAuthorized = await authorization.authorizeRequest(mockReq); expect(isAuthorized).to.equal(true); @@ -93,12 +93,12 @@ describe('authorizeRequest', function () { it('returns true if the authorization_scheme is undefined', async function () { registerMockDBConnection(); - const mockSystemUserObject = ({ role_names: [] } as unknown) as SystemUser; + const mockSystemUserObject = { role_names: [] } as unknown as SystemUser; sinon.stub(AuthorizationService.prototype, 'getSystemUserObject').resolves(mockSystemUserObject); sinon.stub(AuthorizationService.prototype, 'authorizeSystemAdministrator').resolves(false); - const mockReq = ({ authorization_scheme: undefined } as unknown) as Request; + const mockReq = { authorization_scheme: undefined } as unknown as Request; const isAuthorized = await authorization.authorizeRequest(mockReq); expect(isAuthorized).to.equal(true); @@ -107,14 +107,14 @@ describe('authorizeRequest', function () { it('returns true if the user is authorized against the authorization_scheme', async function () { registerMockDBConnection(); - const mockSystemUserObject = ({ role_names: [] } as unknown) as SystemUser; + const mockSystemUserObject = { role_names: [] } as unknown as SystemUser; sinon.stub(AuthorizationService.prototype, 'getSystemUserObject').resolves(mockSystemUserObject); sinon.stub(AuthorizationService.prototype, 'authorizeSystemAdministrator').resolves(false); sinon.stub(AuthorizationService.prototype, 'executeAuthorizationScheme').resolves(true); - const mockReq = ({ authorization_scheme: {} } as unknown) as Request; + const mockReq = { authorization_scheme: {} } as unknown as Request; const isAuthorized = await authorization.authorizeRequest(mockReq); expect(isAuthorized).to.equal(true); @@ -123,14 +123,14 @@ describe('authorizeRequest', function () { it('returns false if the user is not authorized against the authorization_scheme', async function () { registerMockDBConnection(); - const mockSystemUserObject = ({ role_names: [] } as unknown) as SystemUser; + const mockSystemUserObject = { role_names: [] } as unknown as SystemUser; sinon.stub(AuthorizationService.prototype, 'getSystemUserObject').resolves(mockSystemUserObject); sinon.stub(AuthorizationService.prototype, 'authorizeSystemAdministrator').resolves(false); sinon.stub(AuthorizationService.prototype, 'executeAuthorizationScheme').resolves(false); - const mockReq = ({ authorization_scheme: {} } as unknown) as Request; + const mockReq = { authorization_scheme: {} } as unknown as Request; const isAuthorized = await authorization.authorizeRequest(mockReq); expect(isAuthorized).to.equal(false); @@ -143,7 +143,7 @@ describe('authorizeRequest', function () { }) }); - const mockReq = ({ authorization_scheme: {} } as unknown) as Request; + const mockReq = { authorization_scheme: {} } as unknown as Request; const isAuthorized = await authorization.authorizeRequest(mockReq); expect(isAuthorized).to.equal(false); diff --git a/api/src/request-handlers/security/authorization.ts b/api/src/request-handlers/security/authorization.ts index c70219532a..2f5bc5ec9c 100644 --- a/api/src/request-handlers/security/authorization.ts +++ b/api/src/request-handlers/security/authorization.ts @@ -19,7 +19,7 @@ export type AuthorizationSchemeCallback = (req: Request) => AuthorizationScheme; * @return {*} {RequestHandler} */ export function authorizeRequestHandler(authorizationSchemeCallback: AuthorizationSchemeCallback): RequestHandler { - return async (req, res, next) => { + return async (req, _, next) => { req['authorization_scheme'] = authorizationSchemeCallback(req); const isAuthorized = await authorizeRequest(req); diff --git a/api/src/services/attachment-service.test.ts b/api/src/services/attachment-service.test.ts index 82a89c4d82..6d0013a780 100644 --- a/api/src/services/attachment-service.test.ts +++ b/api/src/services/attachment-service.test.ts @@ -42,7 +42,7 @@ describe('AttachmentService', () => { const dbConnection = getMockDBConnection(); const service = new AttachmentService(dbConnection); - const data = [({ id: 1 } as unknown) as IProjectAttachment]; + const data = [{ id: 1 } as unknown as IProjectAttachment]; const repoStub = sinon.stub(AttachmentRepository.prototype, 'getProjectAttachments').resolves(data); @@ -59,11 +59,9 @@ describe('AttachmentService', () => { const attachmentService = new AttachmentService(dbConnection); - const attachmentData = [ - ({ survey_attachment_id: 1, file_type: 'Attachment' } as unknown) as ISurveyAttachment - ]; + const attachmentData = [{ survey_attachment_id: 1, file_type: 'Attachment' } as unknown as ISurveyAttachment]; - const supplementaryData = ({ survey_attachment_publish_id: 1 } as unknown) as SurveyAttachmentPublish; + const supplementaryData = { survey_attachment_publish_id: 1 } as unknown as SurveyAttachmentPublish; const attachmentRepoStub = sinon .stub(AttachmentRepository.prototype, 'getSurveyAttachments') @@ -88,9 +86,9 @@ describe('AttachmentService', () => { const attachmentService = new AttachmentService(dbConnection); - const attachmentData = [({ survey_report_attachment_id: 1 } as unknown) as ISurveyReportAttachment]; + const attachmentData = [{ survey_report_attachment_id: 1 } as unknown as ISurveyReportAttachment]; - const supplementaryData = ({ survey_report_publish_id: 1 } as unknown) as SurveyReportPublish; + const supplementaryData = { survey_report_publish_id: 1 } as unknown as SurveyReportPublish; const attachmentRepoStub = sinon .stub(AttachmentRepository.prototype, 'getSurveyReportAttachments') @@ -114,7 +112,7 @@ describe('AttachmentService', () => { const dbConnection = getMockDBConnection(); const service = new AttachmentService(dbConnection); - const data = ({ id: 1 } as unknown) as IProjectAttachment; + const data = { id: 1 } as unknown as IProjectAttachment; const repoStub = sinon.stub(AttachmentRepository.prototype, 'getProjectAttachmentById').resolves(data); @@ -130,7 +128,7 @@ describe('AttachmentService', () => { const dbConnection = getMockDBConnection(); const service = new AttachmentService(dbConnection); - const data = ([{ id: 1 }, { id: 2 }] as unknown) as IProjectAttachment[]; + const data = [{ id: 1 }, { id: 2 }] as unknown as IProjectAttachment[]; const repoStub = sinon.stub(AttachmentRepository.prototype, 'getProjectAttachmentsByIds').resolves(data); @@ -151,7 +149,7 @@ describe('AttachmentService', () => { const repoStub = sinon.stub(AttachmentRepository.prototype, 'insertProjectAttachment').resolves(data); const response = await service.insertProjectAttachment( - ({} as unknown) as Express.Multer.File, + {} as unknown as Express.Multer.File, 1, 'string', 'string' @@ -183,7 +181,7 @@ describe('AttachmentService', () => { const dbConnection = getMockDBConnection(); const service = new AttachmentService(dbConnection); - const data = ({ id: 1 } as unknown) as QueryResult; + const data = { id: 1 } as unknown as QueryResult; const repoStub = sinon.stub(AttachmentRepository.prototype, 'getProjectAttachmentByFileName').resolves(data); @@ -201,14 +199,14 @@ describe('AttachmentService', () => { const serviceStub1 = sinon .stub(AttachmentService.prototype, 'getProjectAttachmentByFileName') - .resolves(({ rowCount: 1 } as unknown) as QueryResult); + .resolves({ rowCount: 1 } as unknown as QueryResult); const serviceStub2 = sinon .stub(AttachmentService.prototype, 'updateProjectAttachment') .resolves({ project_attachment_id: 1, revision_count: 1 }); const response = await service.upsertProjectAttachment( - ({ originalname: 'file.test' } as unknown) as Express.Multer.File, + { originalname: 'file.test' } as unknown as Express.Multer.File, 1, 'string' ); @@ -228,14 +226,14 @@ describe('AttachmentService', () => { const serviceStub1 = sinon .stub(AttachmentService.prototype, 'getProjectAttachmentByFileName') - .resolves(({ rowCount: 0 } as unknown) as QueryResult); + .resolves({ rowCount: 0 } as unknown as QueryResult); const serviceStub2 = sinon .stub(AttachmentService.prototype, 'insertProjectAttachment') .resolves({ project_attachment_id: 1, revision_count: 1 }); const response = await service.upsertProjectAttachment( - ({ originalname: 'file.test' } as unknown) as Express.Multer.File, + { originalname: 'file.test' } as unknown as Express.Multer.File, 1, 'string' ); @@ -290,11 +288,11 @@ describe('AttachmentService', () => { const getProjectReportStub = sinon .stub(AttachmentService.prototype, 'getProjectReportAttachmentById') - .resolves(({ + .resolves({ key: 'key', uuid: 'uuid', project_report_attachment_id: 1 - } as unknown) as IProjectReportAttachment); + } as unknown as IProjectReportAttachment); const deleteProjectReportAuthorsStub = sinon .stub(AttachmentService.prototype, 'deleteProjectReportAttachmentAuthors') .resolves(); @@ -336,11 +334,11 @@ describe('AttachmentService', () => { const getProjectReportStub = sinon .stub(AttachmentService.prototype, 'getProjectReportAttachmentById') - .resolves(({ + .resolves({ key: 'key', uuid: 'uuid', project_report_attachment_id: 1 - } as unknown) as IProjectReportAttachment); + } as unknown as IProjectReportAttachment); const deleteProjectReportAuthorsStub = sinon .stub(AttachmentService.prototype, 'deleteProjectReportAttachmentAuthors') .resolves(); @@ -349,11 +347,11 @@ describe('AttachmentService', () => { .resolves(); const getProjectAttachmentStub = sinon .stub(AttachmentService.prototype, 'getProjectAttachmentById') - .resolves(({ + .resolves({ key: 'key', uuid: 'uuid', project_attachment_id: 1 - } as unknown) as IProjectAttachment); + } as unknown as IProjectAttachment); const deleteProjectAttachmentStub = sinon .stub(AttachmentService.prototype, '_deleteProjectAttachmentRecord') .resolves(); @@ -387,7 +385,7 @@ describe('AttachmentService', () => { const dbConnection = getMockDBConnection(); const service = new AttachmentService(dbConnection); - const data = [({ id: 1 } as unknown) as IProjectReportAttachment]; + const data = [{ id: 1 } as unknown as IProjectReportAttachment]; const repoStub = sinon.stub(AttachmentRepository.prototype, 'getProjectReportAttachments').resolves(data); @@ -403,7 +401,7 @@ describe('AttachmentService', () => { const dbConnection = getMockDBConnection(); const service = new AttachmentService(dbConnection); - const data = ({ id: 1 } as unknown) as IProjectReportAttachment; + const data = { id: 1 } as unknown as IProjectReportAttachment; const repoStub = sinon.stub(AttachmentRepository.prototype, 'getProjectReportAttachmentById').resolves(data); @@ -419,7 +417,7 @@ describe('AttachmentService', () => { const dbConnection = getMockDBConnection(); const service = new AttachmentService(dbConnection); - const data = ([{ id: 1 }, { id: 2 }] as unknown) as IProjectReportAttachment[]; + const data = [{ id: 1 }, { id: 2 }] as unknown as IProjectReportAttachment[]; const repoStub = sinon .stub(AttachmentRepository.prototype, 'getProjectReportAttachmentsByIds') @@ -437,7 +435,7 @@ describe('AttachmentService', () => { const dbConnection = getMockDBConnection(); const service = new AttachmentService(dbConnection); - const data = [({ id: 1 } as unknown) as IProjectReportAttachmentAuthor]; + const data = [{ id: 1 } as unknown as IProjectReportAttachmentAuthor]; const repoStub = sinon .stub(AttachmentRepository.prototype, 'getProjectReportAttachmentAuthors') @@ -463,7 +461,7 @@ describe('AttachmentService', () => { 'string', 1, 1, - ({ title: 'string' } as unknown) as PostReportAttachmentMetadata, + { title: 'string' } as unknown as PostReportAttachmentMetadata, 'string' ); @@ -481,9 +479,9 @@ describe('AttachmentService', () => { const repoStub = sinon.stub(AttachmentRepository.prototype, 'updateProjectReportAttachment').resolves(data); - const response = await service.updateProjectReportAttachment('string', 1, ({ + const response = await service.updateProjectReportAttachment('string', 1, { title: 'string' - } as unknown) as PutReportAttachmentMetadata); + } as unknown as PutReportAttachmentMetadata); expect(repoStub).to.be.calledOnce; expect(response).to.eql(data); @@ -495,7 +493,7 @@ describe('AttachmentService', () => { const dbConnection = getMockDBConnection(); const service = new AttachmentService(dbConnection); - const data = ({ id: 1 } as unknown) as QueryResult; + const data = { id: 1 } as unknown as QueryResult; const repoStub = sinon .stub(AttachmentRepository.prototype, 'deleteProjectReportAttachmentAuthors') @@ -530,7 +528,7 @@ describe('AttachmentService', () => { const dbConnection = getMockDBConnection(); const service = new AttachmentService(dbConnection); - const data = ({ id: 1 } as unknown) as QueryResult; + const data = { id: 1 } as unknown as QueryResult; const repoStub = sinon .stub(AttachmentRepository.prototype, 'getProjectReportAttachmentByFileName') @@ -550,7 +548,7 @@ describe('AttachmentService', () => { const serviceStub1 = sinon .stub(AttachmentService.prototype, 'getProjectReportAttachmentByFileName') - .resolves(({ rowCount: 1 } as unknown) as QueryResult); + .resolves({ rowCount: 1 } as unknown as QueryResult); const serviceStub2 = sinon .stub(AttachmentService.prototype, 'updateProjectReportAttachment') @@ -565,7 +563,7 @@ describe('AttachmentService', () => { .resolves(); const response = await service.upsertProjectReportAttachment( - ({ originalname: 'file.test' } as unknown) as Express.Multer.File, + { originalname: 'file.test' } as unknown as Express.Multer.File, 1, { title: 'string', @@ -590,7 +588,7 @@ describe('AttachmentService', () => { const serviceStub1 = sinon .stub(AttachmentService.prototype, 'getProjectReportAttachmentByFileName') - .resolves(({ rowCount: 0 } as unknown) as QueryResult); + .resolves({ rowCount: 0 } as unknown as QueryResult); const serviceStub2 = sinon .stub(AttachmentService.prototype, 'insertProjectReportAttachment') @@ -605,7 +603,7 @@ describe('AttachmentService', () => { .resolves(); const response = await service.upsertProjectReportAttachment( - ({ originalname: 'file.test' } as unknown) as Express.Multer.File, + { originalname: 'file.test' } as unknown as Express.Multer.File, 1, { title: 'string', @@ -650,9 +648,9 @@ describe('AttachmentService', () => { .stub(AttachmentRepository.prototype, 'updateProjectReportAttachmentMetadata') .resolves(); - const response = await service.updateProjectReportAttachmentMetadata(1, 1, ({ + const response = await service.updateProjectReportAttachmentMetadata(1, 1, { title: 'string' - } as unknown) as PutReportAttachmentMetadata); + } as unknown as PutReportAttachmentMetadata); expect(repoStub).to.be.calledOnce; expect(response).to.eql(undefined); @@ -686,7 +684,7 @@ describe('AttachmentService', () => { const dbConnection = getMockDBConnection(); const service = new AttachmentService(dbConnection); - const data = [({ id: 1 } as unknown) as ISurveyAttachment]; + const data = [{ id: 1 } as unknown as ISurveyAttachment]; const repoStub = sinon.stub(AttachmentRepository.prototype, 'getSurveyAttachments').resolves(data); @@ -702,7 +700,7 @@ describe('AttachmentService', () => { const dbConnection = getMockDBConnection(); const service = new AttachmentService(dbConnection); - const data = ([{ id: 1 }, { id: 2 }] as unknown) as ISurveyAttachment[]; + const data = [{ id: 1 }, { id: 2 }] as unknown as ISurveyAttachment[]; const repoStub = sinon.stub(AttachmentRepository.prototype, 'getSurveyAttachmentsByIds').resolves(data); @@ -737,12 +735,12 @@ describe('AttachmentService', () => { const getSurveyAttachmentStub = sinon .stub(AttachmentService.prototype, 'getSurveyAttachmentById') - .resolves(({ + .resolves({ survey_report_attachment_id: 1, survey_attachment_id: 1, uuid: 'uuid', key: 's3/key' - } as unknown) as ISurveyAttachment); + } as unknown as ISurveyAttachment); const deleteSurveyReportPublishStub = sinon .stub(HistoryPublishService.prototype, 'deleteSurveyReportAttachmentPublishRecord') .resolves(); @@ -762,12 +760,12 @@ describe('AttachmentService', () => { const getSurveyReportStub = sinon .stub(AttachmentService.prototype, 'getSurveyReportAttachmentById') - .resolves(({ + .resolves({ survey_report_attachment_id: 1, survey_attachment_id: 1, uuid: 'uuid', key: 's3/key' - } as unknown) as ISurveyReportAttachment); + } as unknown as ISurveyReportAttachment); const mockS3Client = new AWS.S3(); sinon.stub(AWS, 'S3').returns(mockS3Client); @@ -799,17 +797,17 @@ describe('AttachmentService', () => { const getSurveyAttachmentStub = sinon .stub(AttachmentService.prototype, 'getSurveyAttachmentById') - .resolves(({ + .resolves({ survey_report_attachment_id: 1, survey_attachment_id: 1, uuid: 'uuid', key: 's3/key' - } as unknown) as ISurveyAttachment); + } as unknown as ISurveyAttachment); const attachmentPublishStatusStub = sinon .stub(HistoryPublishService.prototype, 'getSurveyAttachmentPublishRecord') - .resolves(({ + .resolves({ survey_attachment_publish_id: 1 - } as unknown) as SurveyAttachmentPublish); + } as unknown as SurveyAttachmentPublish); const deleteSurveyReportPublishStub = sinon .stub(HistoryPublishService.prototype, 'deleteSurveyReportAttachmentPublishRecord') .resolves(); @@ -825,12 +823,12 @@ describe('AttachmentService', () => { const getSurveyReportStub = sinon .stub(AttachmentService.prototype, 'getSurveyReportAttachmentById') - .resolves(({ + .resolves({ survey_report_attachment_id: 1, survey_attachment_id: 1, uuid: 'uuid', key: 's3/key' - } as unknown) as ISurveyReportAttachment); + } as unknown as ISurveyReportAttachment); const mockS3Client = new AWS.S3(); sinon.stub(AWS, 'S3').returns(mockS3Client); @@ -909,7 +907,7 @@ describe('AttachmentService', () => { const dbConnection = getMockDBConnection(); const service = new AttachmentService(dbConnection); - const data = ({ id: 1 } as unknown) as QueryResult; + const data = { id: 1 } as unknown as QueryResult; const repoStub = sinon.stub(AttachmentRepository.prototype, 'getSurveyAttachmentByFileName').resolves(data); @@ -927,14 +925,14 @@ describe('AttachmentService', () => { const serviceStub1 = sinon .stub(AttachmentService.prototype, 'getSurveyReportAttachmentByFileName') - .resolves(({ rowCount: 1 } as unknown) as QueryResult); + .resolves({ rowCount: 1 } as unknown as QueryResult); const serviceStub2 = sinon .stub(AttachmentService.prototype, 'updateSurveyAttachment') .resolves({ survey_attachment_id: 1, revision_count: 1 }); const response = await service.upsertSurveyAttachment( - ({ originalname: 'file.test' } as unknown) as Express.Multer.File, + { originalname: 'file.test' } as unknown as Express.Multer.File, 1, 1, 'string' @@ -955,14 +953,14 @@ describe('AttachmentService', () => { const serviceStub1 = sinon .stub(AttachmentService.prototype, 'getSurveyReportAttachmentByFileName') - .resolves(({ rowCount: 0 } as unknown) as QueryResult); + .resolves({ rowCount: 0 } as unknown as QueryResult); const serviceStub2 = sinon .stub(AttachmentService.prototype, 'insertSurveyAttachment') .resolves({ survey_attachment_id: 1, revision_count: 1 }); const response = await service.upsertSurveyAttachment( - ({ originalname: 'file.test' } as unknown) as Express.Multer.File, + { originalname: 'file.test' } as unknown as Express.Multer.File, 1, 1, 'string' @@ -985,7 +983,7 @@ describe('AttachmentService', () => { const dbConnection = getMockDBConnection(); const service = new AttachmentService(dbConnection); - const data = [({ id: 1 } as unknown) as ISurveyReportAttachment]; + const data = [{ id: 1 } as unknown as ISurveyReportAttachment]; const repoStub = sinon.stub(AttachmentRepository.prototype, 'getSurveyReportAttachments').resolves(data); @@ -1001,7 +999,7 @@ describe('AttachmentService', () => { const dbConnection = getMockDBConnection(); const service = new AttachmentService(dbConnection); - const data = ({ id: 1 } as unknown) as ISurveyReportAttachment; + const data = { id: 1 } as unknown as ISurveyReportAttachment; const repoStub = sinon.stub(AttachmentRepository.prototype, 'getSurveyReportAttachmentById').resolves(data); @@ -1017,7 +1015,7 @@ describe('AttachmentService', () => { const dbConnection = getMockDBConnection(); const service = new AttachmentService(dbConnection); - const data = ([{ id: 1 }, { id: 2 }] as unknown) as ISurveyReportAttachment[]; + const data = [{ id: 1 }, { id: 2 }] as unknown as ISurveyReportAttachment[]; const repoStub = sinon.stub(AttachmentRepository.prototype, 'getSurveyReportAttachmentsByIds').resolves(data); @@ -1033,7 +1031,7 @@ describe('AttachmentService', () => { const dbConnection = getMockDBConnection(); const service = new AttachmentService(dbConnection); - const data = [({ id: 1 } as unknown) as ISurveyReportAttachmentAuthor]; + const data = [{ id: 1 } as unknown as ISurveyReportAttachmentAuthor]; const repoStub = sinon .stub(AttachmentRepository.prototype, 'getSurveyReportAttachmentAuthors') @@ -1059,7 +1057,7 @@ describe('AttachmentService', () => { 'string', 1, 1, - ({ title: 'string' } as unknown) as PostReportAttachmentMetadata, + { title: 'string' } as unknown as PostReportAttachmentMetadata, 'string' ); @@ -1077,9 +1075,9 @@ describe('AttachmentService', () => { const repoStub = sinon.stub(AttachmentRepository.prototype, 'updateSurveyReportAttachment').resolves(data); - const response = await service.updateSurveyReportAttachment('string', 1, ({ + const response = await service.updateSurveyReportAttachment('string', 1, { title: 'string' - } as unknown) as PutReportAttachmentMetadata); + } as unknown as PutReportAttachmentMetadata); expect(repoStub).to.be.calledOnce; expect(response).to.eql(data); @@ -1122,7 +1120,7 @@ describe('AttachmentService', () => { const dbConnection = getMockDBConnection(); const service = new AttachmentService(dbConnection); - const data = ({ id: 1 } as unknown) as QueryResult; + const data = { id: 1 } as unknown as QueryResult; const repoStub = sinon .stub(AttachmentRepository.prototype, 'getSurveyReportAttachmentByFileName') @@ -1142,7 +1140,7 @@ describe('AttachmentService', () => { const serviceStub1 = sinon .stub(AttachmentService.prototype, 'getSurveyReportAttachmentByFileName') - .resolves(({ rowCount: 1 } as unknown) as QueryResult); + .resolves({ rowCount: 1 } as unknown as QueryResult); const serviceStub2 = sinon .stub(AttachmentService.prototype, 'updateSurveyReportAttachment') @@ -1155,7 +1153,7 @@ describe('AttachmentService', () => { const serviceStub4 = sinon.stub(AttachmentService.prototype, 'insertSurveyReportAttachmentAuthor').resolves(); const response = await service.upsertSurveyReportAttachment( - ({ originalname: 'file.test' } as unknown) as Express.Multer.File, + { originalname: 'file.test' } as unknown as Express.Multer.File, 1, 1, { @@ -1181,7 +1179,7 @@ describe('AttachmentService', () => { const serviceStub1 = sinon .stub(AttachmentService.prototype, 'getSurveyReportAttachmentByFileName') - .resolves(({ rowCount: 0 } as unknown) as QueryResult); + .resolves({ rowCount: 0 } as unknown as QueryResult); const serviceStub2 = sinon .stub(AttachmentService.prototype, 'insertSurveyReportAttachment') @@ -1194,7 +1192,7 @@ describe('AttachmentService', () => { const serviceStub4 = sinon.stub(AttachmentService.prototype, 'insertSurveyReportAttachmentAuthor').resolves(); const response = await service.upsertSurveyReportAttachment( - ({ originalname: 'file.test' } as unknown) as Express.Multer.File, + { originalname: 'file.test' } as unknown as Express.Multer.File, 1, 1, { @@ -1258,9 +1256,9 @@ describe('AttachmentService', () => { .stub(AttachmentRepository.prototype, 'updateSurveyReportAttachmentMetadata') .resolves(); - const response = await service.updateSurveyReportAttachmentMetadata(1, 1, ({ + const response = await service.updateSurveyReportAttachmentMetadata(1, 1, { title: 'string' - } as unknown) as PutReportAttachmentMetadata); + } as unknown as PutReportAttachmentMetadata); expect(repoStub).to.be.calledOnce; expect(response).to.eql(undefined); diff --git a/api/src/services/authorization-service.test.ts b/api/src/services/authorization-service.test.ts index e613dee811..3c92c25a14 100644 --- a/api/src/services/authorization-service.test.ts +++ b/api/src/services/authorization-service.test.ts @@ -29,7 +29,7 @@ describe('AuthorizationService', () => { }); it('returns false if any AND authorizationScheme rules return false', async function () { - const mockAuthorizationScheme = ({ and: [] } as unknown) as AuthorizationScheme; + const mockAuthorizationScheme = { and: [] } as unknown as AuthorizationScheme; const mockDBConnection = getMockDBConnection(); sinon.stub(AuthorizationService.prototype, 'executeAuthorizeConfig').resolves([true, false, true]); @@ -42,7 +42,7 @@ describe('AuthorizationService', () => { }); it('returns true if all AND authorizationScheme rules return true', async function () { - const mockAuthorizationScheme = ({ and: [] } as unknown) as AuthorizationScheme; + const mockAuthorizationScheme = { and: [] } as unknown as AuthorizationScheme; const mockDBConnection = getMockDBConnection(); sinon.stub(AuthorizationService.prototype, 'executeAuthorizeConfig').resolves([true, true, true]); @@ -55,7 +55,7 @@ describe('AuthorizationService', () => { }); it('returns false if all OR authorizationScheme rules return false', async function () { - const mockAuthorizationScheme = ({ or: [] } as unknown) as AuthorizationScheme; + const mockAuthorizationScheme = { or: [] } as unknown as AuthorizationScheme; const mockDBConnection = getMockDBConnection(); sinon.stub(AuthorizationService.prototype, 'executeAuthorizeConfig').resolves([false, false, false]); @@ -68,7 +68,7 @@ describe('AuthorizationService', () => { }); it('returns true if any OR authorizationScheme rules return true', async function () { - const mockAuthorizationScheme = ({ or: [] } as unknown) as AuthorizationScheme; + const mockAuthorizationScheme = { or: [] } as unknown as AuthorizationScheme; const mockDBConnection = getMockDBConnection(); sinon.stub(AuthorizationService.prototype, 'executeAuthorizeConfig').resolves([false, true, false]); @@ -140,9 +140,9 @@ describe('AuthorizationService', () => { it('returns true if `systemUserObject` is not null and includes admin role', async function () { const mockDBConnection = getMockDBConnection(); - const mockGetSystemUsersObjectResponse = ({ + const mockGetSystemUsersObjectResponse = { role_names: [SYSTEM_ROLE.SYSTEM_ADMIN] - } as unknown) as SystemUser; + } as unknown as SystemUser; sinon.stub(AuthorizationService.prototype, 'getSystemUserObject').resolves(mockGetSystemUsersObjectResponse); @@ -160,7 +160,7 @@ describe('AuthorizationService', () => { }); it('returns false if `authorizeSystemRoles` is null', async function () { - const mockAuthorizeSystemRoles = (null as unknown) as AuthorizeBySystemRoles; + const mockAuthorizeSystemRoles = null as unknown as AuthorizeBySystemRoles; const mockDBConnection = getMockDBConnection(); const authorizationService = new AuthorizationService(mockDBConnection); @@ -177,7 +177,7 @@ describe('AuthorizationService', () => { }; const mockDBConnection = getMockDBConnection(); - const mockGetSystemUsersObjectResponse = (null as unknown) as SystemUser; + const mockGetSystemUsersObjectResponse = null as unknown as SystemUser; sinon.stub(AuthorizationService.prototype, 'getSystemUserObject').resolves(mockGetSystemUsersObjectResponse); const authorizationService = new AuthorizationService(mockDBConnection); @@ -194,7 +194,7 @@ describe('AuthorizationService', () => { }; const mockDBConnection = getMockDBConnection(); - const mockGetSystemUsersObjectResponse = ({ record_end_date: 'datetime' } as unknown) as SystemUser; + const mockGetSystemUsersObjectResponse = { record_end_date: 'datetime' } as unknown as SystemUser; sinon.stub(AuthorizationService.prototype, 'getSystemUserObject').resolves(mockGetSystemUsersObjectResponse); const authorizationService = new AuthorizationService(mockDBConnection); @@ -212,7 +212,7 @@ describe('AuthorizationService', () => { const mockDBConnection = getMockDBConnection(); const authorizationService = new AuthorizationService(mockDBConnection, { - systemUser: ({} as unknown) as SystemUser + systemUser: {} as unknown as SystemUser }); const isAuthorizedBySystemRole = await authorizationService.authorizeBySystemRole(mockAuthorizeSystemRoles); @@ -228,7 +228,7 @@ describe('AuthorizationService', () => { const mockDBConnection = getMockDBConnection(); const authorizationService = new AuthorizationService(mockDBConnection, { - systemUser: ({ role_names: [] } as unknown) as SystemUser + systemUser: { role_names: [] } as unknown as SystemUser }); const isAuthorizedBySystemRole = await authorizationService.authorizeBySystemRole(mockAuthorizeSystemRoles); @@ -244,7 +244,7 @@ describe('AuthorizationService', () => { const mockDBConnection = getMockDBConnection(); const authorizationService = new AuthorizationService(mockDBConnection, { - systemUser: ({ role_names: [SYSTEM_ROLE.SYSTEM_ADMIN] } as unknown) as SystemUser + systemUser: { role_names: [SYSTEM_ROLE.SYSTEM_ADMIN] } as unknown as SystemUser }); const isAuthorizedBySystemRole = await authorizationService.authorizeBySystemRole(mockAuthorizeSystemRoles); @@ -261,7 +261,7 @@ describe('AuthorizationService', () => { it('returns false if `systemUserObject` is null', async function () { const mockDBConnection = getMockDBConnection(); - const mockGetSystemUsersObjectResponse = (null as unknown) as SystemUser; + const mockGetSystemUsersObjectResponse = null as unknown as SystemUser; sinon.stub(AuthorizationService.prototype, 'getSystemUserObject').resolves(mockGetSystemUsersObjectResponse); const authorizationService = new AuthorizationService(mockDBConnection); @@ -274,11 +274,11 @@ describe('AuthorizationService', () => { it('returns true if `systemUserObject` is not null', async function () { const mockDBConnection = getMockDBConnection(); - const mockGetSystemUsersObjectResponse = (null as unknown) as SystemUser; + const mockGetSystemUsersObjectResponse = null as unknown as SystemUser; sinon.stub(AuthorizationService.prototype, 'getSystemUserObject').resolves(mockGetSystemUsersObjectResponse); const authorizationService = new AuthorizationService(mockDBConnection, { - systemUser: ({} as unknown) as SystemUser + systemUser: {} as unknown as SystemUser }); const isAuthorizedBySystemRole = await authorizationService.authorizeBySystemUser(); @@ -298,10 +298,10 @@ describe('AuthorizationService', () => { const authorizationService = new AuthorizationService(mockDBConnection); - const authorizeByServiceClientData = ({ + const authorizeByServiceClientData = { validServiceClientIDs: SOURCE_SYSTEM['SIMS-SVC-4464'], discriminator: 'ServiceClient' - } as unknown) as AuthorizeByServiceClient; + } as unknown as AuthorizeByServiceClient; const result = await authorizationService.authorizeByServiceClient(authorizeByServiceClientData); @@ -316,10 +316,10 @@ describe('AuthorizationService', () => { keycloakToken: { preferred_username: '' } as KeycloakUserInformation }); - const authorizeByServiceClientData = ({ + const authorizeByServiceClientData = { validServiceClientIDs: SOURCE_SYSTEM['SIMS-SVC-4464'], discriminator: 'ServiceClient' - } as unknown) as AuthorizeByServiceClient; + } as unknown as AuthorizeByServiceClient; const result = await authorizationService.authorizeByServiceClient(authorizeByServiceClientData); @@ -331,10 +331,10 @@ describe('AuthorizationService', () => { const authorizationService = new AuthorizationService(mockDBConnection); - const authorizeByServiceClientData = ({ + const authorizeByServiceClientData = { validServiceClientIDs: SOURCE_SYSTEM['SIMS-SVC-4464'], discriminator: 'ServiceClient' - } as unknown) as AuthorizeByServiceClient; + } as unknown as AuthorizeByServiceClient; const isAuthorizedBySystemRole = await authorizationService.authorizeByServiceClient( authorizeByServiceClientData @@ -346,17 +346,17 @@ describe('AuthorizationService', () => { it('returns true if `systemUserObject` hasAtLeastOneValidValue', async function () { const mockDBConnection = getMockDBConnection(); - const mockGetSystemUsersObjectResponse = (null as unknown) as SystemUser; + const mockGetSystemUsersObjectResponse = null as unknown as SystemUser; sinon.stub(AuthorizationService.prototype, 'getSystemUserObject').resolves(mockGetSystemUsersObjectResponse); const authorizationService = new AuthorizationService(mockDBConnection, { keycloakToken: { clientId: SOURCE_SYSTEM['SIMS-SVC-4464'] } as ServiceClientUserInformation }); - const authorizeByServiceClientData = ({ + const authorizeByServiceClientData = { validServiceClientIDs: SOURCE_SYSTEM['SIMS-SVC-4464'], discriminator: 'ServiceClient' - } as unknown) as AuthorizeByServiceClient; + } as unknown as AuthorizeByServiceClient; const isAuthorizedBySystemRole = await authorizationService.authorizeByServiceClient( authorizeByServiceClientData @@ -373,7 +373,7 @@ describe('AuthorizationService', () => { }); it('returns false if `authorizeProjectPermission` is null', async function () { - const mockAuthorizeProjectPermission = (null as unknown) as AuthorizeByProjectPermission; + const mockAuthorizeProjectPermission = null as unknown as AuthorizeByProjectPermission; const mockDBConnection = getMockDBConnection(); const authorizationService = new AuthorizationService(mockDBConnection); @@ -393,7 +393,7 @@ describe('AuthorizationService', () => { }; const mockDBConnection = getMockDBConnection(); - const mockGetSystemUsersObjectResponse = (null as unknown) as ProjectUser & SystemUser; + const mockGetSystemUsersObjectResponse = null as unknown as ProjectUser & SystemUser; sinon .stub(AuthorizationService.prototype, 'getProjectUserObjectByProjectId') .resolves(mockGetSystemUsersObjectResponse); @@ -415,8 +415,7 @@ describe('AuthorizationService', () => { }; const mockDBConnection = getMockDBConnection(); - const mockGetSystemUsersObjectResponse = ({ record_end_date: 'datetime' } as unknown) as ProjectUser & - SystemUser; + const mockGetSystemUsersObjectResponse = { record_end_date: 'datetime' } as unknown as ProjectUser & SystemUser; sinon .stub(AuthorizationService.prototype, 'getProjectUserObjectByProjectId') .resolves(mockGetSystemUsersObjectResponse); @@ -439,7 +438,7 @@ describe('AuthorizationService', () => { const mockDBConnection = getMockDBConnection(); const authorizationService = new AuthorizationService(mockDBConnection, { - projectUser: ({ project_id: 1 } as unknown) as ProjectUser & SystemUser + projectUser: { project_id: 1 } as unknown as ProjectUser & SystemUser }); const isAuthorizedByProjectPermission = await authorizationService.authorizeByProjectPermission( @@ -458,7 +457,7 @@ describe('AuthorizationService', () => { const mockDBConnection = getMockDBConnection(); const authorizationService = new AuthorizationService(mockDBConnection, { - projectUser: ({ project_id: 1, project_role_permissions: [] } as unknown) as ProjectUser & SystemUser + projectUser: { project_id: 1, project_role_permissions: [] } as unknown as ProjectUser & SystemUser }); const isAuthorizedByProjectPermission = await authorizationService.authorizeByProjectPermission( @@ -477,10 +476,10 @@ describe('AuthorizationService', () => { const mockDBConnection = getMockDBConnection(); const authorizationService = new AuthorizationService(mockDBConnection, { - projectUser: ({ + projectUser: { project_id: 1, project_role_permissions: [PROJECT_PERMISSION.COORDINATOR] - } as unknown) as ProjectUser & SystemUser + } as unknown as ProjectUser & SystemUser }); const isAuthorizedByProjectPermission = await authorizationService.authorizeByProjectPermission( @@ -497,7 +496,7 @@ describe('AuthorizationService', () => { }); it('returns false if `authorizeProjectPermission` is null', async function () { - const mockAuthorizeProjectPermission = (null as unknown) as AuthorizeByProjectPermission; + const mockAuthorizeProjectPermission = null as unknown as AuthorizeByProjectPermission; const mockDBConnection = getMockDBConnection(); const authorizationService = new AuthorizationService(mockDBConnection); @@ -517,7 +516,7 @@ describe('AuthorizationService', () => { }; const mockDBConnection = getMockDBConnection(); - const mockGetSystemUsersObjectResponse = (null as unknown) as ProjectUser & SystemUser; + const mockGetSystemUsersObjectResponse = null as unknown as ProjectUser & SystemUser; sinon .stub(AuthorizationService.prototype, 'getProjectUserObjectByProjectId') .resolves(mockGetSystemUsersObjectResponse); @@ -539,8 +538,7 @@ describe('AuthorizationService', () => { }; const mockDBConnection = getMockDBConnection(); - const mockGetSystemUsersObjectResponse = ({ record_end_date: 'datetime' } as unknown) as ProjectUser & - SystemUser; + const mockGetSystemUsersObjectResponse = { record_end_date: 'datetime' } as unknown as ProjectUser & SystemUser; sinon .stub(AuthorizationService.prototype, 'getProjectUserObjectByProjectId') .resolves(mockGetSystemUsersObjectResponse); @@ -563,7 +561,7 @@ describe('AuthorizationService', () => { const mockDBConnection = getMockDBConnection(); const authorizationService = new AuthorizationService(mockDBConnection, { - projectUser: ({ project_id: 1 } as unknown) as ProjectUser & SystemUser + projectUser: { project_id: 1 } as unknown as ProjectUser & SystemUser }); const isAuthorizedByProjectPermission = await authorizationService.authorizeByProjectPermission( @@ -582,7 +580,7 @@ describe('AuthorizationService', () => { const mockDBConnection = getMockDBConnection(); const authorizationService = new AuthorizationService(mockDBConnection, { - projectUser: ({ project_id: 1, project_role_permissions: [] } as unknown) as ProjectUser & SystemUser + projectUser: { project_id: 1, project_role_permissions: [] } as unknown as ProjectUser & SystemUser }); const isAuthorizedByProjectPermission = await authorizationService.authorizeByProjectPermission( @@ -601,10 +599,10 @@ describe('AuthorizationService', () => { const mockDBConnection = getMockDBConnection(); const authorizationService = new AuthorizationService(mockDBConnection, { - projectUser: ({ + projectUser: { project_id: 1, project_role_permissions: [PROJECT_PERMISSION.COORDINATOR] - } as unknown) as ProjectUser & SystemUser + } as unknown as ProjectUser & SystemUser }); const isAuthorizedByProjectPermission = await authorizationService.authorizeByProjectPermission( @@ -822,7 +820,7 @@ describe('AuthorizationService', () => { agency: null }; - sinon.stub(UserService.prototype, 'getUserByGuid').resolves((userObjectMock as unknown) as any); + sinon.stub(UserService.prototype, 'getUserByGuid').resolves(userObjectMock as unknown as any); const authorizationService = new AuthorizationService(mockDBConnection, { keycloakToken: { @@ -974,7 +972,7 @@ describe('AuthorizationService', () => { }; sinon .stub(ProjectParticipationService.prototype, 'getProjectParticipantByProjectIdAndUserGuid') - .resolves((projectUserMock as unknown) as any); + .resolves(projectUserMock as unknown as any); const authorizationService = new AuthorizationService(mockDBConnection, { keycloakToken: { @@ -1128,7 +1126,7 @@ describe('AuthorizationService', () => { }; sinon .stub(ProjectParticipationService.prototype, 'getProjectParticipantBySurveyIdAndUserGuid') - .resolves((projectUserMock as unknown) as any); + .resolves(projectUserMock as unknown as any); const authorizationService = new AuthorizationService(mockDBConnection, { keycloakToken: { diff --git a/api/src/services/bctw-service.test.ts b/api/src/services/bctw-service.test.ts index 8084b517a9..7b98bd404b 100755 --- a/api/src/services/bctw-service.test.ts +++ b/api/src/services/bctw-service.test.ts @@ -221,7 +221,7 @@ describe('BctwService', () => { describe('uploadKeyX', () => { it('should send a post request', async () => { const mockAxios = sinon.stub(bctwService.axiosInstance, 'post').resolves({ data: { results: [], errors: [] } }); - const mockMulterFile = ({ buffer: 'buffer', originalname: 'originalname' } as unknown) as Express.Multer.File; + const mockMulterFile = { buffer: 'buffer', originalname: 'originalname' } as unknown as Express.Multer.File; sinon.stub(FormData.prototype, 'append'); const mockGetFormDataHeaders = sinon .stub(FormData.prototype, 'getHeaders') @@ -236,7 +236,7 @@ describe('BctwService', () => { it('should throw an error if the response body has errors', async () => { sinon.stub(bctwService.axiosInstance, 'post').resolves({ data: { results: [], errors: [{ error: 'error' }] } }); - const mockMulterFile = ({ buffer: 'buffer', originalname: 'originalname' } as unknown) as Express.Multer.File; + const mockMulterFile = { buffer: 'buffer', originalname: 'originalname' } as unknown as Express.Multer.File; sinon.stub(FormData.prototype, 'append'); sinon.stub(FormData.prototype, 'getHeaders').resolves({ 'content-type': 'multipart/form-data' }); diff --git a/api/src/services/critterbase-service.ts b/api/src/services/critterbase-service.ts index 33c356be75..d5d079bb29 100644 --- a/api/src/services/critterbase-service.ts +++ b/api/src/services/critterbase-service.ts @@ -333,9 +333,7 @@ export class CritterbaseService { return this._makeGetRequest(CbRoutes[route], params); } - async getTaxonMeasurements( - tsn: string - ): Promise<{ + async getTaxonMeasurements(tsn: string): Promise<{ qualitative: CBQualitativeMeasurementTypeDefinition[]; quantitative: CBQuantitativeMeasurementTypeDefinition[]; }> { diff --git a/api/src/services/eml-service.test.ts b/api/src/services/eml-service.test.ts index 3b57947fe5..95d48a87f4 100644 --- a/api/src/services/eml-service.test.ts +++ b/api/src/services/eml-service.test.ts @@ -210,7 +210,7 @@ describe('EmlPackage', () => { } ]; - const emlPackage = new EmlPackage({ packageId: (null as unknown) as string }); + const emlPackage = new EmlPackage({ packageId: null as unknown as string }); const response = emlPackage.withAdditionalMetadata(additionalMeta1).withAdditionalMetadata(additionalMeta2); @@ -226,7 +226,7 @@ describe('EmlPackage', () => { title: 'Project Name 1' }; - const emlPackage = new EmlPackage({ packageId: (null as unknown) as string }); + const emlPackage = new EmlPackage({ packageId: null as unknown as string }); const response = emlPackage.withRelatedProjects([project]); @@ -250,7 +250,7 @@ describe('EmlPackage', () => { title: 'Project Name 2' }; - const emlPackage = new EmlPackage({ packageId: (null as unknown) as string }); + const emlPackage = new EmlPackage({ packageId: null as unknown as string }); const response = emlPackage.withRelatedProjects([project1]).withRelatedProjects([project2]); diff --git a/api/src/services/funding-source-service.ts b/api/src/services/funding-source-service.ts index da2516910d..d07f049faf 100644 --- a/api/src/services/funding-source-service.ts +++ b/api/src/services/funding-source-service.ts @@ -59,9 +59,7 @@ export class FundingSourceService extends DBService { * }>)} * @memberof FundingSourceService */ - async getFundingSource( - fundingSourceId: number - ): Promise<{ + async getFundingSource(fundingSourceId: number): Promise<{ funding_source: FundingSource & FundingSourceSupplementaryData; funding_source_survey_references: (SurveyFundingSource | SurveyFundingSourceSupplementaryData)[]; }> { diff --git a/api/src/services/history-publish-service.test.ts b/api/src/services/history-publish-service.test.ts index 007e58bd56..0deb6874a4 100644 --- a/api/src/services/history-publish-service.test.ts +++ b/api/src/services/history-publish-service.test.ts @@ -84,7 +84,7 @@ describe('HistoryPublishService', () => { const dbConnection = getMockDBConnection(); const service = new HistoryPublishService(dbConnection); - const mockResponse = ({ survey_metadata_publish_id: 1 } as unknown) as SurveyMetadataPublish; + const mockResponse = { survey_metadata_publish_id: 1 } as unknown as SurveyMetadataPublish; const repositoryStub = sinon .stub(HistoryPublishRepository.prototype, 'getSurveyMetadataPublishRecord') .resolves(mockResponse); @@ -102,7 +102,7 @@ describe('HistoryPublishService', () => { const dbConnection = getMockDBConnection(); const service = new HistoryPublishService(dbConnection); - const mockResponse = ({ survey_attachment_publish_id: 1 } as unknown) as SurveyAttachmentPublish; + const mockResponse = { survey_attachment_publish_id: 1 } as unknown as SurveyAttachmentPublish; const repositoryStub = sinon .stub(HistoryPublishRepository.prototype, 'getSurveyAttachmentPublishRecord') .resolves(mockResponse); @@ -120,7 +120,7 @@ describe('HistoryPublishService', () => { const dbConnection = getMockDBConnection(); const service = new HistoryPublishService(dbConnection); - const mockResponse = ({ survey_report_publish_id: 1 } as unknown) as SurveyReportPublish; + const mockResponse = { survey_report_publish_id: 1 } as unknown as SurveyReportPublish; const repositoryStub = sinon .stub(HistoryPublishRepository.prototype, 'getSurveyReportPublishRecord') .resolves(mockResponse); diff --git a/api/src/services/history-publish-service.ts b/api/src/services/history-publish-service.ts index 87e3d849ff..4b88427ad2 100644 --- a/api/src/services/history-publish-service.ts +++ b/api/src/services/history-publish-service.ts @@ -36,9 +36,7 @@ export class HistoryPublishService extends DBService { * @returns {*} {Promise} * @memberof HistoryPublishRepository */ - async insertSurveyAttachmentPublishRecord( - data: ISurveyAttachmentPublish - ): Promise<{ + async insertSurveyAttachmentPublishRecord(data: ISurveyAttachmentPublish): Promise<{ survey_attachment_publish_id: number; }> { return this.historyRepository.insertSurveyAttachmentPublishRecord(data); diff --git a/api/src/services/observation-service.ts b/api/src/services/observation-service.ts index ac9c11b52a..d6edc57d0f 100644 --- a/api/src/services/observation-service.ts +++ b/api/src/services/observation-service.ts @@ -193,13 +193,12 @@ export class ObservationService extends DBService { } if (subcount.quantitative.length) { - const quantitativeData: InsertObservationSubCountQuantitativeMeasurementRecord[] = subcount.quantitative.map( - (item) => ({ + const quantitativeData: InsertObservationSubCountQuantitativeMeasurementRecord[] = + subcount.quantitative.map((item) => ({ observation_subcount_id: observationSubCountRecord.observation_subcount_id, critterbase_taxon_measurement_id: item.measurement_id, value: item.measurement_value - }) - ); + })); await measurementService.insertObservationSubCountQuantitativeMeasurement(quantitativeData); } } @@ -279,9 +278,7 @@ export class ObservationService extends DBService { * }>} * @memberof ObservationService */ - async getSurveyObservationsGeometryWithSupplementaryData( - surveyId: number - ): Promise<{ + async getSurveyObservationsGeometryWithSupplementaryData(surveyId: number): Promise<{ surveyObservationsGeometry: ObservationGeometryRecord[]; supplementaryObservationData: ObservationCountSupplementaryData; }> { diff --git a/api/src/services/platform-service.test.ts b/api/src/services/platform-service.test.ts index e4433fdc7d..692bf69d76 100644 --- a/api/src/services/platform-service.test.ts +++ b/api/src/services/platform-service.test.ts @@ -55,7 +55,7 @@ describe('PlatformService', () => { const _generateSurveyDataPackageStub = sinon .stub(PlatformService.prototype, '_generateSurveyDataPackage') - .resolves(({ id: '123-456-789' } as unknown) as any); + .resolves({ id: '123-456-789' } as unknown as any); sinon.stub(axios, 'post').resolves({}); @@ -85,7 +85,7 @@ describe('PlatformService', () => { const _generateSurveyDataPackageStub = sinon .stub(PlatformService.prototype, '_generateSurveyDataPackage') - .resolves(({ id: '123-456-789' } as unknown) as any); + .resolves({ id: '123-456-789' } as unknown as any); sinon.stub(axios, 'post').resolves({ data: { submission_uuid: '123-456-789', artifact_upload_keys: [] } }); @@ -132,7 +132,7 @@ describe('PlatformService', () => { const getAllSurveyObservationsStub = sinon .stub(ObservationService.prototype, 'getAllSurveyObservations') - .resolves([({ survey_observation_id: 2 } as unknown) as ObservationRecord]); + .resolves([{ survey_observation_id: 2 } as unknown as ObservationRecord]); const getSurveyLocationsDataStub = sinon .stub(SurveyService.prototype, 'getSurveyLocationsData') diff --git a/api/src/services/project-service.test.ts b/api/src/services/project-service.test.ts index be6973b122..8c46a63625 100644 --- a/api/src/services/project-service.test.ts +++ b/api/src/services/project-service.test.ts @@ -72,7 +72,7 @@ describe('ProjectService', () => { const dbConnection = getMockDBConnection(); const service = new ProjectService(dbConnection); - const data = ({ project_id: 1 } as unknown) as ProjectData; + const data = { project_id: 1 } as unknown as ProjectData; const repoStub = sinon.stub(ProjectRepository.prototype, 'getProjectData').resolves(data); diff --git a/api/src/services/subcount-service.ts b/api/src/services/subcount-service.ts index f333d5e235..c7d2df4595 100644 --- a/api/src/services/subcount-service.ts +++ b/api/src/services/subcount-service.ts @@ -78,9 +78,7 @@ export class SubCountService extends DBService { * }>} * @memberof SubCountService */ - async getMeasurementTypeDefinitionsForSurvey( - surveyId: number - ): Promise<{ + async getMeasurementTypeDefinitionsForSurvey(surveyId: number): Promise<{ qualitative_measurements: CBQualitativeMeasurementTypeDefinition[]; quantitative_measurements: CBQuantitativeMeasurementTypeDefinition[]; }> { diff --git a/api/src/services/survey-block-service.test.ts b/api/src/services/survey-block-service.test.ts index 5bbc08d7e8..75aec62a92 100644 --- a/api/src/services/survey-block-service.test.ts +++ b/api/src/services/survey-block-service.test.ts @@ -17,7 +17,7 @@ describe('SurveyBlockService', () => { describe('getSurveyBlocksForSurveyId', () => { it('should succeed with valid data', async () => { - const mockResponse = ({ + const mockResponse = { rows: [ { survey_block_id: 1, @@ -33,7 +33,7 @@ describe('SurveyBlockService', () => { } ], rowCount: 1 - } as any) as Promise>; + } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); @@ -47,10 +47,10 @@ describe('SurveyBlockService', () => { }); it('should succeed with empty data', async () => { - const mockResponse = ({ + const mockResponse = { rows: [], rowCount: 0 - } as any) as Promise>; + } as any as Promise>; const dbConnection = getMockDBConnection({ sql: () => mockResponse }); diff --git a/api/src/services/survey-service.test.ts b/api/src/services/survey-service.test.ts index 999437ac12..71a5c69869 100644 --- a/api/src/services/survey-service.test.ts +++ b/api/src/services/survey-service.test.ts @@ -56,22 +56,22 @@ describe('SurveyService', () => { const getSurveyDataStub = sinon .stub(SurveyService.prototype, 'getSurveyData') - .resolves(({ data: 'surveyData' } as unknown) as any); + .resolves({ data: 'surveyData' } as unknown as any); const getSpeciesDataStub = sinon .stub(SurveyService.prototype, 'getSpeciesData') - .resolves(({ data: 'speciesData' } as unknown) as any); + .resolves({ data: 'speciesData' } as unknown as any); const getPermitDataStub = sinon .stub(SurveyService.prototype, 'getPermitData') - .resolves(({ data: 'permitData' } as unknown) as any); + .resolves({ data: 'permitData' } as unknown as any); const getSurveyFundingSourceDataStub = sinon .stub(SurveyService.prototype, 'getSurveyFundingSourceData') - .resolves(({ data: 'fundingSourceData' } as unknown) as any); + .resolves({ data: 'fundingSourceData' } as unknown as any); const getSurveyPurposeAndMethodologyStub = sinon .stub(SurveyService.prototype, 'getSurveyPurposeAndMethodology') - .resolves(({ data: 'purposeAndMethodologyData' } as unknown) as any); + .resolves({ data: 'purposeAndMethodologyData' } as unknown as any); const getSurveyProprietorDataForViewStub = sinon .stub(SurveyService.prototype, 'getSurveyProprietorDataForView') - .resolves(({ data: 'proprietorData' } as unknown) as any); + .resolves({ data: 'proprietorData' } as unknown as any); const getSurveyLocationsDataStub = sinon.stub(SurveyService.prototype, 'getSurveyLocationsData').resolves([]); const getSurveyParticipantsStub = sinon .stub(SurveyParticipationService.prototype, 'getSurveyParticipants') @@ -255,7 +255,7 @@ describe('SurveyService', () => { const dbConnection = getMockDBConnection(); const service = new SurveyService(dbConnection); - const data = ({ id: 1 } as unknown) as ISurveyProprietorModel; + const data = { id: 1 } as unknown as ISurveyProprietorModel; const repoStub = sinon .stub(SurveyRepository.prototype, 'getSurveyProprietorDataForSecurityRequest') @@ -326,7 +326,7 @@ describe('SurveyService', () => { it('fetches and returns all supplementary data', async () => { const getSurveyMetadataPublishRecordStub = sinon .stub(HistoryPublishService.prototype, 'getSurveyMetadataPublishRecord') - .resolves(({ survey_metadata_publish_id: 5 } as unknown) as any); + .resolves({ survey_metadata_publish_id: 5 } as unknown as any); const surveyService = new SurveyService(getMockDBConnection()); @@ -345,11 +345,11 @@ describe('SurveyService', () => { const dbConnection = getMockDBConnection(); const service = new SurveyService(dbConnection); - const mockSurveyData = ({ survey_id: 1 } as unknown) as SurveyRecord; - const mockSurveyTypesData = ([ + const mockSurveyData = { survey_id: 1 } as unknown as SurveyRecord; + const mockSurveyTypesData = [ { survey_id: 1, type_id: 2 }, { survey_id: 1, type_id: 3 } - ] as unknown) as SurveyTypeRecord[]; + ] as unknown as SurveyTypeRecord[]; const getSurveyDataStub = sinon.stub(SurveyRepository.prototype, 'getSurveyData').resolves(mockSurveyData); const getSurveyTypesDataStub = sinon @@ -371,7 +371,7 @@ describe('SurveyService', () => { const dbConnection = getMockDBConnection(); const service = new SurveyService(dbConnection); - const data = ({ id: 1 } as unknown) as IGetSpeciesData; + const data = { id: 1 } as unknown as IGetSpeciesData; const repoStub = sinon.stub(SurveyRepository.prototype, 'getSpeciesData').resolves([data]); const getTaxonomyByTsnsStub = sinon.stub(PlatformService.prototype, 'getTaxonomyByTsns').resolves([]); @@ -458,7 +458,7 @@ describe('SurveyService', () => { const dbConnection = getMockDBConnection(); const service = new SurveyService(dbConnection); - const data = ([{ survey_location_id: 1 }] as any) as SurveyLocationRecord[]; + const data = [{ survey_location_id: 1 }] as any as SurveyLocationRecord[]; const repoStub = sinon.stub(SurveyLocationRepository.prototype, 'getSurveyLocationsData').resolves(data); @@ -474,7 +474,7 @@ describe('SurveyService', () => { const dbConnection = getMockDBConnection(); const service = new SurveyService(dbConnection); - const data = ({ id: 1 } as unknown) as SurveyObject; + const data = { id: 1 } as unknown as SurveyObject; const repoStub = sinon.stub(SurveyService.prototype, 'getSurveyById').resolves(data); @@ -495,7 +495,7 @@ describe('SurveyService', () => { const repoStub = sinon.stub(SurveyService.prototype, 'getSurveyIdsByProjectId').resolves([data]); const surveyStub = sinon .stub(SurveyService.prototype, 'getSurveysByIds') - .resolves([(data as unknown) as SurveyObject]); + .resolves([data as unknown as SurveyObject]); const response = await service.getSurveysByProjectId(1); expect(repoStub).to.be.calledOnce; @@ -509,7 +509,7 @@ describe('SurveyService', () => { const dbConnection = getMockDBConnection(); const service = new SurveyService(dbConnection); - const data = ({ id: 1 } as unknown) as GetAttachmentsData; + const data = { id: 1 } as unknown as GetAttachmentsData; const repoStub = sinon.stub(SurveyRepository.prototype, 'getAttachmentsData').resolves(data); @@ -525,7 +525,7 @@ describe('SurveyService', () => { const dbConnection = getMockDBConnection(); const service = new SurveyService(dbConnection); - const data = ({ id: 1 } as unknown) as GetReportAttachmentsData; + const data = { id: 1 } as unknown as GetReportAttachmentsData; const repoStub = sinon.stub(SurveyRepository.prototype, 'getReportAttachmentsData').resolves(data); @@ -545,7 +545,7 @@ describe('SurveyService', () => { const repoStub = sinon.stub(SurveyRepository.prototype, 'insertSurveyData').resolves(data); - const response = await service.insertSurveyData(1, ({ id: 1 } as unknown) as PostSurveyObject); + const response = await service.insertSurveyData(1, { id: 1 } as unknown as PostSurveyObject); expect(repoStub).to.be.calledOnce; expect(response).to.eql(data); @@ -626,7 +626,7 @@ describe('SurveyService', () => { const repoStub = sinon.stub(SurveyRepository.prototype, 'insertSurveyProprietor').resolves(data); - const response = await service.insertSurveyProprietor(({ id: 1 } as unknown) as PostProprietorData, 1); + const response = await service.insertSurveyProprietor({ id: 1 } as unknown as PostProprietorData, 1); expect(repoStub).to.be.calledOnce; expect(response).to.eql(data); @@ -673,11 +673,11 @@ describe('SurveyService', () => { }); it('throws api error if response is null', async () => { - const mockDBConnection = getMockDBConnection({ knex: async () => (undefined as unknown) as any }); + const mockDBConnection = getMockDBConnection({ knex: async () => undefined as unknown as any }); const surveyService = new SurveyService(mockDBConnection); try { - await surveyService.updateSurveyDetailsData(1, ({ survey_details: 'details' } as unknown) as PutSurveyObject); + await surveyService.updateSurveyDetailsData(1, { survey_details: 'details' } as unknown as PutSurveyObject); expect.fail(); } catch (actualError) { expect((actualError as ApiGeneralError).message).to.equal('Failed to update survey data'); @@ -685,14 +685,14 @@ describe('SurveyService', () => { }); it('returns data if response is not null', async () => { - const mockQueryResponse = ({ response: 'something', rowCount: 1 } as unknown) as QueryResult; + const mockQueryResponse = { response: 'something', rowCount: 1 } as unknown as QueryResult; const mockDBConnection = getMockDBConnection({ knex: async () => mockQueryResponse }); const surveyService = new SurveyService(mockDBConnection); - const response = await surveyService.updateSurveyDetailsData(1, ({ + const response = await surveyService.updateSurveyDetailsData(1, { survey_details: 'details' - } as unknown) as PutSurveyObject); + } as unknown as PutSurveyObject); expect(response).to.eql(undefined); }); @@ -730,15 +730,15 @@ describe('SurveyService', () => { sinon.stub(SurveyService.prototype, 'insertFocalSpecies').resolves(1); sinon.stub(SurveyService.prototype, 'insertAncillarySpecies').resolves(1); - const mockQueryResponse = ({ response: 'something', rowCount: 1 } as unknown) as QueryResult; + const mockQueryResponse = { response: 'something', rowCount: 1 } as unknown as QueryResult; const mockDBConnection = getMockDBConnection({ knex: async () => mockQueryResponse }); const surveyService = new SurveyService(mockDBConnection); - const response = await surveyService.updateSurveySpeciesData(1, ({ + const response = await surveyService.updateSurveySpeciesData(1, { survey_details: 'details', species: { focal_species: [1], ancillary_species: [1] } - } as unknown) as PutSurveyObject); + } as unknown as PutSurveyObject); expect(response).to.eql([1, 1]); }); @@ -873,9 +873,9 @@ describe('SurveyService', () => { const repoStub = sinon.stub(SurveyService.prototype, 'deleteSurveyProprietorData').resolves(); - const response = await service.updateSurveyProprietorData(1, ({ + const response = await service.updateSurveyProprietorData(1, { proprietor: { survey_data_proprietary: false } - } as unknown) as PutSurveyObject); + } as unknown as PutSurveyObject); expect(repoStub).to.be.calledOnce; expect(response).to.eql(undefined); @@ -888,9 +888,9 @@ describe('SurveyService', () => { const repoStub = sinon.stub(SurveyService.prototype, 'deleteSurveyProprietorData').resolves(); const serviceStub = sinon.stub(SurveyService.prototype, 'insertSurveyProprietor').resolves(); - const response = await service.updateSurveyProprietorData(1, ({ + const response = await service.updateSurveyProprietorData(1, { proprietor: { survey_data_proprietary: 'string' } - } as unknown) as PutSurveyObject); + } as unknown as PutSurveyObject); expect(repoStub).to.be.calledOnce; expect(serviceStub).to.be.calledOnce; @@ -920,15 +920,15 @@ describe('SurveyService', () => { it('returns [] if not vantage_code_ids is given', async () => { sinon.stub(SurveyService.prototype, 'deleteSurveyVantageCodes').resolves(); - const mockQueryResponse = (undefined as unknown) as QueryResult; + const mockQueryResponse = undefined as unknown as QueryResult; const mockDBConnection = getMockDBConnection({ sql: async () => mockQueryResponse }); const surveyService = new SurveyService(mockDBConnection); - const response = await surveyService.updateSurveyVantageCodesData(1, ({ + const response = await surveyService.updateSurveyVantageCodesData(1, { permit: { permit_number: '1', permit_type: 'type' }, purpose_and_methodology: { vantage_code_ids: undefined } - } as unknown) as PutSurveyObject); + } as unknown as PutSurveyObject); expect(response).to.eql([]); }); @@ -937,16 +937,16 @@ describe('SurveyService', () => { sinon.stub(SurveyService.prototype, 'deleteSurveyVantageCodes').resolves(); sinon.stub(SurveyService.prototype, 'insertVantageCodes').resolves(1); - const mockQueryResponse = (undefined as unknown) as QueryResult; + const mockQueryResponse = undefined as unknown as QueryResult; const mockDBConnection = getMockDBConnection({ sql: async () => mockQueryResponse }); const surveyService = new SurveyService(mockDBConnection); - const response = await surveyService.updateSurveyVantageCodesData(1, ({ + const response = await surveyService.updateSurveyVantageCodesData(1, { permit: { permit_number: '1', permit_type: 'type' }, proprietor: { survey_data_proprietary: 'asd' }, purpose_and_methodology: { vantage_code_ids: [1] } - } as unknown) as PutSurveyObject); + } as unknown as PutSurveyObject); expect(response).to.eql([1]); }); diff --git a/api/src/services/user-service.test.ts b/api/src/services/user-service.test.ts index a588d5527a..1933b94b94 100644 --- a/api/src/services/user-service.test.ts +++ b/api/src/services/user-service.test.ts @@ -21,7 +21,7 @@ describe('UserService', () => { const mockResponseRow = { system_user_id: 123 }; const mockUserRepository = sinon.stub(UserRepository.prototype, 'getUserById'); - mockUserRepository.resolves((mockResponseRow as unknown) as SystemUser); + mockUserRepository.resolves(mockResponseRow as unknown as SystemUser); const userService = new UserService(mockDBConnection); @@ -55,7 +55,7 @@ describe('UserService', () => { const mockResponseRow = [{ system_user_id: 123 }]; const mockUserRepository = sinon.stub(UserRepository.prototype, 'getUserByGuid'); - mockUserRepository.resolves((mockResponseRow as unknown) as SystemUser[]); + mockUserRepository.resolves(mockResponseRow as unknown as SystemUser[]); const userService = new UserService(mockDBConnection); @@ -89,7 +89,7 @@ describe('UserService', () => { const mockResponseRow = [{ system_user_id: 123 }]; const mockUserRepository = sinon.stub(UserRepository.prototype, 'getUserByIdentifier'); - mockUserRepository.resolves((mockResponseRow as unknown) as SystemUser[]); + mockUserRepository.resolves(mockResponseRow as unknown as SystemUser[]); const userService = new UserService(mockDBConnection); @@ -110,7 +110,7 @@ describe('UserService', () => { const mockRowObj = { system_user_id: 123 }; const mockUserRepository = sinon.stub(UserRepository.prototype, 'addSystemUser'); - mockUserRepository.resolves((mockRowObj as unknown) as SystemUser); + mockUserRepository.resolves(mockRowObj as unknown as SystemUser); const userService = new UserService(mockDBConnection); @@ -165,7 +165,7 @@ describe('UserService', () => { }); it('throws an error if it fails to get the current system user id', async () => { - const mockDBConnection = getMockDBConnection({ systemUserId: () => (null as unknown) as number }); + const mockDBConnection = getMockDBConnection({ systemUserId: () => null as unknown as number }); const existingSystemUser = null; const getUserByGuidStub = sinon.stub(UserService.prototype, 'getUserByGuid').resolves(existingSystemUser); @@ -199,7 +199,7 @@ describe('UserService', () => { const existingSystemUser = null; const getUserByGuidStub = sinon.stub(UserService.prototype, 'getUserByGuid').resolves(existingSystemUser); - const addedSystemUser = ({ system_user_id: 2, record_end_date: null } as unknown) as SystemUser; + const addedSystemUser = { system_user_id: 2, record_end_date: null } as unknown as SystemUser; const addSystemUserStub = sinon.stub(UserService.prototype, 'addSystemUser').resolves(addedSystemUser); const activateSystemUserStub = sinon.stub(UserService.prototype, 'activateSystemUser'); diff --git a/api/src/utils/file-utils.test.ts b/api/src/utils/file-utils.test.ts index 71fa5b5efd..08c16057f6 100644 --- a/api/src/utils/file-utils.test.ts +++ b/api/src/utils/file-utils.test.ts @@ -15,7 +15,7 @@ import { describe('deleteFileFromS3', () => { it('returns null when no key specified', async () => { - const result = await deleteFileFromS3((null as unknown) as string); + const result = await deleteFileFromS3(null as unknown as string); expect(result).to.be.null; }); @@ -23,7 +23,7 @@ describe('deleteFileFromS3', () => { describe('getS3SignedURL', () => { it('returns null when no key specified', async () => { - const result = await getS3SignedURL((null as unknown) as string); + const result = await getS3SignedURL(null as unknown as string); expect(result).to.be.null; }); diff --git a/api/src/utils/keycloak-utils.test.ts b/api/src/utils/keycloak-utils.test.ts index 333533ac45..fb33576d58 100644 --- a/api/src/utils/keycloak-utils.test.ts +++ b/api/src/utils/keycloak-utils.test.ts @@ -242,7 +242,7 @@ describe('keycloakUtils', () => { expect(response).to.equal(SYSTEM_IDENTITY_SOURCE.DATABASE); }); it('should coerce null string user identity to DATABASE', () => { - const response = coerceUserIdentitySource((null as unknown) as string); + const response = coerceUserIdentitySource(null as unknown as string); expect(response).to.equal(SYSTEM_IDENTITY_SOURCE.DATABASE); }); it('should coerce bceid basic user identity to BCEIDBASIC', () => { diff --git a/api/src/utils/logger.ts b/api/src/utils/logger.ts index 2407e1f804..e9d493fb21 100644 --- a/api/src/utils/logger.ts +++ b/api/src/utils/logger.ts @@ -70,7 +70,7 @@ export const getLogger = function (logLabel: string) { export const WinstonLogLevels = ['silent', 'error', 'warn', 'info', 'debug', 'silly'] as const; -export type WinstonLogLevel = typeof WinstonLogLevels[number]; +export type WinstonLogLevel = (typeof WinstonLogLevels)[number]; /** * Set the winston logger log level. diff --git a/api/src/utils/media/csv/csv-file.test.ts b/api/src/utils/media/csv/csv-file.test.ts index 38de369af7..d8bed9c8b0 100644 --- a/api/src/utils/media/csv/csv-file.test.ts +++ b/api/src/utils/media/csv/csv-file.test.ts @@ -45,7 +45,7 @@ describe('CSVWorksheet', () => { describe('getHeaders', () => { it('returns empty array if the worksheet is null', () => { - const xlsxWorkSheet = (null as unknown) as xlsx.WorkSheet; + const xlsxWorkSheet = null as unknown as xlsx.WorkSheet; const csvWorksheet = new CSVWorksheet('Sheet1', xlsxWorkSheet); @@ -68,7 +68,7 @@ describe('CSVWorksheet', () => { describe('getRows', () => { it('returns empty array if the worksheet is null', () => { - const xlsxWorkSheet = (null as unknown) as xlsx.WorkSheet; + const xlsxWorkSheet = null as unknown as xlsx.WorkSheet; const csvWorksheet = new CSVWorksheet('Sheet1', xlsxWorkSheet); diff --git a/api/src/utils/media/media-utils.test.ts b/api/src/utils/media/media-utils.test.ts index 411cdfb4ea..da8fbbf2f7 100644 --- a/api/src/utils/media/media-utils.test.ts +++ b/api/src/utils/media/media-utils.test.ts @@ -17,7 +17,7 @@ describe('parseUnknownMedia', () => { it('calls parseUnknownMulterFile', () => { const parseUnknownMulterFileStub = sinon.stub(media_utils, 'parseUnknownMulterFile'); - media_utils.parseUnknownMedia(({ originalname: 'name' } as unknown) as Express.Multer.File); + media_utils.parseUnknownMedia({ originalname: 'name' } as unknown as Express.Multer.File); expect(parseUnknownMulterFileStub).to.have.been.calledOnce; }); @@ -25,7 +25,7 @@ describe('parseUnknownMedia', () => { it('calls parseUnknownS3File', () => { const parseUnknownS3FileStub = sinon.stub(media_utils, 'parseUnknownS3File'); - media_utils.parseUnknownMedia(({} as unknown) as GetObjectOutput); + media_utils.parseUnknownMedia({} as unknown as GetObjectOutput); expect(parseUnknownS3FileStub).to.have.been.calledOnce; }); @@ -33,10 +33,10 @@ describe('parseUnknownMedia', () => { describe('parseUnknownMulterFile', () => { it('returns a MediaFile', () => { - const multerFile = ({ + const multerFile = { originalname: 'file1.txt', buffer: Buffer.from('file1data') - } as unknown) as Express.Multer.File; + } as unknown as Express.Multer.File; const response = media_utils.parseUnknownMulterFile(multerFile); @@ -50,7 +50,7 @@ describe('parseUnknownMulterFile', () => { zipFile.addFile('folder2/', Buffer.from('')); // add folder zipFile.addFile('folder2/file2.csv', Buffer.from('file2data')); - const multerFile = ({ originalname: 'zipFile.zip', buffer: zipFile.toBuffer() } as unknown) as Express.Multer.File; + const multerFile = { originalname: 'zipFile.zip', buffer: zipFile.toBuffer() } as unknown as Express.Multer.File; const response = media_utils.parseUnknownMulterFile(multerFile); @@ -65,10 +65,10 @@ describe('parseUnknownMulterFile', () => { describe('parseUnknownS3File', () => { it('returns a MediaFile', () => { - const s3File = ({ + const s3File = { Metadata: { filename: 'file1.txt' }, Body: Buffer.from('file1data') - } as unknown) as GetObjectOutput; + } as unknown as GetObjectOutput; const response = media_utils.parseUnknownS3File(s3File); @@ -82,11 +82,11 @@ describe('parseUnknownS3File', () => { zipFile.addFile('folder2/', Buffer.from('')); // add folder zipFile.addFile('folder2/file2.csv', Buffer.from('file2data')); - const s3File = ({ + const s3File = { Metadata: { filename: 'zipFile.zip' }, ContentType: 'application/zip', Body: zipFile.toBuffer() - } as unknown) as GetObjectOutput; + } as unknown as GetObjectOutput; const response = media_utils.parseUnknownS3File(s3File); @@ -107,7 +107,7 @@ describe('parseUnknownZipFile', () => { zipFile.addFile('folder2/', Buffer.from('')); // add folder zipFile.addFile('folder2/file2.csv', Buffer.from('file2data')); - const multerFile = ({ originalname: 'zipFile.zip', buffer: zipFile.toBuffer() } as unknown) as Express.Multer.File; + const multerFile = { originalname: 'zipFile.zip', buffer: zipFile.toBuffer() } as unknown as Express.Multer.File; const response = media_utils.parseUnknownZipFile(multerFile.buffer); @@ -121,7 +121,7 @@ describe('parseUnknownZipFile', () => { zipFile.addFile('folder2/', Buffer.from('')); // add folder - const multerFile = ({ originalname: 'zipFile.zip', buffer: zipFile.toBuffer() } as unknown) as Express.Multer.File; + const multerFile = { originalname: 'zipFile.zip', buffer: zipFile.toBuffer() } as unknown as Express.Multer.File; const response = media_utils.parseUnknownZipFile(multerFile.buffer); @@ -131,10 +131,10 @@ describe('parseUnknownZipFile', () => { describe('parseMulterFile', () => { it('returns a MediaFile item', () => { - const multerFile = ({ + const multerFile = { originalname: 'file1.csv', buffer: Buffer.from('file1data') - } as unknown) as Express.Multer.File; + } as unknown as Express.Multer.File; const response = media_utils.parseMulterFile(multerFile); @@ -142,10 +142,10 @@ describe('parseMulterFile', () => { }); it('returns a MediaFile item when the file mime type is unknown', () => { - const multerFile = ({ + const multerFile = { originalname: 'file1.notAKnownMimeTypecsv', buffer: Buffer.from('file1data') - } as unknown) as Express.Multer.File; + } as unknown as Express.Multer.File; const response = media_utils.parseMulterFile(multerFile); @@ -153,24 +153,24 @@ describe('parseMulterFile', () => { }); it('returns a MediaFile item when the file buffer is null', () => { - const multerFile = ({ + const multerFile = { originalname: 'file1.csv', buffer: null - } as unknown) as Express.Multer.File; + } as unknown as Express.Multer.File; const response = media_utils.parseMulterFile(multerFile); - expect(response).to.eql(new MediaFile('file1.csv', 'text/csv', (null as unknown) as Buffer)); + expect(response).to.eql(new MediaFile('file1.csv', 'text/csv', null as unknown as Buffer)); }); }); describe('parseS3File', () => { it('returns a MediaFile item', () => { - const s3File = ({ + const s3File = { Metadata: { filename: 'file1.csv' }, ContentType: 'text/csv', Body: Buffer.from('file1data') - } as unknown) as GetObjectOutput; + } as unknown as GetObjectOutput; const response = media_utils.parseS3File(s3File); @@ -178,11 +178,11 @@ describe('parseS3File', () => { }); it('returns a MediaFile item when the file mime type is unknown', () => { - const s3File = ({ + const s3File = { Metadata: { filename: 'file1.notAKnownMimeTypecsv' }, ContentType: 'notAKnownMimeTypecsv', Body: Buffer.from('file1data') - } as unknown) as GetObjectOutput; + } as unknown as GetObjectOutput; const response = media_utils.parseS3File(s3File); @@ -190,36 +190,36 @@ describe('parseS3File', () => { }); it('returns a MediaFile item when the file buffer is null', () => { - const s3File = ({ + const s3File = { Metadata: { filename: 'file1.csv' }, ContentType: 'text/csv', Body: null - } as unknown) as GetObjectOutput; + } as unknown as GetObjectOutput; const response = media_utils.parseS3File(s3File); - expect(response).to.eql(new MediaFile('file1.csv', 'text/csv', (null as unknown) as Buffer)); + expect(response).to.eql(new MediaFile('file1.csv', 'text/csv', null as unknown as Buffer)); }); }); describe('checkFileForKeyx', () => { - const validKeyxFile = ({ + const validKeyxFile = { originalname: 'test.keyx', mimetype: 'application/octet-stream', buffer: Buffer.alloc(0) - } as unknown) as Express.Multer.File; + } as unknown as Express.Multer.File; - const invalidFile = ({ + const invalidFile = { originalname: 'test.txt', mimetype: 'text/plain', buffer: Buffer.alloc(0) - } as unknown) as Express.Multer.File; + } as unknown as Express.Multer.File; - const zipFile = ({ + const zipFile = { originalname: 'test.zip', mimetype: 'application/zip', buffer: Buffer.alloc(0) - } as unknown) as Express.Multer.File; + } as unknown as Express.Multer.File; it('should return true if the file extension is .keyx', () => { expect(media_utils.checkFileForKeyx(validKeyxFile)).to.equal(true); diff --git a/api/src/utils/media/xlsx/validation/xlsx-validation.ts b/api/src/utils/media/xlsx/validation/xlsx-validation.ts index 8608214851..b92a5fedff 100644 --- a/api/src/utils/media/xlsx/validation/xlsx-validation.ts +++ b/api/src/utils/media/xlsx/validation/xlsx-validation.ts @@ -27,11 +27,8 @@ export const getParentChildKeyMatchValidator = (config?: ParentChildKeyMatchVali if (!config) { return csvWorkbook; } - const { - child_worksheet_name, - parent_worksheet_name, - column_names - } = config.workbook_parent_child_key_match_validator; + const { child_worksheet_name, parent_worksheet_name, column_names } = + config.workbook_parent_child_key_match_validator; const parentWorksheet = csvWorkbook.worksheets[parent_worksheet_name]; const childWorksheet = csvWorkbook.worksheets[child_worksheet_name]; diff --git a/api/src/utils/pagination.test.ts b/api/src/utils/pagination.test.ts index bc615fabbe..7a82fc1eb4 100644 --- a/api/src/utils/pagination.test.ts +++ b/api/src/utils/pagination.test.ts @@ -25,14 +25,14 @@ describe('pagination', () => { }); it('should cast request params to number successfully', () => { - const mockRequst = ({ + const mockRequst = { query: { limit: '100', page: '2', sort: 'name', order: 'desc' } - } as unknown) as Request; + } as unknown as Request; const result = makePaginationOptionsFromRequest(mockRequst); expect(result).to.eql({ diff --git a/api/src/utils/spatial-utils.test.ts b/api/src/utils/spatial-utils.test.ts index d37e670e71..e602ac6384 100644 --- a/api/src/utils/spatial-utils.test.ts +++ b/api/src/utils/spatial-utils.test.ts @@ -4,7 +4,7 @@ import { parseLatLongString, parseUTMString, utmToLatLng } from './spatial-utils describe('parseUTMString', () => { it('returns null when no UTM string provided', async () => { - expect(parseUTMString((null as unknown) as string)).to.be.null; + expect(parseUTMString(null as unknown as string)).to.be.null; expect(parseUTMString('')).to.be.null; }); @@ -97,7 +97,7 @@ describe('parseUTMString', () => { describe('parseLatLongString', () => { it('returns null when no LatLong string provided', async () => { - expect(parseLatLongString((null as unknown) as string)).to.be.null; + expect(parseLatLongString(null as unknown as string)).to.be.null; expect(parseLatLongString('')).to.be.null; }); diff --git a/api/src/utils/string-utils.ts b/api/src/utils/string-utils.ts index e0e9fa4619..94f6f31dd4 100644 --- a/api/src/utils/string-utils.ts +++ b/api/src/utils/string-utils.ts @@ -12,7 +12,7 @@ import { isString } from 'lodash'; */ export function safeToLowerCase(value: T): T { if (isString(value)) { - return (value.toLowerCase() as unknown) as T; + return value.toLowerCase() as unknown as T; } return value; @@ -30,7 +30,7 @@ export function safeToLowerCase(value: T): T { */ export function safeTrim(value: T): T { if (isString(value)) { - return (value.trim() as unknown) as T; + return value.trim() as unknown as T; } return value; diff --git a/api/src/utils/xlsx-utils/worksheet-utils.test.ts b/api/src/utils/xlsx-utils/worksheet-utils.test.ts index 16e2d7ee11..bae3ec33ce 100644 --- a/api/src/utils/xlsx-utils/worksheet-utils.test.ts +++ b/api/src/utils/xlsx-utils/worksheet-utils.test.ts @@ -243,7 +243,7 @@ describe('worksheet utils', () => { it('throws when no measurements are fetched', async () => { const fetch = sinon .stub(CritterbaseService.prototype, 'getTaxonMeasurements') - .resolves((null as unknown) as { qualitative: []; quantitative: [] }); + .resolves(null as unknown as { qualitative: []; quantitative: [] }); const service = new CritterbaseService({ keycloak_guid: '', username: '' }); @@ -561,7 +561,7 @@ describe('worksheet utils', () => { } }; - const mockWorksheet = ({} as unknown) as xlsx.WorkSheet; + const mockWorksheet = {} as unknown as xlsx.WorkSheet; const getWorksheetHeaderssStub = sinon .stub(worksheet_utils, 'getWorksheetHeaders') @@ -583,7 +583,7 @@ describe('worksheet utils', () => { } }; - const mockWorksheet = ({} as unknown) as xlsx.WorkSheet; + const mockWorksheet = {} as unknown as xlsx.WorkSheet; const getWorksheetHeaderssStub = sinon .stub(worksheet_utils, 'getWorksheetHeaders') diff --git a/app/.eslintrc b/app/.eslintrc index 155a4234ee..8f0da90907 100644 --- a/app/.eslintrc +++ b/app/.eslintrc @@ -1,13 +1,13 @@ { "extends": [ - "react-app", "eslint:recommended", "plugin:prettier/recommended", + "plugin:react-hooks/recommended", "plugin:@typescript-eslint/eslint-recommended", "plugin:@typescript-eslint/recommended" ], "parser": "@typescript-eslint/parser", - "plugins": ["prettier", "@typescript-eslint"], + "plugins": ["prettier", "@typescript-eslint", "react-hooks"], "rules": { "prettier/prettier": ["warn"], "@typescript-eslint/no-explicit-any": "off", @@ -23,6 +23,14 @@ "ts-check": false } ], + "@typescript-eslint/no-unused-vars": [ + "error", + { + "args": "all", + "argsIgnorePattern": "^_", + "caughtErrorsIgnorePattern": "^_" + } + ], "no-var": "error" } } diff --git a/app/package-lock.json b/app/package-lock.json index 668d059c22..4a065a2f83 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -82,21 +82,22 @@ "@types/react-window": "^1.8.2", "@types/shpjs": "^3.4.0", "@types/uuid": "^8.3.0", - "@typescript-eslint/eslint-plugin": "^5.59.9", - "@typescript-eslint/parser": "^5.59.9", + "@typescript-eslint/eslint-plugin": "~7.6.0", + "@typescript-eslint/parser": "~7.6.0", "assert": "^2.1.0", "axios-mock-adapter": "^1.22.0", "buffer": "^6.0.3", - "eslint": "^8.42.0", - "eslint-config-prettier": "^8.8.0", - "eslint-plugin-prettier": "^4.2.1", + "eslint": "~8.56.0", + "eslint-config-prettier": "~8.10.0", + "eslint-plugin-prettier": "~4.2.1", + "eslint-plugin-react-hooks": "~4.6.0", "fs-constants": "^1.0.0", "fs-extra": "^11.1.1", "jest": "^29.7.0", "jest-sonar-reporter": "^2.0.0", "path-browserify": "^1.0.1", "prettier": "^2.8.8", - "prettier-plugin-organize-imports": "^3.2.2", + "prettier-plugin-organize-imports": "^3.2.4", "react-app-rewired": "^2.2.1", "react-scripts": "^5.0.1", "stream-browserify": "^3.0.0" @@ -2670,6 +2671,16 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, "node_modules/@eslint/eslintrc/node_modules/globals": { "version": "13.24.0", "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", @@ -2685,6 +2696,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@eslint/eslintrc/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/@eslint/eslintrc/node_modules/type-fest": { "version": "0.20.2", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", @@ -2698,9 +2721,9 @@ } }, "node_modules/@eslint/js": { - "version": "8.57.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.0.tgz", - "integrity": "sha512-Ys+3g2TaW7gADOJzPt83SJtCDhMjndcDMFVQ/Tj9iA1BfJzFKD9mAUXT3OenpuPHbI6P/myECxRJrofUsDx/5g==", + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.56.0.tgz", + "integrity": "sha512-gMsVel9D7f2HLkBma9VbtzZRehRogVRfbr++f06nL2vnCGCNlzOD+/MUov/F4p8myyAHspEhVobgjpX64q5m6A==", "dev": true, "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" @@ -2759,6 +2782,28 @@ "node": ">=10.10.0" } }, + "node_modules/@humanwhocodes/config-array/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@humanwhocodes/config-array/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/@humanwhocodes/module-importer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", @@ -4007,9 +4052,9 @@ } }, "node_modules/@mui/x-data-grid": { - "version": "6.19.8", - "resolved": "https://registry.npmjs.org/@mui/x-data-grid/-/x-data-grid-6.19.8.tgz", - "integrity": "sha512-QsOW9GhJdhvagJfUb5jpZE1MMaCLugxx0l89amxJAthMia95BlGS7jndiDEh8IQNthgzfxjAzrSv8GZpcgSEaA==", + "version": "6.19.10", + "resolved": "https://registry.npmjs.org/@mui/x-data-grid/-/x-data-grid-6.19.10.tgz", + "integrity": "sha512-p6cc6pJvPPXw/KqDbU8xqaxvv1qVNU2qawTCGfXwtCUwjWaa8VumLfXioX4Sn9yHxf1SuCxnW9ZasHlaS577eg==", "dependencies": { "@babel/runtime": "^7.23.2", "@mui/utils": "^5.14.16", @@ -4032,13 +4077,13 @@ } }, "node_modules/@mui/x-data-grid-pro": { - "version": "6.19.8", - "resolved": "https://registry.npmjs.org/@mui/x-data-grid-pro/-/x-data-grid-pro-6.19.8.tgz", - "integrity": "sha512-G1mr+AUoIxxU0+Tgus5wk2uPVqtz9Ij3Ta20bhmuDFi78fvDdyHwN52K913Ob9dQS+kfeVDhvcJYN7gN6jz4DQ==", + "version": "6.19.10", + "resolved": "https://registry.npmjs.org/@mui/x-data-grid-pro/-/x-data-grid-pro-6.19.10.tgz", + "integrity": "sha512-2AxtPmnhJfQTWSd0VtfOSxyqSQamqBnjmb9/vEH4KWq4xqyeak1sHoin95657Q/xnLLv4HDtMtW6+4YfHFdgwA==", "dependencies": { "@babel/runtime": "^7.23.2", "@mui/utils": "^5.14.16", - "@mui/x-data-grid": "6.19.8", + "@mui/x-data-grid": "6.19.10", "@mui/x-license-pro": "6.10.2", "@types/format-util": "^1.0.3", "clsx": "^2.0.0", @@ -4056,9 +4101,9 @@ } }, "node_modules/@mui/x-date-pickers": { - "version": "6.19.8", - "resolved": "https://registry.npmjs.org/@mui/x-date-pickers/-/x-date-pickers-6.19.8.tgz", - "integrity": "sha512-6wgc2DoRTR9/mKesku4CVCKr9yYkY3FI2Oy/wshLTs2rFkw2Z10uxXFHBR9ugEtNPNCQv0qqwldElenYI97wsA==", + "version": "6.19.9", + "resolved": "https://registry.npmjs.org/@mui/x-date-pickers/-/x-date-pickers-6.19.9.tgz", + "integrity": "sha512-B2m4Fv/fOme5qmV6zuE3QnWQSvj3zKtI2OvikPz5prpiCcIxqpeytkQ7VfrWH3/Aqd5yhG1Yr4IgbqG0ymIXGg==", "dependencies": { "@babel/runtime": "^7.23.2", "@mui/base": "^5.0.0-beta.22", @@ -4144,6 +4189,28 @@ "eslint-scope": "5.1.1" } }, + "node_modules/@nicolo-ribaudo/eslint-scope-5-internals/node_modules/eslint-scope": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", + "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "dev": true, + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@nicolo-ribaudo/eslint-scope-5-internals/node_modules/estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "dev": true, + "engines": { + "node": ">=4.0" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -4411,9 +4478,9 @@ "dev": true }, "node_modules/@rushstack/eslint-patch": { - "version": "1.10.1", - "resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.10.1.tgz", - "integrity": "sha512-S3Kq8e7LqxkA9s7HKLqXGTGck1uwis5vAXan3FnU5yw1Ec5hsSGnq4s/UCaSqABPOnOTg7zASLyst7+ohgWexg==", + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.10.2.tgz", + "integrity": "sha512-hw437iINopmQuxWPSUEvqE56NCPsiU8N4AYtfHmJFckclktzK9YQJieD3XkDCDH4OjL+C7zgPUh73R/nrcHrqw==", "dev": true }, "node_modules/@sinclair/typebox": { @@ -4695,22 +4762,23 @@ } }, "node_modules/@testing-library/dom": { - "version": "9.3.4", - "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-9.3.4.tgz", - "integrity": "sha512-FlS4ZWlp97iiNWig0Muq8p+3rVDjRiYE+YKGbAqXOu9nwJFFOdL00kFpz42M+4huzYi86vAK1sOOfyOG45muIQ==", + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.0.0.tgz", + "integrity": "sha512-PmJPnogldqoVFf+EwbHvbBJ98MmqASV8kLrBYgsDNxQcFMeIS7JFL48sfyXvuMtgmWO/wMhh25odr+8VhDmn4g==", "dev": true, + "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", - "aria-query": "5.1.3", + "aria-query": "5.3.0", "chalk": "^4.1.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "pretty-format": "^27.0.2" }, "engines": { - "node": ">=14" + "node": ">=18" } }, "node_modules/@testing-library/dom/node_modules/ansi-styles": { @@ -4718,6 +4786,7 @@ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dev": true, + "peer": true, "dependencies": { "color-convert": "^2.0.1" }, @@ -4733,6 +4802,7 @@ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "dev": true, + "peer": true, "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -4749,6 +4819,7 @@ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "dev": true, + "peer": true, "dependencies": { "color-name": "~1.1.4" }, @@ -4760,13 +4831,15 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true + "dev": true, + "peer": true }, "node_modules/@testing-library/dom/node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "dev": true, + "peer": true, "engines": { "node": ">=8" } @@ -4776,6 +4849,7 @@ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "dev": true, + "peer": true, "dependencies": { "has-flag": "^4.0.0" }, @@ -4902,9 +4976,9 @@ } }, "node_modules/@testing-library/react": { - "version": "14.2.2", - "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-14.2.2.tgz", - "integrity": "sha512-SOUuM2ysCvjUWBXTNfQ/ztmnKDmqaiPV3SvoIuyxMUca45rbSWWAT/qB8CUs/JQ/ux/8JFs9DNdFQ3f6jH3crA==", + "version": "14.3.1", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-14.3.1.tgz", + "integrity": "sha512-H99XjUhWQw0lTgyMN05W3xQG1Nh4lq574D8keFf1dDoNTJgp66VbJozRaczoF+wsiaPJNt/TcnfpLGufGxSrZQ==", "dev": true, "dependencies": { "@babel/runtime": "^7.12.5", @@ -4919,6 +4993,136 @@ "react-dom": "^18.0.0" } }, + "node_modules/@testing-library/react/node_modules/@testing-library/dom": { + "version": "9.3.4", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-9.3.4.tgz", + "integrity": "sha512-FlS4ZWlp97iiNWig0Muq8p+3rVDjRiYE+YKGbAqXOu9nwJFFOdL00kFpz42M+4huzYi86vAK1sOOfyOG45muIQ==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.1.3", + "chalk": "^4.1.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@testing-library/react/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@testing-library/react/node_modules/aria-query": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.1.3.tgz", + "integrity": "sha512-R5iJ5lkuHybztUfuOAznmboyjWq8O6sqNqtK7CLOqdydi54VNbORp49mb14KbWgG1QD3JFO9hJdZ+y4KutfdOQ==", + "dev": true, + "dependencies": { + "deep-equal": "^2.0.5" + } + }, + "node_modules/@testing-library/react/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/@testing-library/react/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/@testing-library/react/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/@testing-library/react/node_modules/deep-equal": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-2.2.3.tgz", + "integrity": "sha512-ZIwpnevOurS8bpT4192sqAowWM76JDKSHYzMLty3BZGSswgq6pBaH3DhCSW5xVAZICZyKdOBPjwww5wfgT/6PA==", + "dev": true, + "dependencies": { + "array-buffer-byte-length": "^1.0.0", + "call-bind": "^1.0.5", + "es-get-iterator": "^1.1.3", + "get-intrinsic": "^1.2.2", + "is-arguments": "^1.1.1", + "is-array-buffer": "^3.0.2", + "is-date-object": "^1.0.5", + "is-regex": "^1.1.4", + "is-shared-array-buffer": "^1.0.2", + "isarray": "^2.0.5", + "object-is": "^1.1.5", + "object-keys": "^1.1.1", + "object.assign": "^4.1.4", + "regexp.prototype.flags": "^1.5.1", + "side-channel": "^1.0.4", + "which-boxed-primitive": "^1.0.2", + "which-collection": "^1.0.1", + "which-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/@testing-library/react/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/@testing-library/react/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/@testing-library/user-event": { "version": "14.5.2", "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.5.2.tgz", @@ -5160,9 +5364,9 @@ } }, "node_modules/@types/eslint": { - "version": "8.56.7", - "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.56.7.tgz", - "integrity": "sha512-SjDvI/x3zsZnOkYZ3lCt9lOZWZLB2jIlNKz+LBgCtDurK0JZcwucxYHn1w2BJkD34dgX9Tjnak0txtq4WTggEA==", + "version": "8.56.9", + "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.56.9.tgz", + "integrity": "sha512-W4W3KcqzjJ0sHg2vAq9vfml6OhsJ53TcUjUqfzzZf/EChUtwspszj/S0pzMxnfRcO55/iGq47dscXw71Fxc4Zg==", "dev": true, "dependencies": { "@types/estree": "*", @@ -5198,9 +5402,9 @@ } }, "node_modules/@types/express-serve-static-core": { - "version": "4.17.43", - "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.17.43.tgz", - "integrity": "sha512-oaYtiBirUOPQGSWNGPWnzyAFJ0BP3cwvN4oWZQY+zUBwpVIGsKUkpBpSztp74drYcjavs7SKFZ4DX1V2QeN8rg==", + "version": "4.19.0", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.0.tgz", + "integrity": "sha512-bGyep3JqPCRry1wq+O5n7oiBgGWmeIJXPjXXCo8EK0u8duZGSYar7cGqd3ML2JUsLGeB7fmc06KYo9fLGWqPvQ==", "dev": true, "dependencies": { "@types/node": "*", @@ -5390,9 +5594,9 @@ "integrity": "sha512-hov8bUuiLiyFPGyFPE1lwWhmzYbirOXQNNo40+y3zow8aFVTeyn3VWL0VFFfdNddA8S4Vf0Tc062rzyNr7Paag==" }, "node_modules/@types/node": { - "version": "18.19.29", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.29.tgz", - "integrity": "sha512-5pAX7ggTmWZdhUrhRWLPf+5oM7F80bcKVCBbr0zwEkTNzTJL2CWQjznpFgHYy6GrzkYi2Yjy7DHKoynFxqPV8g==", + "version": "18.19.31", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.31.tgz", + "integrity": "sha512-ArgCD39YpyyrtFKIqMDvjz79jto5fcI/SVUs2HwB+f0dAzq68yqOdyaSivLiLugSziTpNXLQrVb7RZFmdZzbhA==", "dev": true, "dependencies": { "undici-types": "~5.26.4" @@ -5462,18 +5666,18 @@ "dev": true }, "node_modules/@types/react": { - "version": "18.2.74", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.74.tgz", - "integrity": "sha512-9AEqNZZyBx8OdZpxzQlaFEVCSFUM2YXJH46yPOiOpm078k6ZLOCcuAzGum/zK8YBwY+dbahVNbHrbgrAwIRlqw==", + "version": "18.2.77", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.77.tgz", + "integrity": "sha512-CUT9KUUF+HytDM7WiXKLF9qUSg4tGImwy4FXTlfEDPEkkNUzJ7rVFolYweJ9fS1ljoIaP7M7Rdjc5eUm/Yu5AA==", "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" } }, "node_modules/@types/react-dom": { - "version": "18.2.24", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.2.24.tgz", - "integrity": "sha512-cN6upcKd8zkGy4HU9F1+/s98Hrp6D4MOcippK4PoE8OZRngohHZpbJn1GsaDLz87MqvHNoT13nHvNqM9ocRHZg==", + "version": "18.2.25", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.2.25.tgz", + "integrity": "sha512-o/V48vf4MQh7juIKZU2QGDfli6p1+OOi5oXx36Hffpc9adsHeXjVp8rHuPkjd8VT8sOJ2Zp05HR7CdpGTIUFUA==", "dev": true, "dependencies": { "@types/react": "*" @@ -5630,32 +5834,33 @@ "dev": true }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "5.62.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.62.0.tgz", - "integrity": "sha512-TiZzBSJja/LbhNPvk6yc0JrX9XqhQ0hdh6M2svYfsHGejaKFIAGd9MQ+ERIMzLGlN/kZoYIgdxFV0PuljTKXag==", + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.6.0.tgz", + "integrity": "sha512-gKmTNwZnblUdnTIJu3e9kmeRRzV2j1a/LUO27KNNAnIC5zjy1aSvXSRp4rVNlmAoHlQ7HzX42NbKpcSr4jF80A==", "dev": true, "dependencies": { - "@eslint-community/regexpp": "^4.4.0", - "@typescript-eslint/scope-manager": "5.62.0", - "@typescript-eslint/type-utils": "5.62.0", - "@typescript-eslint/utils": "5.62.0", + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "7.6.0", + "@typescript-eslint/type-utils": "7.6.0", + "@typescript-eslint/utils": "7.6.0", + "@typescript-eslint/visitor-keys": "7.6.0", "debug": "^4.3.4", "graphemer": "^1.4.0", - "ignore": "^5.2.0", - "natural-compare-lite": "^1.4.0", - "semver": "^7.3.7", - "tsutils": "^3.21.0" + "ignore": "^5.3.1", + "natural-compare": "^1.4.0", + "semver": "^7.6.0", + "ts-api-utils": "^1.3.0" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^18.18.0 || >=20.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^5.0.0", - "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0" + "@typescript-eslint/parser": "^7.0.0", + "eslint": "^8.56.0" }, "peerDependenciesMeta": { "typescript": { @@ -5715,16 +5920,14 @@ "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, - "node_modules/@typescript-eslint/parser": { + "node_modules/@typescript-eslint/experimental-utils/node_modules/@typescript-eslint/scope-manager": { "version": "5.62.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.62.0.tgz", - "integrity": "sha512-VlJEV0fOQ7BExOsHYAGrgbEiZoi8D+Bl2+f6V2RrXerRSylnp+ZBHmPvaIa8cz0Ajx7WO7Z5RqfgYg7ED1nRhA==", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.62.0.tgz", + "integrity": "sha512-VXuvVvZeQCQb5Zgf4HAxc04q5j+WrNAtNh9OwCsCgpKqESMTu3tF/jhZ3xG6T4NZwWl65Bg8KuS2uEvhSfLl0w==", "dev": true, "dependencies": { - "@typescript-eslint/scope-manager": "5.62.0", "@typescript-eslint/types": "5.62.0", - "@typescript-eslint/typescript-estree": "5.62.0", - "debug": "^4.3.4" + "@typescript-eslint/visitor-keys": "5.62.0" }, "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" @@ -5732,25 +5935,13 @@ "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } } }, - "node_modules/@typescript-eslint/scope-manager": { + "node_modules/@typescript-eslint/experimental-utils/node_modules/@typescript-eslint/types": { "version": "5.62.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.62.0.tgz", - "integrity": "sha512-VXuvVvZeQCQb5Zgf4HAxc04q5j+WrNAtNh9OwCsCgpKqESMTu3tF/jhZ3xG6T4NZwWl65Bg8KuS2uEvhSfLl0w==", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.62.0.tgz", + "integrity": "sha512-87NVngcbVXUahrRTqIK27gD2t5Cu1yuCXxbLcFtCzZGlfyVWWh8mLHkoxzjsB6DDNnvdL+fW8MiwPEJyGJQDgQ==", "dev": true, - "dependencies": { - "@typescript-eslint/types": "5.62.0", - "@typescript-eslint/visitor-keys": "5.62.0" - }, "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, @@ -5759,15 +5950,18 @@ "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/@typescript-eslint/type-utils": { + "node_modules/@typescript-eslint/experimental-utils/node_modules/@typescript-eslint/typescript-estree": { "version": "5.62.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-5.62.0.tgz", - "integrity": "sha512-xsSQreu+VnfbqQpW5vnCJdq1Z3Q0U31qiWmRhr98ONQmcp/yhiPJFPq8MXiJVLiksmOKSjIldZzkebzHuCGzew==", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.62.0.tgz", + "integrity": "sha512-CmcQ6uY7b9y694lKdRB8FEel7JbU/40iSAPomu++SjLMntB+2Leay2LO6i8VnJk58MtE9/nQSFIH6jpyRWyYzA==", "dev": true, "dependencies": { - "@typescript-eslint/typescript-estree": "5.62.0", - "@typescript-eslint/utils": "5.62.0", + "@typescript-eslint/types": "5.62.0", + "@typescript-eslint/visitor-keys": "5.62.0", "debug": "^4.3.4", + "globby": "^11.1.0", + "is-glob": "^4.0.3", + "semver": "^7.3.7", "tsutils": "^3.21.0" }, "engines": { @@ -5777,44 +5971,212 @@ "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, - "peerDependencies": { - "eslint": "*" - }, "peerDependenciesMeta": { "typescript": { "optional": true } } }, - "node_modules/@typescript-eslint/types": { + "node_modules/@typescript-eslint/experimental-utils/node_modules/@typescript-eslint/utils": { "version": "5.62.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.62.0.tgz", - "integrity": "sha512-87NVngcbVXUahrRTqIK27gD2t5Cu1yuCXxbLcFtCzZGlfyVWWh8mLHkoxzjsB6DDNnvdL+fW8MiwPEJyGJQDgQ==", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-5.62.0.tgz", + "integrity": "sha512-n8oxjeb5aIbPFEtmQxQYOLI0i9n5ySBEY/ZEHHZqKQSFnxio1rv6dthascc9dLuwrL0RC5mPCxB7vnAVGAYWAQ==", "dev": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@types/json-schema": "^7.0.9", + "@types/semver": "^7.3.12", + "@typescript-eslint/scope-manager": "5.62.0", + "@typescript-eslint/types": "5.62.0", + "@typescript-eslint/typescript-estree": "5.62.0", + "eslint-scope": "^5.1.1", + "semver": "^7.3.7" + }, "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, - "node_modules/@typescript-eslint/typescript-estree": { + "node_modules/@typescript-eslint/experimental-utils/node_modules/@typescript-eslint/visitor-keys": { "version": "5.62.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.62.0.tgz", - "integrity": "sha512-CmcQ6uY7b9y694lKdRB8FEel7JbU/40iSAPomu++SjLMntB+2Leay2LO6i8VnJk58MtE9/nQSFIH6jpyRWyYzA==", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.62.0.tgz", + "integrity": "sha512-07ny+LHRzQXepkGg6w0mFY41fVUNBrL2Roj/++7V1txKugfjm/Ci/qSND03r2RhlJhJYMcTn9AhhSSqQp0Ysyw==", "dev": true, "dependencies": { "@typescript-eslint/types": "5.62.0", - "@typescript-eslint/visitor-keys": "5.62.0", + "eslint-visitor-keys": "^3.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/experimental-utils/node_modules/eslint-scope": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", + "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "dev": true, + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@typescript-eslint/experimental-utils/node_modules/estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "dev": true, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/@typescript-eslint/experimental-utils/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/experimental-utils/node_modules/semver": { + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz", + "integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==", + "dev": true, + "dependencies": { + "lru-cache": "^6.0.0" + }, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/experimental-utils/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true + }, + "node_modules/@typescript-eslint/parser": { + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.6.0.tgz", + "integrity": "sha512-usPMPHcwX3ZoPWnBnhhorc14NJw9J4HpSXQX4urF2TPKG0au0XhJoZyX62fmvdHONUkmyUe74Hzm1//XA+BoYg==", + "dev": true, + "dependencies": { + "@typescript-eslint/scope-manager": "7.6.0", + "@typescript-eslint/types": "7.6.0", + "@typescript-eslint/typescript-estree": "7.6.0", + "@typescript-eslint/visitor-keys": "7.6.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.56.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.6.0.tgz", + "integrity": "sha512-ngttyfExA5PsHSx0rdFgnADMYQi+Zkeiv4/ZxGYUWd0nLs63Ha0ksmp8VMxAIC0wtCFxMos7Lt3PszJssG/E6w==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "7.6.0", + "@typescript-eslint/visitor-keys": "7.6.0" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.6.0.tgz", + "integrity": "sha512-NxAfqAPNLG6LTmy7uZgpK8KcuiS2NZD/HlThPXQRGwz6u7MDBWRVliEEl1Gj6U7++kVJTpehkhZzCJLMK66Scw==", + "dev": true, + "dependencies": { + "@typescript-eslint/typescript-estree": "7.6.0", + "@typescript-eslint/utils": "7.6.0", + "debug": "^4.3.4", + "ts-api-utils": "^1.3.0" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.56.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/types": { + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.6.0.tgz", + "integrity": "sha512-h02rYQn8J+MureCvHVVzhl69/GAfQGPQZmOMjG1KfCl7o3HtMSlPaPUAPu6lLctXI5ySRGIYk94clD/AUMCUgQ==", + "dev": true, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.6.0.tgz", + "integrity": "sha512-+7Y/GP9VuYibecrCQWSKgl3GvUM5cILRttpWtnAu8GNL9j11e4tbuGZmZjJ8ejnKYyBRb2ddGQ3rEFCq3QjMJw==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "7.6.0", + "@typescript-eslint/visitor-keys": "7.6.0", "debug": "^4.3.4", "globby": "^11.1.0", "is-glob": "^4.0.3", - "semver": "^7.3.7", - "tsutils": "^3.21.0" + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^1.3.0" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^18.18.0 || >=20.0.0" }, "funding": { "type": "opencollective", @@ -5860,29 +6222,28 @@ "dev": true }, "node_modules/@typescript-eslint/utils": { - "version": "5.62.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-5.62.0.tgz", - "integrity": "sha512-n8oxjeb5aIbPFEtmQxQYOLI0i9n5ySBEY/ZEHHZqKQSFnxio1rv6dthascc9dLuwrL0RC5mPCxB7vnAVGAYWAQ==", + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.6.0.tgz", + "integrity": "sha512-x54gaSsRRI+Nwz59TXpCsr6harB98qjXYzsRxGqvA5Ue3kQH+FxS7FYU81g/omn22ML2pZJkisy6Q+ElK8pBCA==", "dev": true, "dependencies": { - "@eslint-community/eslint-utils": "^4.2.0", - "@types/json-schema": "^7.0.9", - "@types/semver": "^7.3.12", - "@typescript-eslint/scope-manager": "5.62.0", - "@typescript-eslint/types": "5.62.0", - "@typescript-eslint/typescript-estree": "5.62.0", - "eslint-scope": "^5.1.1", - "semver": "^7.3.7" + "@eslint-community/eslint-utils": "^4.4.0", + "@types/json-schema": "^7.0.15", + "@types/semver": "^7.5.8", + "@typescript-eslint/scope-manager": "7.6.0", + "@typescript-eslint/types": "7.6.0", + "@typescript-eslint/typescript-estree": "7.6.0", + "semver": "^7.6.0" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^18.18.0 || >=20.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0" + "eslint": "^8.56.0" } }, "node_modules/@typescript-eslint/utils/node_modules/lru-cache": { @@ -5919,16 +6280,16 @@ "dev": true }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "5.62.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.62.0.tgz", - "integrity": "sha512-07ny+LHRzQXepkGg6w0mFY41fVUNBrL2Roj/++7V1txKugfjm/Ci/qSND03r2RhlJhJYMcTn9AhhSSqQp0Ysyw==", + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.6.0.tgz", + "integrity": "sha512-4eLB7t+LlNUmXzfOu1VAIAdkjbu5xNSerURS9X/S5TUKWFRpXRQZbmtPqgKmYx8bj3J0irtQXSiWAOY82v+cgw==", "dev": true, "dependencies": { - "@typescript-eslint/types": "5.62.0", - "eslint-visitor-keys": "^3.3.0" + "@typescript-eslint/types": "7.6.0", + "eslint-visitor-keys": "^3.4.3" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^18.18.0 || >=20.0.0" }, "funding": { "type": "opencollective", @@ -6398,12 +6759,12 @@ "dev": true }, "node_modules/aria-query": { - "version": "5.1.3", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.1.3.tgz", - "integrity": "sha512-R5iJ5lkuHybztUfuOAznmboyjWq8O6sqNqtK7CLOqdydi54VNbORp49mb14KbWgG1QD3JFO9hJdZ+y4KutfdOQ==", + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", "dev": true, "dependencies": { - "deep-equal": "^2.0.5" + "dequal": "^2.0.3" } }, "node_modules/array-buffer-byte-length": { @@ -7258,12 +7619,11 @@ "dev": true }, "node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" + "balanced-match": "^1.0.0" } }, "node_modules/braces": { @@ -7402,14 +7762,6 @@ "node": "^12.13.0 || ^14.15.0 || >=16.0.0" } }, - "node_modules/cacache/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, "node_modules/cacache/node_modules/glob": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz", @@ -7529,9 +7881,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001605", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001605.tgz", - "integrity": "sha512-nXwGlFWo34uliI9z3n6Qc0wZaf7zaZWA1CPZ169La5mV3I/gem7bst0vr5XQH5TJXZIMfDeZyOrZnSlVzKxxHQ==", + "version": "1.0.30001609", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001609.tgz", + "integrity": "sha512-JFPQs34lHKx1B5t1EpQpWH4c+29zIyn/haGsbpfq3suuV9v56enjFt23zqijxGTMwy1p/4H2tjnQMY+p1WoAyA==", "funding": [ { "type": "opencollective", @@ -8773,9 +9125,9 @@ "dev": true }, "node_modules/dedent": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.5.1.tgz", - "integrity": "sha512-+LxW+KLWxu3HW3M2w2ympwtqPrqYRzU8fqi6Fhd18fBALe15blJPI/I4+UHveMVG6lJqB4JNd4UG0S5cnVHwIg==", + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.5.3.tgz", + "integrity": "sha512-NHQtfOOW68WD8lgypbLA5oT+Bt0xXJhiYvoR6SmmNXZfpzOGXwdKWmcwG8N7PwVVWV3eF/68nmD9BaJSsTBhyQ==", "dev": true, "peerDependencies": { "babel-plugin-macros": "^3.1.0" @@ -8787,29 +9139,16 @@ } }, "node_modules/deep-equal": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-2.2.3.tgz", - "integrity": "sha512-ZIwpnevOurS8bpT4192sqAowWM76JDKSHYzMLty3BZGSswgq6pBaH3DhCSW5xVAZICZyKdOBPjwww5wfgT/6PA==", - "dev": true, + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.1.2.tgz", + "integrity": "sha512-5tdhKF6DbU7iIzrIOa1AOUt39ZRm13cmL1cGEh//aqR8x9+tNfbywRf0n5FD/18OKMdo7DNEtrX2t22ZAkI+eg==", "dependencies": { - "array-buffer-byte-length": "^1.0.0", - "call-bind": "^1.0.5", - "es-get-iterator": "^1.1.3", - "get-intrinsic": "^1.2.2", "is-arguments": "^1.1.1", - "is-array-buffer": "^3.0.2", "is-date-object": "^1.0.5", "is-regex": "^1.1.4", - "is-shared-array-buffer": "^1.0.2", - "isarray": "^2.0.5", "object-is": "^1.1.5", "object-keys": "^1.1.1", - "object.assign": "^4.1.4", - "regexp.prototype.flags": "^1.5.1", - "side-channel": "^1.0.4", - "which-boxed-primitive": "^1.0.2", - "which-collection": "^1.0.1", - "which-typed-array": "^1.1.13" + "regexp.prototype.flags": "^1.5.1" }, "engines": { "node": ">= 0.4" @@ -9186,9 +9525,9 @@ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" }, "node_modules/ejs": { - "version": "3.1.9", - "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.9.tgz", - "integrity": "sha512-rC+QVNMJWv+MtPgkt0y+0rVEIdbtxVADApW9JXrUVlzHetgcyczP/E7DJmWJ4fJCZF2cPcBk0laWO9ZHMG3DmQ==", + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", + "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==", "dev": true, "dependencies": { "jake": "^10.8.5" @@ -9201,9 +9540,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.4.725", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.725.tgz", - "integrity": "sha512-OGkMXLY7XH6ykHE5ZOVVIMHaGAvvxqw98cswTKB683dntBJre7ufm9wouJ0ExDm0VXhHenU8mREvxIbV5nNoVQ==" + "version": "1.4.735", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.735.tgz", + "integrity": "sha512-pkYpvwg8VyOTQAeBqZ7jsmpCjko1Qc6We1ZtZCjRyYbT5v4AIUKDy5cQTRotQlSSZmMr8jqpEt6JtOj5k7lR7A==" }, "node_modules/emittery": { "version": "0.13.1", @@ -9556,6 +9895,15 @@ "node": ">=4" } }, + "node_modules/escodegen/node_modules/estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "dev": true, + "engines": { + "node": ">=4.0" + } + }, "node_modules/escodegen/node_modules/levn": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/levn/-/levn-0.3.0.tgz", @@ -9618,16 +9966,16 @@ } }, "node_modules/eslint": { - "version": "8.57.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.0.tgz", - "integrity": "sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ==", + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.56.0.tgz", + "integrity": "sha512-Go19xM6T9puCOWntie1/P997aXxFsOi37JIHRWI514Hc6ZnaHGKY9xFhrU65RT6CcBEzZoGG1e6Nq+DT04ZtZQ==", "dev": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", "@eslint/eslintrc": "^2.1.4", - "@eslint/js": "8.57.0", - "@humanwhocodes/config-array": "^0.11.14", + "@eslint/js": "8.56.0", + "@humanwhocodes/config-array": "^0.11.13", "@humanwhocodes/module-importer": "^1.0.1", "@nodelib/fs.walk": "^1.2.8", "@ungap/structured-clone": "^1.2.0", @@ -9712,35 +10060,302 @@ "eslint": "^8.0.0" } }, - "node_modules/eslint-import-resolver-node": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz", - "integrity": "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==", - "dev": true, - "dependencies": { - "debug": "^3.2.7", - "is-core-module": "^2.13.0", - "resolve": "^1.22.4" - } - }, - "node_modules/eslint-import-resolver-node/node_modules/debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", - "dev": true, - "dependencies": { - "ms": "^2.1.1" - } - }, - "node_modules/eslint-module-utils": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.8.1.tgz", - "integrity": "sha512-rXDXR3h7cs7dy9RNpUlQf80nX31XWJEyGq1tRMo+6GsO5VmTe4UTwtmonAD4ZkAsrfMVDA2wlGJ3790Ys+D49Q==", + "node_modules/eslint-config-react-app/node_modules/@typescript-eslint/eslint-plugin": { + "version": "5.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.62.0.tgz", + "integrity": "sha512-TiZzBSJja/LbhNPvk6yc0JrX9XqhQ0hdh6M2svYfsHGejaKFIAGd9MQ+ERIMzLGlN/kZoYIgdxFV0PuljTKXag==", "dev": true, "dependencies": { - "debug": "^3.2.7" - }, - "engines": { + "@eslint-community/regexpp": "^4.4.0", + "@typescript-eslint/scope-manager": "5.62.0", + "@typescript-eslint/type-utils": "5.62.0", + "@typescript-eslint/utils": "5.62.0", + "debug": "^4.3.4", + "graphemer": "^1.4.0", + "ignore": "^5.2.0", + "natural-compare-lite": "^1.4.0", + "semver": "^7.3.7", + "tsutils": "^3.21.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^5.0.0", + "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/eslint-config-react-app/node_modules/@typescript-eslint/parser": { + "version": "5.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.62.0.tgz", + "integrity": "sha512-VlJEV0fOQ7BExOsHYAGrgbEiZoi8D+Bl2+f6V2RrXerRSylnp+ZBHmPvaIa8cz0Ajx7WO7Z5RqfgYg7ED1nRhA==", + "dev": true, + "dependencies": { + "@typescript-eslint/scope-manager": "5.62.0", + "@typescript-eslint/types": "5.62.0", + "@typescript-eslint/typescript-estree": "5.62.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/eslint-config-react-app/node_modules/@typescript-eslint/scope-manager": { + "version": "5.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.62.0.tgz", + "integrity": "sha512-VXuvVvZeQCQb5Zgf4HAxc04q5j+WrNAtNh9OwCsCgpKqESMTu3tF/jhZ3xG6T4NZwWl65Bg8KuS2uEvhSfLl0w==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "5.62.0", + "@typescript-eslint/visitor-keys": "5.62.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/eslint-config-react-app/node_modules/@typescript-eslint/type-utils": { + "version": "5.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-5.62.0.tgz", + "integrity": "sha512-xsSQreu+VnfbqQpW5vnCJdq1Z3Q0U31qiWmRhr98ONQmcp/yhiPJFPq8MXiJVLiksmOKSjIldZzkebzHuCGzew==", + "dev": true, + "dependencies": { + "@typescript-eslint/typescript-estree": "5.62.0", + "@typescript-eslint/utils": "5.62.0", + "debug": "^4.3.4", + "tsutils": "^3.21.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/eslint-config-react-app/node_modules/@typescript-eslint/types": { + "version": "5.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.62.0.tgz", + "integrity": "sha512-87NVngcbVXUahrRTqIK27gD2t5Cu1yuCXxbLcFtCzZGlfyVWWh8mLHkoxzjsB6DDNnvdL+fW8MiwPEJyGJQDgQ==", + "dev": true, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/eslint-config-react-app/node_modules/@typescript-eslint/typescript-estree": { + "version": "5.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.62.0.tgz", + "integrity": "sha512-CmcQ6uY7b9y694lKdRB8FEel7JbU/40iSAPomu++SjLMntB+2Leay2LO6i8VnJk58MtE9/nQSFIH6jpyRWyYzA==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "5.62.0", + "@typescript-eslint/visitor-keys": "5.62.0", + "debug": "^4.3.4", + "globby": "^11.1.0", + "is-glob": "^4.0.3", + "semver": "^7.3.7", + "tsutils": "^3.21.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/eslint-config-react-app/node_modules/@typescript-eslint/utils": { + "version": "5.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-5.62.0.tgz", + "integrity": "sha512-n8oxjeb5aIbPFEtmQxQYOLI0i9n5ySBEY/ZEHHZqKQSFnxio1rv6dthascc9dLuwrL0RC5mPCxB7vnAVGAYWAQ==", + "dev": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@types/json-schema": "^7.0.9", + "@types/semver": "^7.3.12", + "@typescript-eslint/scope-manager": "5.62.0", + "@typescript-eslint/types": "5.62.0", + "@typescript-eslint/typescript-estree": "5.62.0", + "eslint-scope": "^5.1.1", + "semver": "^7.3.7" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/eslint-config-react-app/node_modules/@typescript-eslint/visitor-keys": { + "version": "5.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.62.0.tgz", + "integrity": "sha512-07ny+LHRzQXepkGg6w0mFY41fVUNBrL2Roj/++7V1txKugfjm/Ci/qSND03r2RhlJhJYMcTn9AhhSSqQp0Ysyw==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "5.62.0", + "eslint-visitor-keys": "^3.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/eslint-config-react-app/node_modules/eslint-plugin-jest": { + "version": "25.7.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-jest/-/eslint-plugin-jest-25.7.0.tgz", + "integrity": "sha512-PWLUEXeeF7C9QGKqvdSbzLOiLTx+bno7/HC9eefePfEb257QFHg7ye3dh80AZVkaa/RQsBB1Q/ORQvg2X7F0NQ==", + "dev": true, + "dependencies": { + "@typescript-eslint/experimental-utils": "^5.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + }, + "peerDependencies": { + "@typescript-eslint/eslint-plugin": "^4.0.0 || ^5.0.0", + "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@typescript-eslint/eslint-plugin": { + "optional": true + }, + "jest": { + "optional": true + } + } + }, + "node_modules/eslint-config-react-app/node_modules/eslint-scope": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", + "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "dev": true, + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/eslint-config-react-app/node_modules/estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "dev": true, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/eslint-config-react-app/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/eslint-config-react-app/node_modules/semver": { + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz", + "integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==", + "dev": true, + "dependencies": { + "lru-cache": "^6.0.0" + }, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/eslint-config-react-app/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true + }, + "node_modules/eslint-import-resolver-node": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz", + "integrity": "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==", + "dev": true, + "dependencies": { + "debug": "^3.2.7", + "is-core-module": "^2.13.0", + "resolve": "^1.22.4" + } + }, + "node_modules/eslint-import-resolver-node/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-module-utils": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.8.1.tgz", + "integrity": "sha512-rXDXR3h7cs7dy9RNpUlQf80nX31XWJEyGq1tRMo+6GsO5VmTe4UTwtmonAD4ZkAsrfMVDA2wlGJ3790Ys+D49Q==", + "dev": true, + "dependencies": { + "debug": "^3.2.7" + }, + "engines": { "node": ">=4" }, "peerDependenciesMeta": { @@ -9807,6 +10422,16 @@ "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8" } }, + "node_modules/eslint-plugin-import/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, "node_modules/eslint-plugin-import/node_modules/debug": { "version": "3.2.7", "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", @@ -9828,28 +10453,16 @@ "node": ">=0.10.0" } }, - "node_modules/eslint-plugin-jest": { - "version": "25.7.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-jest/-/eslint-plugin-jest-25.7.0.tgz", - "integrity": "sha512-PWLUEXeeF7C9QGKqvdSbzLOiLTx+bno7/HC9eefePfEb257QFHg7ye3dh80AZVkaa/RQsBB1Q/ORQvg2X7F0NQ==", + "node_modules/eslint-plugin-import/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", "dev": true, "dependencies": { - "@typescript-eslint/experimental-utils": "^5.0.0" + "brace-expansion": "^1.1.7" }, "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" - }, - "peerDependencies": { - "@typescript-eslint/eslint-plugin": "^4.0.0 || ^5.0.0", - "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "@typescript-eslint/eslint-plugin": { - "optional": true - }, - "jest": { - "optional": true - } + "node": "*" } }, "node_modules/eslint-plugin-jsx-a11y": { @@ -9882,13 +10495,26 @@ "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8" } }, - "node_modules/eslint-plugin-jsx-a11y/node_modules/aria-query": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", - "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "node_modules/eslint-plugin-jsx-a11y/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", "dev": true, "dependencies": { - "dequal": "^2.0.3" + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint-plugin-jsx-a11y/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" } }, "node_modules/eslint-plugin-prettier": { @@ -9956,6 +10582,16 @@ "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0" } }, + "node_modules/eslint-plugin-react/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, "node_modules/eslint-plugin-react/node_modules/doctrine": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", @@ -9968,13 +10604,16 @@ "node": ">=0.10.0" } }, - "node_modules/eslint-plugin-react/node_modules/estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "node_modules/eslint-plugin-react/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, "engines": { - "node": ">=4.0" + "node": "*" } }, "node_modules/eslint-plugin-react/node_modules/resolve": { @@ -10010,7 +10649,107 @@ "eslint": "^7.5.0 || ^8.0.0" } }, - "node_modules/eslint-scope": { + "node_modules/eslint-plugin-testing-library/node_modules/@typescript-eslint/scope-manager": { + "version": "5.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.62.0.tgz", + "integrity": "sha512-VXuvVvZeQCQb5Zgf4HAxc04q5j+WrNAtNh9OwCsCgpKqESMTu3tF/jhZ3xG6T4NZwWl65Bg8KuS2uEvhSfLl0w==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "5.62.0", + "@typescript-eslint/visitor-keys": "5.62.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/eslint-plugin-testing-library/node_modules/@typescript-eslint/types": { + "version": "5.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.62.0.tgz", + "integrity": "sha512-87NVngcbVXUahrRTqIK27gD2t5Cu1yuCXxbLcFtCzZGlfyVWWh8mLHkoxzjsB6DDNnvdL+fW8MiwPEJyGJQDgQ==", + "dev": true, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/eslint-plugin-testing-library/node_modules/@typescript-eslint/typescript-estree": { + "version": "5.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.62.0.tgz", + "integrity": "sha512-CmcQ6uY7b9y694lKdRB8FEel7JbU/40iSAPomu++SjLMntB+2Leay2LO6i8VnJk58MtE9/nQSFIH6jpyRWyYzA==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "5.62.0", + "@typescript-eslint/visitor-keys": "5.62.0", + "debug": "^4.3.4", + "globby": "^11.1.0", + "is-glob": "^4.0.3", + "semver": "^7.3.7", + "tsutils": "^3.21.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-testing-library/node_modules/@typescript-eslint/utils": { + "version": "5.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-5.62.0.tgz", + "integrity": "sha512-n8oxjeb5aIbPFEtmQxQYOLI0i9n5ySBEY/ZEHHZqKQSFnxio1rv6dthascc9dLuwrL0RC5mPCxB7vnAVGAYWAQ==", + "dev": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@types/json-schema": "^7.0.9", + "@types/semver": "^7.3.12", + "@typescript-eslint/scope-manager": "5.62.0", + "@typescript-eslint/types": "5.62.0", + "@typescript-eslint/typescript-estree": "5.62.0", + "eslint-scope": "^5.1.1", + "semver": "^7.3.7" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/eslint-plugin-testing-library/node_modules/@typescript-eslint/visitor-keys": { + "version": "5.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.62.0.tgz", + "integrity": "sha512-07ny+LHRzQXepkGg6w0mFY41fVUNBrL2Roj/++7V1txKugfjm/Ci/qSND03r2RhlJhJYMcTn9AhhSSqQp0Ysyw==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "5.62.0", + "eslint-visitor-keys": "^3.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/eslint-plugin-testing-library/node_modules/eslint-scope": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", @@ -10023,6 +10762,64 @@ "node": ">=8.0.0" } }, + "node_modules/eslint-plugin-testing-library/node_modules/estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "dev": true, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/eslint-plugin-testing-library/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/eslint-plugin-testing-library/node_modules/semver": { + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz", + "integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==", + "dev": true, + "dependencies": { + "lru-cache": "^6.0.0" + }, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/eslint-plugin-testing-library/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true + }, + "node_modules/eslint-scope": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", + "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", + "dev": true, + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, "node_modules/eslint-visitor-keys": { "version": "3.4.3", "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", @@ -10165,6 +10962,16 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/eslint/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, "node_modules/eslint/node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -10193,36 +11000,11 @@ "node": ">=7.0.0" } }, - "node_modules/eslint/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "node_modules/eslint/node_modules/eslint-scope": { - "version": "7.2.2", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", - "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", - "dev": true, - "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^5.2.0" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint/node_modules/estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "dev": true, - "engines": { - "node": ">=4.0" - } + "node_modules/eslint/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true }, "node_modules/eslint/node_modules/globals": { "version": "13.24.0", @@ -10248,6 +11030,18 @@ "node": ">=8" } }, + "node_modules/eslint/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/eslint/node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -10314,15 +11108,6 @@ "node": ">=0.10" } }, - "node_modules/esquery/node_modules/estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "dev": true, - "engines": { - "node": ">=4.0" - } - }, "node_modules/esrecurse": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", @@ -10335,7 +11120,7 @@ "node": ">=4.0" } }, - "node_modules/esrecurse/node_modules/estraverse": { + "node_modules/estraverse": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", @@ -10344,15 +11129,6 @@ "node": ">=4.0" } }, - "node_modules/estraverse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", - "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", - "dev": true, - "engines": { - "node": ">=4.0" - } - }, "node_modules/estree-walker": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-1.0.1.tgz", @@ -10652,15 +11428,6 @@ "minimatch": "^5.0.1" } }, - "node_modules/filelist/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", - "dev": true, - "dependencies": { - "balanced-match": "^1.0.0" - } - }, "node_modules/filelist/node_modules/minimatch": { "version": "5.1.6", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", @@ -10914,6 +11681,16 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/fork-ts-checker-webpack-plugin/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, "node_modules/fork-ts-checker-webpack-plugin/node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -11009,6 +11786,18 @@ "node": ">=10" } }, + "node_modules/fork-ts-checker-webpack-plugin/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/fork-ts-checker-webpack-plugin/node_modules/schema-utils": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-2.7.0.tgz", @@ -11269,25 +12058,6 @@ "deep-equal": "^1.0.0" } }, - "node_modules/geojson-equality/node_modules/deep-equal": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.1.2.tgz", - "integrity": "sha512-5tdhKF6DbU7iIzrIOa1AOUt39ZRm13cmL1cGEh//aqR8x9+tNfbywRf0n5FD/18OKMdo7DNEtrX2t22ZAkI+eg==", - "dependencies": { - "is-arguments": "^1.1.1", - "is-date-object": "^1.0.5", - "is-regex": "^1.1.4", - "object-is": "^1.1.5", - "object-keys": "^1.1.1", - "regexp.prototype.flags": "^1.5.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", @@ -11410,6 +12180,26 @@ "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", "dev": true }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/global-modules": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/global-modules/-/global-modules-2.0.0.tgz", @@ -11503,6 +12293,15 @@ "node": ">= 0.10" } }, + "node_modules/globule/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, "node_modules/globule/node_modules/glob": { "version": "7.1.7", "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.7.tgz", @@ -12969,6 +13768,16 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/jake/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, "node_modules/jake/node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -13012,6 +13821,18 @@ "node": ">=8" } }, + "node_modules/jake/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/jake/node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -15844,15 +16665,6 @@ "node": ">=4" } }, - "node_modules/jsdom/node_modules/estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "dev": true, - "engines": { - "node": ">=4.0" - } - }, "node_modules/jsdom/node_modules/form-data": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.1.tgz", @@ -16712,14 +17524,18 @@ "dev": true }, "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "9.0.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.4.tgz", + "integrity": "sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw==", + "dev": true, "dependencies": { - "brace-expansion": "^1.1.7" + "brace-expansion": "^2.0.1" }, "engines": { - "node": "*" + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/minimist": { @@ -17376,6 +18192,15 @@ "node": ">= 4" } }, + "node_modules/npm-run-all/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, "node_modules/npm-run-all/node_modules/cross-spawn": { "version": "6.0.5", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz", @@ -17391,6 +18216,17 @@ "node": ">=4.8" } }, + "node_modules/npm-run-all/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/npm-run-all/node_modules/path-key": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-2.0.1.tgz", @@ -22065,6 +22901,28 @@ "node": ">=6.0.0" } }, + "node_modules/recursive-readdir/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/recursive-readdir/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/redent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", @@ -23014,9 +23872,9 @@ } }, "node_modules/socks": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.1.tgz", - "integrity": "sha512-B6w7tkwNid7ToxjZ08rQMT8M9BJAf8DKx8Ft4NivzH0zBUfd6jldGcisJn/RLgxcX3FPNDdNQCUEMMT79b+oCQ==", + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.3.tgz", + "integrity": "sha512-l5x7VUUWbjVFbafGLxPWkYsHIhEvmF85tbIeFZWc8ZPtoMyybuEhL7Jye/ooC4/d48FgOjSJXgsF/AJPYCW8Zw==", "dependencies": { "ip-address": "^9.0.5", "smart-buffer": "^4.2.0" @@ -23639,15 +24497,6 @@ "node": ">=16 || 14 >=14.17" } }, - "node_modules/sucrase/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", - "dev": true, - "dependencies": { - "balanced-match": "^1.0.0" - } - }, "node_modules/sucrase/node_modules/commander": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", @@ -23679,21 +24528,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/sucrase/node_modules/minimatch": { - "version": "9.0.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.4.tgz", - "integrity": "sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw==", - "dev": true, - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/sucrase/node_modules/minipass": { "version": "7.0.4", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.0.4.tgz", @@ -24170,6 +25004,28 @@ "node": ">=8" } }, + "node_modules/test-exclude/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/test-exclude/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/text-encoding-polyfill": { "version": "0.6.7", "resolved": "https://registry.npmjs.org/text-encoding-polyfill/-/text-encoding-polyfill-0.6.7.tgz", @@ -24311,6 +25167,18 @@ "integrity": "sha512-c3zayb8/kWWpycWYg87P71E1S1ZL6b6IJxfb5fvsUgsf0S2MVGaDhDXXjDMpdCpfWXqptc+4mXwmiy1ypXqRAA==", "dev": true }, + "node_modules/ts-api-utils": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.3.0.tgz", + "integrity": "sha512-UQMIo7pb8WRomKR1/+MFVLTroIvDVtMX3K6OUir8ynLyzB8Jeriont2bTAtmNPa1ekAgN7YPDyf6V+ygrdU+eQ==", + "dev": true, + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "typescript": ">=4.2.0" + } + }, "node_modules/ts-interface-checker": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", @@ -25164,6 +26032,28 @@ "node": ">=10.13.0" } }, + "node_modules/webpack/node_modules/eslint-scope": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", + "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "dev": true, + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/webpack/node_modules/estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "dev": true, + "engines": { + "node": ">=4.0" + } + }, "node_modules/websocket-driver": { "version": "0.7.4", "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz", diff --git a/app/package.json b/app/package.json index edecb62445..4c5cdf5ec9 100644 --- a/app/package.json +++ b/app/package.json @@ -17,8 +17,8 @@ "update-snapshots": "react-scripts test --ci --watchAll=false --updateSnapshot", "lint": "eslint src/ --ext .jsx,.js,.ts,.tsx", "lint-fix": "npm run lint -- --fix", - "format": "prettier --check \"./src/**/*.{js,jsx,ts,tsx,css,scss}\"", - "format-fix": "prettier --write \"./src/**/*.{js,jsx,ts,tsx,json,css,scss}\"", + "format": "prettier --loglevel=warn --check \"./src/**/*.{js,jsx,ts,tsx,css,scss}\"", + "format-fix": "prettier --loglevel=warn --write \"./src/**/*.{js,jsx,ts,tsx,json,css,scss}\"", "fix": "npm-run-all -l -s lint-fix format-fix" }, "engines": { @@ -99,21 +99,22 @@ "@types/react-window": "^1.8.2", "@types/shpjs": "^3.4.0", "@types/uuid": "^8.3.0", - "@typescript-eslint/eslint-plugin": "^5.59.9", - "@typescript-eslint/parser": "^5.59.9", + "@typescript-eslint/eslint-plugin": "~7.6.0", + "@typescript-eslint/parser": "~7.6.0", "assert": "^2.1.0", "axios-mock-adapter": "^1.22.0", "buffer": "^6.0.3", - "eslint": "^8.42.0", - "eslint-config-prettier": "^8.8.0", - "eslint-plugin-prettier": "^4.2.1", + "eslint": "~8.56.0", + "eslint-config-prettier": "~8.10.0", + "eslint-plugin-prettier": "~4.2.1", + "eslint-plugin-react-hooks": "~4.6.0", "fs-constants": "^1.0.0", "fs-extra": "^11.1.1", "jest": "^29.7.0", "jest-sonar-reporter": "^2.0.0", "path-browserify": "^1.0.1", "prettier": "^2.8.8", - "prettier-plugin-organize-imports": "^3.2.2", + "prettier-plugin-organize-imports": "^3.2.4", "react-app-rewired": "^2.2.1", "react-scripts": "^5.0.1", "stream-browserify": "^3.0.0" diff --git a/app/src/components/file-upload/FileUploadItem.test.tsx b/app/src/components/file-upload/FileUploadItem.test.tsx index ef5deeef5f..b9927f8e95 100644 --- a/app/src/components/file-upload/FileUploadItem.test.tsx +++ b/app/src/components/file-upload/FileUploadItem.test.tsx @@ -10,7 +10,7 @@ describe('FileUploadItem', () => { it('calls props.onCancel when the `X` button is clicked', async () => { let rejectRef: (value: unknown) => void; - const mockUploadPromise = new Promise(function (resolve: any, reject: any) { + const mockUploadPromise = new Promise(function (_, reject: any) { rejectRef = reject; }); @@ -56,7 +56,7 @@ describe('FileUploadItem', () => { it('handles file upload success', async () => { let resolveRef: (value: unknown) => void; - const mockUploadPromise = new Promise(function (resolve: any, reject: any) { + const mockUploadPromise = new Promise(function (resolve: any, _) { resolveRef = resolve; }); @@ -98,7 +98,7 @@ describe('FileUploadItem', () => { it('handles file upload rejection', async () => { let rejectRef: (reason: unknown) => void; - const mockUploadPromise = new Promise(function (resolve: any, reject: any) { + const mockUploadPromise = new Promise(function (_, reject: any) { rejectRef = reject; }); diff --git a/app/src/components/map/components/MarkerCluster.tsx b/app/src/components/map/components/MarkerCluster.tsx index 1a5b194ce4..a649089e6c 100644 --- a/app/src/components/map/components/MarkerCluster.tsx +++ b/app/src/components/map/components/MarkerCluster.tsx @@ -99,7 +99,7 @@ const MarkerCluster: React.FC> = (pr const layerControls: ReactElement[] = []; - props.layers.forEach((layer, index) => { + props.layers.forEach((layer) => { if (!layer.markers?.length) { return; } diff --git a/app/src/components/publish/components/PublishSurveyContent.tsx b/app/src/components/publish/components/PublishSurveyContent.tsx index b236a32dca..aa2ea9ac64 100644 --- a/app/src/components/publish/components/PublishSurveyContent.tsx +++ b/app/src/components/publish/components/PublishSurveyContent.tsx @@ -33,7 +33,6 @@ const PublishSurveyContent = () => { }}> Published data submitted as part of this survey may be secured according to the{' '} - {/* eslint-disable-next-line react/jsx-no-target-blank */}
@@ -94,7 +93,6 @@ const PublishSurveyContent = () => { label={ All published data for this survey meets or exceed the{' '} - {/* eslint-disable-next-line jsx-a11y/anchor-is-valid */} Freedom of Information and Protection of Privacy Act (FOIPPA) {' '} diff --git a/app/src/components/security/RouteGuards.tsx b/app/src/components/security/RouteGuards.tsx index efbaad16a8..34db6af060 100644 --- a/app/src/components/security/RouteGuards.tsx +++ b/app/src/components/security/RouteGuards.tsx @@ -66,7 +66,7 @@ export const SystemRoleRouteGuard = (props: ISystemRoleRouteGuardProps) => { return ; } - if (!hasAtLeastOneValidValue(props.validRoles, authStateContext.simsUserWrapper.roleNames)) { + if (!hasAtLeastOneValidValue(validRoles, authStateContext.simsUserWrapper.roleNames)) { return ; } diff --git a/app/src/constants/dateTimeFormats.ts b/app/src/constants/dateTimeFormats.ts index 5373bfe342..7700a0b2c7 100644 --- a/app/src/constants/dateTimeFormats.ts +++ b/app/src/constants/dateTimeFormats.ts @@ -13,7 +13,6 @@ export enum DATE_FORMAT { MediumDateFormat = 'MMMM D, YYYY', //January 5, 2020 MediumDateFormat2 = 'MMMM-DD-YYYY', //January-5-2020 MediumDateTimeFormat = 'MMMM D, YYYY, h:mm a', //January 5, 2020, 3:30 pm - LongDateFormat = 'dddd, MMMM D, YYYY, h:mm a', //Monday, January 5, 2020, 3:30 pm LongDateTimeFormat = 'dddd, MMMM D, YYYY, h:mm a' //Monday, January 5, 2020, 3:30 pm } diff --git a/app/src/features/projects/create/CreateProjectPage.tsx b/app/src/features/projects/create/CreateProjectPage.tsx index 5918d8aede..769e83a883 100644 --- a/app/src/features/projects/create/CreateProjectPage.tsx +++ b/app/src/features/projects/create/CreateProjectPage.tsx @@ -151,7 +151,7 @@ const CreateProjectPage = () => { * @param {History.Location} location * @return {*} */ - const handleLocationChange = (location: History.Location, action: History.Action) => { + const handleLocationChange = (location: History.Location) => { if (!dialogContext.yesNoDialogProps.open) { // If the cancel dialog is not open: open it dialogContext.setYesNoDialog({ diff --git a/app/src/features/projects/edit/EditProjectPage.tsx b/app/src/features/projects/edit/EditProjectPage.tsx index e45a6ac8cd..b998475d01 100644 --- a/app/src/features/projects/edit/EditProjectPage.tsx +++ b/app/src/features/projects/edit/EditProjectPage.tsx @@ -130,7 +130,7 @@ const EditProjectPage = () => { * @param {History.Location} location * @return {*} */ - const handleLocationChange = (location: History.Location, action: History.Action) => { + const handleLocationChange = (location: History.Location) => { if (!dialogContext.yesNoDialogProps.open) { // If the cancel dialog is not open: open it dialogContext.setYesNoDialog({ diff --git a/app/src/features/projects/view/components/TeamMember.tsx b/app/src/features/projects/view/components/TeamMember.tsx index c656ad839c..b90c081535 100644 --- a/app/src/features/projects/view/components/TeamMember.tsx +++ b/app/src/features/projects/view/components/TeamMember.tsx @@ -54,7 +54,7 @@ const TeamMembers = () => { return ( - {projectTeamMembers.map((member, index) => { + {projectTeamMembers.map((member) => { const isCoordinator = member.roles.includes('Coordinator'); const isCollaborator = member.roles.includes('Collaborator'); return ( diff --git a/app/src/features/surveys/CreateSurveyPage.tsx b/app/src/features/surveys/CreateSurveyPage.tsx index e35a5f1853..e001044c10 100644 --- a/app/src/features/surveys/CreateSurveyPage.tsx +++ b/app/src/features/surveys/CreateSurveyPage.tsx @@ -151,7 +151,7 @@ const CreateSurveyPage = () => { * @param {History.Location} location * @return {*} */ - const handleLocationChange = (location: History.Location, action: History.Action) => { + const handleLocationChange = (location: History.Location) => { if (!dialogContext.yesNoDialogProps.open) { // If the cancel dialog is not open: open it dialogContext.setYesNoDialog({ diff --git a/app/src/features/surveys/components/ProprietaryDataForm.tsx b/app/src/features/surveys/components/ProprietaryDataForm.tsx index 99350a0739..933fe8af56 100644 --- a/app/src/features/surveys/components/ProprietaryDataForm.tsx +++ b/app/src/features/surveys/components/ProprietaryDataForm.tsx @@ -168,7 +168,7 @@ const ProprietaryDataForm: React.FC = (props) => { name="proprietor.proprietary_data_category" label="Proprietary Data Category" options={props.proprietary_data_category} - onChange={(event, option) => { + onChange={(_, option) => { // Reset proprietor_name and first_nations_id if user changes proprietary_data_category from // `First Nations Land` to any other option. This is because the `First Nations Land` category is // based on a dropdown, where as the other options are free-text and only one of `proprietor_name` or @@ -204,7 +204,7 @@ const ProprietaryDataForm: React.FC = (props) => { name="proprietor.first_nations_id" label="Proprietor Name" options={props.first_nations} - onChange={(event, option) => { + onChange={(_, option) => { // Set the first nations id field for sending to the API setFieldValue('proprietor.first_nations_id', option?.value); setFieldValue('proprietor.proprietor_name', option?.label); diff --git a/app/src/features/surveys/edit/EditSurveyPage.tsx b/app/src/features/surveys/edit/EditSurveyPage.tsx index bdb4fb12a1..2b29941934 100644 --- a/app/src/features/surveys/edit/EditSurveyPage.tsx +++ b/app/src/features/surveys/edit/EditSurveyPage.tsx @@ -150,7 +150,7 @@ const EditSurveyPage = () => { * @param {History.Location} location * @return {*} */ - const handleLocationChange = (location: History.Location, action: History.Action) => { + const handleLocationChange = (location: History.Location) => { if (!dialogContext.yesNoDialogProps.open) { // If the cancel dialog is not open: open it dialogContext.setYesNoDialog({ diff --git a/app/src/features/surveys/observations/sampling-sites/SamplingSitePage.tsx b/app/src/features/surveys/observations/sampling-sites/SamplingSitePage.tsx index aad2c1f049..3f212f359c 100644 --- a/app/src/features/surveys/observations/sampling-sites/SamplingSitePage.tsx +++ b/app/src/features/surveys/observations/sampling-sites/SamplingSitePage.tsx @@ -115,7 +115,7 @@ const SamplingSitePage = () => { * @param {History.Location} location * @return {*} */ - const handleLocationChange = (location: History.Location, action: History.Action) => { + const handleLocationChange = (location: History.Location) => { if (!dialogContext.yesNoDialogProps.open) { // If the cancel dialog is not open: open it dialogContext.setYesNoDialog({ diff --git a/app/src/features/surveys/observations/sampling-sites/components/SamplingBlockForm.tsx b/app/src/features/surveys/observations/sampling-sites/components/SamplingBlockForm.tsx index f79ad9ecb6..19a74e1aa3 100644 --- a/app/src/features/surveys/observations/sampling-sites/components/SamplingBlockForm.tsx +++ b/app/src/features/surveys/observations/sampling-sites/components/SamplingBlockForm.tsx @@ -12,7 +12,7 @@ import Typography from '@mui/material/Typography'; import { SurveyContext } from 'contexts/surveyContext'; import { useFormikContext } from 'formik'; import { IGetSurveyBlock } from 'interfaces/useSurveyApi.interface'; -import { default as React, useContext, useState } from 'react'; +import { useContext, useState } from 'react'; import { TransitionGroup } from 'react-transition-group'; import BlockStratumCard from '../edit/components/BlockStratumCard'; import { ICreateSamplingSiteRequest } from '../SamplingSitePage'; @@ -80,7 +80,7 @@ export const SamplingBlockForm = () => { setSearchText(''); } }} - onClose={(value, reason) => { + onClose={() => { setSearchText(''); }} renderInput={(params) => ( @@ -131,9 +131,7 @@ export const SamplingBlockForm = () => { }}> ) => handleRemoveItem(item)} - aria-label="settings"> + handleRemoveItem(item)} aria-label="settings"> } diff --git a/app/src/features/surveys/observations/sampling-sites/components/SamplingSiteMapControl.tsx b/app/src/features/surveys/observations/sampling-sites/components/SamplingSiteMapControl.tsx index 6bc0b03dc1..7501a9a6a5 100644 --- a/app/src/features/surveys/observations/sampling-sites/components/SamplingSiteMapControl.tsx +++ b/app/src/features/surveys/observations/sampling-sites/components/SamplingSiteMapControl.tsx @@ -202,7 +202,7 @@ const SamplingSiteMapControl = (props: ISamplingSiteMapControlProps) => { ]); }); }} - onLayerDelete={(event: DrawEvents.Deleted) => { + onLayerDelete={() => { setFieldValue(name, []); }} /> diff --git a/app/src/features/surveys/observations/sampling-sites/components/SamplingStratumForm.tsx b/app/src/features/surveys/observations/sampling-sites/components/SamplingStratumForm.tsx index ffd73699f3..3a4c3b5c9c 100644 --- a/app/src/features/surveys/observations/sampling-sites/components/SamplingStratumForm.tsx +++ b/app/src/features/surveys/observations/sampling-sites/components/SamplingStratumForm.tsx @@ -30,7 +30,7 @@ const SamplingStratumForm: React.FC = () => { setFieldValue(`stratums[${values.stratums.length}]`, stratum); }; - const handleRemoveItem = (stratum: IGetSurveyStratum, index: number) => { + const handleRemoveItem = (stratum: IGetSurveyStratum) => { setFieldValue( `stratums`, values.stratums.filter((existing) => existing.survey_stratum_id !== stratum.survey_stratum_id) @@ -125,11 +125,7 @@ const SamplingStratumForm: React.FC = () => { }}> ) => - handleRemoveItem(item, index) - } - aria-label="settings"> + handleRemoveItem(item)} aria-label="settings"> } diff --git a/app/src/features/surveys/observations/sampling-sites/edit/SamplingSiteEditPage.tsx b/app/src/features/surveys/observations/sampling-sites/edit/SamplingSiteEditPage.tsx index 3e298c31f5..6e9e5e7159 100644 --- a/app/src/features/surveys/observations/sampling-sites/edit/SamplingSiteEditPage.tsx +++ b/app/src/features/surveys/observations/sampling-sites/edit/SamplingSiteEditPage.tsx @@ -167,7 +167,7 @@ const SamplingSiteEditPage = () => { * @param {History.Location} location * @return {*} */ - const handleLocationChange = (location: History.Location, action: History.Action) => { + const handleLocationChange = (location: History.Location) => { if (!dialogContext.yesNoDialogProps.open) { // If the cancel dialog is not open: open it dialogContext.setYesNoDialog({ diff --git a/app/src/features/surveys/observations/sampling-sites/edit/components/SampleSiteGeneralInformationForm.tsx b/app/src/features/surveys/observations/sampling-sites/edit/components/SampleSiteGeneralInformationForm.tsx index 91db456d78..e621a3ada7 100644 --- a/app/src/features/surveys/observations/sampling-sites/edit/components/SampleSiteGeneralInformationForm.tsx +++ b/app/src/features/surveys/observations/sampling-sites/edit/components/SampleSiteGeneralInformationForm.tsx @@ -7,7 +7,7 @@ import React from 'react'; * * @return {*} */ -const SampleSiteGeneralInformationForm: React.FC = (props) => { +const SampleSiteGeneralInformationForm: React.FC = () => { return ( <> diff --git a/app/src/features/surveys/observations/sampling-sites/edit/components/SamplingBlockEditForm.tsx b/app/src/features/surveys/observations/sampling-sites/edit/components/SamplingBlockEditForm.tsx index e9762dcbc2..b02a74ac97 100644 --- a/app/src/features/surveys/observations/sampling-sites/edit/components/SamplingBlockEditForm.tsx +++ b/app/src/features/surveys/observations/sampling-sites/edit/components/SamplingBlockEditForm.tsx @@ -12,7 +12,7 @@ import Typography from '@mui/material/Typography'; import { SurveyContext } from 'contexts/surveyContext'; import { useFormikContext } from 'formik'; import { IGetSampleBlockDetails, IGetSurveyBlock } from 'interfaces/useSurveyApi.interface'; -import { default as React, useContext, useState } from 'react'; +import { useContext, useState } from 'react'; import { TransitionGroup } from 'react-transition-group'; import BlockStratumCard from './BlockStratumCard'; import { IEditSamplingSiteRequest } from './SampleSiteEditForm'; @@ -80,7 +80,7 @@ const SamplingBlockEditForm = () => { setSearchText(''); } }} - onClose={(value, reason) => { + onClose={() => { setSearchText(''); }} renderInput={(params) => ( @@ -131,9 +131,7 @@ const SamplingBlockEditForm = () => { }}> ) => handleRemoveItem(item)} - aria-label="settings"> + handleRemoveItem(item)} aria-label="settings"> } diff --git a/app/src/features/surveys/observations/sampling-sites/edit/components/SamplingSiteEditMapControl.tsx b/app/src/features/surveys/observations/sampling-sites/edit/components/SamplingSiteEditMapControl.tsx index c3abf01428..e8c9e167a5 100644 --- a/app/src/features/surveys/observations/sampling-sites/edit/components/SamplingSiteEditMapControl.tsx +++ b/app/src/features/surveys/observations/sampling-sites/edit/components/SamplingSiteEditMapControl.tsx @@ -204,7 +204,7 @@ const SamplingSiteEditMapControl = (props: ISamplingSiteEditMapControlProps) => setEditedGeometry([feature]); }); }} - onLayerDelete={(event: DrawEvents.Deleted) => { + onLayerDelete={() => { setFieldValue(name, sampleSiteData?.geojson ? [sampleSiteData?.geojson] : []); }} /> diff --git a/app/src/features/surveys/observations/sampling-sites/edit/components/SamplingStratumEditForm.tsx b/app/src/features/surveys/observations/sampling-sites/edit/components/SamplingStratumEditForm.tsx index 5a1d65956a..8987059acd 100644 --- a/app/src/features/surveys/observations/sampling-sites/edit/components/SamplingStratumEditForm.tsx +++ b/app/src/features/surveys/observations/sampling-sites/edit/components/SamplingStratumEditForm.tsx @@ -12,7 +12,7 @@ import Typography from '@mui/material/Typography'; import { SurveyContext } from 'contexts/surveyContext'; import { useFormikContext } from 'formik'; import { IGetSampleStratumDetails, IGetSurveyStratum } from 'interfaces/useSurveyApi.interface'; -import { default as React, useContext, useState } from 'react'; +import { useContext, useState } from 'react'; import { TransitionGroup } from 'react-transition-group'; import BlockStratumCard from './BlockStratumCard'; import { IEditSamplingSiteRequest } from './SampleSiteEditForm'; @@ -79,7 +79,7 @@ const SamplingStratumEditForm = () => { setSearchText(''); } }} - onClose={(value, reason) => { + onClose={() => { setSearchText(''); }} renderInput={(params) => ( @@ -130,9 +130,7 @@ const SamplingStratumEditForm = () => { }}> ) => handleRemoveItem(item)} - aria-label="settings"> + handleRemoveItem(item)} aria-label="settings"> } diff --git a/app/src/features/surveys/view/SurveyMap.tsx b/app/src/features/surveys/view/SurveyMap.tsx index c9c2228a82..0f86c8045f 100644 --- a/app/src/features/surveys/view/SurveyMap.tsx +++ b/app/src/features/surveys/view/SurveyMap.tsx @@ -238,7 +238,7 @@ const SurveyMap = (props: ISurveyMapProps) => { key: mapPoint.key, geoJSON: mapPoint.feature, GeoJSONProps: { - onEachFeature: (feature, layer) => { + onEachFeature: (_, layer) => { layer.on({ popupopen: () => { if (mapPointMetadata[mapPoint.key]) { diff --git a/app/src/features/surveys/view/components/SurveyGeneralInformation.tsx b/app/src/features/surveys/view/components/SurveyGeneralInformation.tsx index 548d0585fa..4653f45802 100644 --- a/app/src/features/surveys/view/components/SurveyGeneralInformation.tsx +++ b/app/src/features/surveys/view/components/SurveyGeneralInformation.tsx @@ -63,7 +63,7 @@ const SurveyGeneralInformation = () => { Species of Interest - {species.focal_species?.map((focalSpecies: ITaxonomy, index: number) => { + {species.focal_species?.map((focalSpecies: ITaxonomy) => { return ( { Secondary Species - {species.ancillary_species?.map((ancillarySpecies: ITaxonomy, index: number) => { + {species.ancillary_species?.map((ancillarySpecies: ITaxonomy) => { return ( { Vantage Code(s) - {surveyData.purpose_and_methodology.vantage_code_ids?.map((vc_id: number, index: number) => { + {surveyData.purpose_and_methodology.vantage_code_ids?.map((vc_id: number) => { return ( { + const point = (_feature: Feature, latlng: LatLng) => { return new L.CircleMarker(latlng, { radius: 5, fillOpacity: 1 }); }; diff --git a/database/.eslintrc b/database/.eslintrc index 2a457fb103..421e0f1cc2 100644 --- a/database/.eslintrc +++ b/database/.eslintrc @@ -1,7 +1,6 @@ { "extends": [ "eslint:recommended", - "prettier/@typescript-eslint", "plugin:prettier/recommended", "plugin:@typescript-eslint/eslint-recommended", "plugin:@typescript-eslint/recommended" @@ -16,6 +15,23 @@ "@typescript-eslint/no-explicit-any": "off", "@typescript-eslint/explicit-module-boundary-types": "off", "@typescript-eslint/ban-types": ["error", { "types": { "object": false, "extendDefaults": true } }], + "@typescript-eslint/ban-ts-comment": [ + "error", + { + "ts-expect-error": false, + "ts-ignore": false, + "ts-nocheck": false, + "ts-check": false + } + ], + "@typescript-eslint/no-unused-vars": [ + "error", + { + "args": "all", + "argsIgnorePattern": "^_", + "caughtErrorsIgnorePattern": "^_" + } + ], "no-var": "error" } } diff --git a/database/package-lock.json b/database/package-lock.json index ba4005bb71..f3da4095a9 100644 --- a/database/package-lock.json +++ b/database/package-lock.json @@ -17,14 +17,14 @@ "@faker-js/faker": "^8.0.2", "@types/node": "^18.15.3", "@types/pg": "^8.11.4", - "@typescript-eslint/eslint-plugin": "^4.33.0", - "@typescript-eslint/parser": "^4.33.0", - "eslint": "^7.32.0", - "eslint-config-prettier": "^6.15.0", - "eslint-plugin-prettier": "^3.3.1", + "@typescript-eslint/eslint-plugin": "~7.6.0", + "@typescript-eslint/parser": "~7.6.0", + "eslint": "~8.56.0", + "eslint-config-prettier": "~8.10.0", + "eslint-plugin-prettier": "~4.2.1", "npm-run-all": "^4.1.5", - "prettier": "^2.3.2", - "prettier-plugin-organize-imports": "^2.3.4", + "prettier": "^2.8.8", + "prettier-plugin-organize-imports": "^3.2.4", "ts-node": "^10.9.2" }, "engines": { @@ -41,149 +41,94 @@ "node": ">=0.10.0" } }, - "node_modules/@babel/code-frame": { - "version": "7.12.11", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.12.11.tgz", - "integrity": "sha512-Zt1yodBx1UcyiePMSkWnU4hPqhwq7hGi2nFL1LeA3EUl+q2LQx16MISgJ0+z7dnmgvP9QtIleuETGOiOH1RcIw==", - "dev": true, - "dependencies": { - "@babel/highlight": "^7.10.4" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.22.20", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz", - "integrity": "sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==", - "dev": true, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/highlight": { - "version": "7.24.2", - "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.24.2.tgz", - "integrity": "sha512-Yac1ao4flkTxTteCDZLEvdxg2fZfz1v8M4QpaGypq/WPDqg3ijHYbDfs+LG5hvzSoqaSZ9/Z9lKSP3CjZjv+pA==", + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", "dev": true, "dependencies": { - "@babel/helper-validator-identifier": "^7.22.20", - "chalk": "^2.4.2", - "js-tokens": "^4.0.0", - "picocolors": "^1.0.0" + "@jridgewell/trace-mapping": "0.3.9" }, "engines": { - "node": ">=6.9.0" + "node": ">=12" } }, - "node_modules/@babel/highlight/node_modules/ansi-styles": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", - "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "node_modules/@eslint-community/eslint-utils": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", + "integrity": "sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==", "dev": true, "dependencies": { - "color-convert": "^1.9.0" + "eslint-visitor-keys": "^3.3.0" }, "engines": { - "node": ">=4" - } - }, - "node_modules/@babel/highlight/node_modules/chalk": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", - "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", - "dev": true, - "dependencies": { - "ansi-styles": "^3.2.1", - "escape-string-regexp": "^1.0.5", - "supports-color": "^5.3.0" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, - "engines": { - "node": ">=4" - } - }, - "node_modules/@babel/highlight/node_modules/color-convert": { - "version": "1.9.3", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", - "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", - "dev": true, - "dependencies": { - "color-name": "1.1.3" + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, - "node_modules/@babel/highlight/node_modules/color-name": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", - "dev": true - }, - "node_modules/@babel/highlight/node_modules/escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "node_modules/@eslint-community/regexpp": { + "version": "4.10.0", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.10.0.tgz", + "integrity": "sha512-Cu96Sd2By9mCNTx2iyKOmq10v22jUVQv0lQnlGNy16oE9589yE+QADPbrMGCkA51cKZSg3Pu/aTJVTGfL/qjUA==", "dev": true, "engines": { - "node": ">=0.8.0" + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" } }, - "node_modules/@babel/highlight/node_modules/has-flag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", - "dev": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/@babel/highlight/node_modules/supports-color": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "node_modules/@eslint/eslintrc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", + "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", "dev": true, "dependencies": { - "has-flag": "^3.0.0" + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^9.6.0", + "globals": "^13.19.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" }, "engines": { - "node": ">=4" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" } }, - "node_modules/@cspotcode/source-map-support": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", - "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", "dev": true, "dependencies": { - "@jridgewell/trace-mapping": "0.3.9" - }, - "engines": { - "node": ">=12" + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" } }, - "node_modules/@eslint/eslintrc": { - "version": "0.4.3", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-0.4.3.tgz", - "integrity": "sha512-J6KFFz5QCYUJq3pf0mjEcCJVERbzv71PUIDczuh9JkwGEzced6CO5ADLHB1rbf/+oPBtoPfMYNOpGDzCANlbXw==", + "node_modules/@eslint/eslintrc/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", "dev": true, "dependencies": { - "ajv": "^6.12.4", - "debug": "^4.1.1", - "espree": "^7.3.0", - "globals": "^13.9.0", - "ignore": "^4.0.6", - "import-fresh": "^3.2.1", - "js-yaml": "^3.13.1", - "minimatch": "^3.0.4", - "strip-json-comments": "^3.1.1" + "brace-expansion": "^1.1.7" }, "engines": { - "node": "^10.12.0 || >=12.0.0" + "node": "*" } }, - "node_modules/@eslint/eslintrc/node_modules/ignore": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-4.0.6.tgz", - "integrity": "sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg==", + "node_modules/@eslint/js": { + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.56.0.tgz", + "integrity": "sha512-gMsVel9D7f2HLkBma9VbtzZRehRogVRfbr++f06nL2vnCGCNlzOD+/MUov/F4p8myyAHspEhVobgjpX64q5m6A==", "dev": true, "engines": { - "node": ">= 4" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, "node_modules/@faker-js/faker": { @@ -203,23 +148,58 @@ } }, "node_modules/@humanwhocodes/config-array": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.5.0.tgz", - "integrity": "sha512-FagtKFz74XrTl7y6HCzQpwDfXP0yhxe9lHLD1UZxjvZIcbyRz8zTFF/yYNfSfzU414eDwZ1SrO0Qvtyf+wFMQg==", + "version": "0.11.14", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz", + "integrity": "sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==", "dev": true, "dependencies": { - "@humanwhocodes/object-schema": "^1.2.0", - "debug": "^4.1.1", - "minimatch": "^3.0.4" + "@humanwhocodes/object-schema": "^2.0.2", + "debug": "^4.3.1", + "minimatch": "^3.0.5" }, "engines": { "node": ">=10.10.0" } }, + "node_modules/@humanwhocodes/config-array/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@humanwhocodes/config-array/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, "node_modules/@humanwhocodes/object-schema": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-1.2.1.tgz", - "integrity": "sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", + "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", "dev": true }, "node_modules/@jridgewell/resolve-uri": { @@ -313,18 +293,18 @@ "dev": true }, "node_modules/@types/node": { - "version": "18.19.29", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.29.tgz", - "integrity": "sha512-5pAX7ggTmWZdhUrhRWLPf+5oM7F80bcKVCBbr0zwEkTNzTJL2CWQjznpFgHYy6GrzkYi2Yjy7DHKoynFxqPV8g==", + "version": "18.19.31", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.31.tgz", + "integrity": "sha512-ArgCD39YpyyrtFKIqMDvjz79jto5fcI/SVUs2HwB+f0dAzq68yqOdyaSivLiLugSziTpNXLQrVb7RZFmdZzbhA==", "dev": true, "dependencies": { "undici-types": "~5.26.4" } }, "node_modules/@types/pg": { - "version": "8.11.4", - "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.11.4.tgz", - "integrity": "sha512-yw3Bwbda6vO+NvI1Ue/YKOwtl31AYvvd/e73O3V4ZkNzuGpTDndLSyc0dQRB2xrQqDePd20pEGIfqSp/GH3pRw==", + "version": "8.11.5", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.11.5.tgz", + "integrity": "sha512-2xMjVviMxneZHDHX5p5S6tsRRs7TpDHeeK7kTTMe/kAC/mRRNjWHjZg0rkiY+e17jXSZV3zJYDxXV8Cy72/Vuw==", "dev": true, "dependencies": { "@types/node": "*", @@ -332,88 +312,68 @@ "pg-types": "^4.0.1" } }, - "node_modules/@types/pg/node_modules/pg-types": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-4.0.2.tgz", - "integrity": "sha512-cRL3JpS3lKMGsKaWndugWQoLOCoP+Cic8oseVcbr0qhPzYD5DWXK+RZ9LY9wxRf7RQia4SCwQlXk0q6FCPrVng==", - "dev": true, - "dependencies": { - "pg-int8": "1.0.1", - "pg-numeric": "1.0.2", - "postgres-array": "~3.0.1", - "postgres-bytea": "~3.0.0", - "postgres-date": "~2.1.0", - "postgres-interval": "^3.0.0", - "postgres-range": "^1.1.1" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/@types/pg/node_modules/postgres-array": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-3.0.2.tgz", - "integrity": "sha512-6faShkdFugNQCLwucjPcY5ARoW1SlbnrZjmGl0IrrqewpvxvhSLHimCVzqeuULCbG0fQv7Dtk1yDbG3xv7Veog==", - "dev": true, - "engines": { - "node": ">=12" - } + "node_modules/@types/semver": { + "version": "7.5.8", + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.8.tgz", + "integrity": "sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ==", + "dev": true }, - "node_modules/@types/pg/node_modules/postgres-bytea": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-3.0.0.tgz", - "integrity": "sha512-CNd4jim9RFPkObHSjVHlVrxoVQXz7quwNFpz7RY1okNNme49+sVyiTvTRobiLV548Hx/hb1BG+iE7h9493WzFw==", - "dev": true, - "dependencies": { - "obuf": "~1.1.2" + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.6.0.tgz", + "integrity": "sha512-gKmTNwZnblUdnTIJu3e9kmeRRzV2j1a/LUO27KNNAnIC5zjy1aSvXSRp4rVNlmAoHlQ7HzX42NbKpcSr4jF80A==", + "dev": true, + "dependencies": { + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "7.6.0", + "@typescript-eslint/type-utils": "7.6.0", + "@typescript-eslint/utils": "7.6.0", + "@typescript-eslint/visitor-keys": "7.6.0", + "debug": "^4.3.4", + "graphemer": "^1.4.0", + "ignore": "^5.3.1", + "natural-compare": "^1.4.0", + "semver": "^7.6.0", + "ts-api-utils": "^1.3.0" }, "engines": { - "node": ">= 6" - } - }, - "node_modules/@types/pg/node_modules/postgres-date": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-2.1.0.tgz", - "integrity": "sha512-K7Juri8gtgXVcDfZttFKVmhglp7epKb1K4pgrkLxehjqkrgPhfG6OO8LHLkfaqkbpjNRnra018XwAr1yQFWGcA==", - "dev": true, - "engines": { - "node": ">=12" - } - }, - "node_modules/@types/pg/node_modules/postgres-interval": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-3.0.0.tgz", - "integrity": "sha512-BSNDnbyZCXSxgA+1f5UU2GmwhoI0aU5yMxRGO8CdFEcY2BQF9xm/7MqKnYoM1nJDk8nONNWDk9WeSmePFhQdlw==", - "dev": true, - "engines": { - "node": ">=12" + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^7.0.0", + "eslint": "^8.56.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, - "node_modules/@typescript-eslint/eslint-plugin": { - "version": "4.33.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-4.33.0.tgz", - "integrity": "sha512-aINiAxGVdOl1eJyVjaWn/YcVAq4Gi/Yo35qHGCnqbWVz61g39D0h23veY/MA0rFFGfxK7TySg2uwDeNv+JgVpg==", + "node_modules/@typescript-eslint/parser": { + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.6.0.tgz", + "integrity": "sha512-usPMPHcwX3ZoPWnBnhhorc14NJw9J4HpSXQX4urF2TPKG0au0XhJoZyX62fmvdHONUkmyUe74Hzm1//XA+BoYg==", "dev": true, "dependencies": { - "@typescript-eslint/experimental-utils": "4.33.0", - "@typescript-eslint/scope-manager": "4.33.0", - "debug": "^4.3.1", - "functional-red-black-tree": "^1.0.1", - "ignore": "^5.1.8", - "regexpp": "^3.1.0", - "semver": "^7.3.5", - "tsutils": "^3.21.0" + "@typescript-eslint/scope-manager": "7.6.0", + "@typescript-eslint/types": "7.6.0", + "@typescript-eslint/typescript-estree": "7.6.0", + "@typescript-eslint/visitor-keys": "7.6.0", + "debug": "^4.3.4" }, "engines": { - "node": "^10.12.0 || >=12.0.0" + "node": "^18.18.0 || >=20.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^4.0.0", - "eslint": "^5.0.0 || ^6.0.0 || ^7.0.0" + "eslint": "^8.56.0" }, "peerDependenciesMeta": { "typescript": { @@ -421,50 +381,43 @@ } } }, - "node_modules/@typescript-eslint/experimental-utils": { - "version": "4.33.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/experimental-utils/-/experimental-utils-4.33.0.tgz", - "integrity": "sha512-zeQjOoES5JFjTnAhI5QY7ZviczMzDptls15GFsI6jyUOq0kOf9+WonkhtlIhh0RgHRnqj5gdNxW5j1EvAyYg6Q==", + "node_modules/@typescript-eslint/scope-manager": { + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.6.0.tgz", + "integrity": "sha512-ngttyfExA5PsHSx0rdFgnADMYQi+Zkeiv4/ZxGYUWd0nLs63Ha0ksmp8VMxAIC0wtCFxMos7Lt3PszJssG/E6w==", "dev": true, "dependencies": { - "@types/json-schema": "^7.0.7", - "@typescript-eslint/scope-manager": "4.33.0", - "@typescript-eslint/types": "4.33.0", - "@typescript-eslint/typescript-estree": "4.33.0", - "eslint-scope": "^5.1.1", - "eslint-utils": "^3.0.0" + "@typescript-eslint/types": "7.6.0", + "@typescript-eslint/visitor-keys": "7.6.0" }, "engines": { - "node": "^10.12.0 || >=12.0.0" + "node": "^18.18.0 || >=20.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "*" } }, - "node_modules/@typescript-eslint/parser": { - "version": "4.33.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-4.33.0.tgz", - "integrity": "sha512-ZohdsbXadjGBSK0/r+d87X0SBmKzOq4/S5nzK6SBgJspFo9/CUDJ7hjayuze+JK7CZQLDMroqytp7pOcFKTxZA==", + "node_modules/@typescript-eslint/type-utils": { + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.6.0.tgz", + "integrity": "sha512-NxAfqAPNLG6LTmy7uZgpK8KcuiS2NZD/HlThPXQRGwz6u7MDBWRVliEEl1Gj6U7++kVJTpehkhZzCJLMK66Scw==", "dev": true, "dependencies": { - "@typescript-eslint/scope-manager": "4.33.0", - "@typescript-eslint/types": "4.33.0", - "@typescript-eslint/typescript-estree": "4.33.0", - "debug": "^4.3.1" + "@typescript-eslint/typescript-estree": "7.6.0", + "@typescript-eslint/utils": "7.6.0", + "debug": "^4.3.4", + "ts-api-utils": "^1.3.0" }, "engines": { - "node": "^10.12.0 || >=12.0.0" + "node": "^18.18.0 || >=20.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^5.0.0 || ^6.0.0 || ^7.0.0" + "eslint": "^8.56.0" }, "peerDependenciesMeta": { "typescript": { @@ -472,84 +425,99 @@ } } }, - "node_modules/@typescript-eslint/scope-manager": { - "version": "4.33.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-4.33.0.tgz", - "integrity": "sha512-5IfJHpgTsTZuONKbODctL4kKuQje/bzBRkwHE8UOZ4f89Zeddg+EGZs8PD8NcN4LdM3ygHWYB3ukPAYjvl/qbQ==", + "node_modules/@typescript-eslint/types": { + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.6.0.tgz", + "integrity": "sha512-h02rYQn8J+MureCvHVVzhl69/GAfQGPQZmOMjG1KfCl7o3HtMSlPaPUAPu6lLctXI5ySRGIYk94clD/AUMCUgQ==", "dev": true, - "dependencies": { - "@typescript-eslint/types": "4.33.0", - "@typescript-eslint/visitor-keys": "4.33.0" - }, "engines": { - "node": "^8.10.0 || ^10.13.0 || >=11.10.1" + "node": "^18.18.0 || >=20.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/@typescript-eslint/types": { - "version": "4.33.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-4.33.0.tgz", - "integrity": "sha512-zKp7CjQzLQImXEpLt2BUw1tvOMPfNoTAfb8l51evhYbOEEzdWyQNmHWWGPR6hwKJDAi+1VXSBmnhL9kyVTTOuQ==", + "node_modules/@typescript-eslint/typescript-estree": { + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.6.0.tgz", + "integrity": "sha512-+7Y/GP9VuYibecrCQWSKgl3GvUM5cILRttpWtnAu8GNL9j11e4tbuGZmZjJ8ejnKYyBRb2ddGQ3rEFCq3QjMJw==", "dev": true, + "dependencies": { + "@typescript-eslint/types": "7.6.0", + "@typescript-eslint/visitor-keys": "7.6.0", + "debug": "^4.3.4", + "globby": "^11.1.0", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^1.3.0" + }, "engines": { - "node": "^8.10.0 || ^10.13.0 || >=11.10.1" + "node": "^18.18.0 || >=20.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, - "node_modules/@typescript-eslint/typescript-estree": { - "version": "4.33.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-4.33.0.tgz", - "integrity": "sha512-rkWRY1MPFzjwnEVHsxGemDzqqddw2QbTJlICPD9p9I9LfsO8fdmfQPOX3uKfUaGRDFJbfrtm/sXhVXN4E+bzCA==", + "node_modules/@typescript-eslint/utils": { + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.6.0.tgz", + "integrity": "sha512-x54gaSsRRI+Nwz59TXpCsr6harB98qjXYzsRxGqvA5Ue3kQH+FxS7FYU81g/omn22ML2pZJkisy6Q+ElK8pBCA==", "dev": true, "dependencies": { - "@typescript-eslint/types": "4.33.0", - "@typescript-eslint/visitor-keys": "4.33.0", - "debug": "^4.3.1", - "globby": "^11.0.3", - "is-glob": "^4.0.1", - "semver": "^7.3.5", - "tsutils": "^3.21.0" + "@eslint-community/eslint-utils": "^4.4.0", + "@types/json-schema": "^7.0.15", + "@types/semver": "^7.5.8", + "@typescript-eslint/scope-manager": "7.6.0", + "@typescript-eslint/types": "7.6.0", + "@typescript-eslint/typescript-estree": "7.6.0", + "semver": "^7.6.0" }, "engines": { - "node": "^10.12.0 || >=12.0.0" + "node": "^18.18.0 || >=20.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } + "peerDependencies": { + "eslint": "^8.56.0" } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "4.33.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-4.33.0.tgz", - "integrity": "sha512-uqi/2aSz9g2ftcHWf8uLPJA70rUv6yuMW5Bohw+bwcuzaxQIHaKFZCKGoGXIrc9vkTJ3+0txM73K0Hq3d5wgIg==", + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.6.0.tgz", + "integrity": "sha512-4eLB7t+LlNUmXzfOu1VAIAdkjbu5xNSerURS9X/S5TUKWFRpXRQZbmtPqgKmYx8bj3J0irtQXSiWAOY82v+cgw==", "dev": true, "dependencies": { - "@typescript-eslint/types": "4.33.0", - "eslint-visitor-keys": "^2.0.0" + "@typescript-eslint/types": "7.6.0", + "eslint-visitor-keys": "^3.4.3" }, "engines": { - "node": "^8.10.0 || ^10.13.0 || >=11.10.1" + "node": "^18.18.0 || >=20.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@ungap/structured-clone": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz", + "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==", + "dev": true + }, "node_modules/acorn": { - "version": "7.4.1", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", - "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==", + "version": "8.11.3", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz", + "integrity": "sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==", "dev": true, "bin": { "acorn": "bin/acorn" @@ -592,15 +560,6 @@ "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/ansi-colors": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", - "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", - "dev": true, - "engines": { - "node": ">=6" - } - }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -632,13 +591,10 @@ "dev": true }, "node_modules/argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", - "dev": true, - "dependencies": { - "sprintf-js": "~1.0.2" - } + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true }, "node_modules/array-buffer-byte-length": { "version": "1.0.1", @@ -687,15 +643,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/astral-regex": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", - "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, "node_modules/available-typed-arrays": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", @@ -718,13 +665,12 @@ "dev": true }, "node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", "dev": true, "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" + "balanced-match": "^1.0.0" } }, "node_modules/braces": { @@ -807,11 +753,11 @@ "integrity": "sha512-3tlv/dIP7FWvj3BsbHrGLJ6l/oKh1O3TcgBqMn+yyCagOxc23fyzDS6HypQbgxWbkpDnf52p1LuR4eWDQ/K9WQ==" }, "node_modules/commander": { - "version": "9.5.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", - "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", + "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", "engines": { - "node": "^12.20.0 || >=14" + "node": ">=14" } }, "node_modules/concat-map": { @@ -980,25 +926,6 @@ "node": ">=6.0.0" } }, - "node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true - }, - "node_modules/enquirer": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/enquirer/-/enquirer-2.4.1.tgz", - "integrity": "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==", - "dev": true, - "dependencies": { - "ansi-colors": "^4.1.1", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8.6" - } - }, "node_modules/error-ex": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", @@ -1153,91 +1080,86 @@ } }, "node_modules/eslint": { - "version": "7.32.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-7.32.0.tgz", - "integrity": "sha512-VHZ8gX+EDfz+97jGcgyGCyRia/dPOd6Xh9yPv8Bl1+SoaIwD+a/vlrOmGRUyOYu7MwUhc7CxqeaDZU13S4+EpA==", - "dev": true, - "dependencies": { - "@babel/code-frame": "7.12.11", - "@eslint/eslintrc": "^0.4.3", - "@humanwhocodes/config-array": "^0.5.0", - "ajv": "^6.10.0", + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.56.0.tgz", + "integrity": "sha512-Go19xM6T9puCOWntie1/P997aXxFsOi37JIHRWI514Hc6ZnaHGKY9xFhrU65RT6CcBEzZoGG1e6Nq+DT04ZtZQ==", + "dev": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.6.1", + "@eslint/eslintrc": "^2.1.4", + "@eslint/js": "8.56.0", + "@humanwhocodes/config-array": "^0.11.13", + "@humanwhocodes/module-importer": "^1.0.1", + "@nodelib/fs.walk": "^1.2.8", + "@ungap/structured-clone": "^1.2.0", + "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.2", - "debug": "^4.0.1", + "debug": "^4.3.2", "doctrine": "^3.0.0", - "enquirer": "^2.3.5", "escape-string-regexp": "^4.0.0", - "eslint-scope": "^5.1.1", - "eslint-utils": "^2.1.0", - "eslint-visitor-keys": "^2.0.0", - "espree": "^7.3.1", - "esquery": "^1.4.0", + "eslint-scope": "^7.2.2", + "eslint-visitor-keys": "^3.4.3", + "espree": "^9.6.1", + "esquery": "^1.4.2", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^6.0.1", - "functional-red-black-tree": "^1.0.1", - "glob-parent": "^5.1.2", - "globals": "^13.6.0", - "ignore": "^4.0.6", - "import-fresh": "^3.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "globals": "^13.19.0", + "graphemer": "^1.4.0", + "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", - "js-yaml": "^3.13.1", + "is-path-inside": "^3.0.3", + "js-yaml": "^4.1.0", "json-stable-stringify-without-jsonify": "^1.0.1", "levn": "^0.4.1", "lodash.merge": "^4.6.2", - "minimatch": "^3.0.4", + "minimatch": "^3.1.2", "natural-compare": "^1.4.0", - "optionator": "^0.9.1", - "progress": "^2.0.0", - "regexpp": "^3.1.0", - "semver": "^7.2.1", - "strip-ansi": "^6.0.0", - "strip-json-comments": "^3.1.0", - "table": "^6.0.9", - "text-table": "^0.2.0", - "v8-compile-cache": "^2.0.3" + "optionator": "^0.9.3", + "strip-ansi": "^6.0.1", + "text-table": "^0.2.0" }, "bin": { "eslint": "bin/eslint.js" }, "engines": { - "node": "^10.12.0 || >=12.0.0" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, "funding": { "url": "https://opencollective.com/eslint" } }, "node_modules/eslint-config-prettier": { - "version": "6.15.0", - "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-6.15.0.tgz", - "integrity": "sha512-a1+kOYLR8wMGustcgAjdydMsQ2A/2ipRPwRKUmfYaSxc9ZPcrku080Ctl6zrZzZNs/U82MjSv+qKREkoq3bJaw==", + "version": "8.10.0", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-8.10.0.tgz", + "integrity": "sha512-SM8AMJdeQqRYT9O9zguiruQZaN7+z+E4eAP9oiLNGKMtomwaB1E9dcgUD6ZAn/eQAb52USbvezbiljfZUhbJcg==", "dev": true, - "dependencies": { - "get-stdin": "^6.0.0" - }, "bin": { - "eslint-config-prettier-check": "bin/cli.js" + "eslint-config-prettier": "bin/cli.js" }, "peerDependencies": { - "eslint": ">=3.14.1" + "eslint": ">=7.0.0" } }, "node_modules/eslint-plugin-prettier": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-3.3.1.tgz", - "integrity": "sha512-Rq3jkcFY8RYeQLgk2cCwuc0P7SEFwDravPhsJZOQ5N4YI4DSg50NyqJ/9gdZHzQlHf8MvafSesbNJCcP/FF6pQ==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-4.2.1.tgz", + "integrity": "sha512-f/0rXLXUt0oFYs8ra4w49wYZBG5GKZpAYsJSm6rnYL5uVDjd+zowwMwVZHnAjf4edNrKpCDYfXDgmRE/Ak7QyQ==", "dev": true, "dependencies": { "prettier-linter-helpers": "^1.0.0" }, "engines": { - "node": ">=6.0.0" + "node": ">=12.0.0" }, "peerDependencies": { - "eslint": ">=5.0.0", - "prettier": ">=1.13.0" + "eslint": ">=7.28.0", + "prettier": ">=2.0.0" }, "peerDependenciesMeta": { "eslint-config-prettier": { @@ -1246,76 +1168,53 @@ } }, "node_modules/eslint-scope": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", - "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", + "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", "dev": true, "dependencies": { "esrecurse": "^4.3.0", - "estraverse": "^4.1.1" - }, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/eslint-utils": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/eslint-utils/-/eslint-utils-3.0.0.tgz", - "integrity": "sha512-uuQC43IGctw68pJA1RgbQS8/NP7rch6Cwd4j3ZBtgo4/8Flj4eGE7ZYSZRN3iq5pVUv6GPdW5Z1RFleo84uLDA==", - "dev": true, - "dependencies": { - "eslint-visitor-keys": "^2.0.0" + "estraverse": "^5.2.0" }, "engines": { - "node": "^10.0.0 || ^12.0.0 || >= 14.0.0" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, "funding": { - "url": "https://github.com/sponsors/mysticatea" - }, - "peerDependencies": { - "eslint": ">=5" + "url": "https://opencollective.com/eslint" } }, "node_modules/eslint-visitor-keys": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-2.1.0.tgz", - "integrity": "sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw==", - "dev": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/eslint/node_modules/eslint-utils": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/eslint-utils/-/eslint-utils-2.1.0.tgz", - "integrity": "sha512-w94dQYoauyvlDc43XnGB8lU3Zt713vNChgt4EWwhXAP2XkBvndfxF0AgIqKOOasjPIPzj9JqgwkwbCYD0/V3Zg==", + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", "dev": true, - "dependencies": { - "eslint-visitor-keys": "^1.1.0" - }, "engines": { - "node": ">=6" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, "funding": { - "url": "https://github.com/sponsors/mysticatea" + "url": "https://opencollective.com/eslint" } }, - "node_modules/eslint/node_modules/eslint-utils/node_modules/eslint-visitor-keys": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-1.3.0.tgz", - "integrity": "sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ==", + "node_modules/eslint/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", "dev": true, - "engines": { - "node": ">=4" + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" } }, - "node_modules/eslint/node_modules/ignore": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-4.0.6.tgz", - "integrity": "sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg==", + "node_modules/eslint/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, "engines": { - "node": ">= 4" + "node": "*" } }, "node_modules/esm": { @@ -1327,39 +1226,20 @@ } }, "node_modules/espree": { - "version": "7.3.1", - "resolved": "https://registry.npmjs.org/espree/-/espree-7.3.1.tgz", - "integrity": "sha512-v3JCNCE64umkFpmkFGqzVKsOT0tN1Zr+ueqLZfpV1Ob8e+CEgPWa+OxCoGH3tnhimMKIaBm4m/vaRpJ/krRz2g==", + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", + "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", "dev": true, "dependencies": { - "acorn": "^7.4.0", - "acorn-jsx": "^5.3.1", - "eslint-visitor-keys": "^1.3.0" + "acorn": "^8.9.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^3.4.1" }, "engines": { - "node": "^10.12.0 || >=12.0.0" - } - }, - "node_modules/espree/node_modules/eslint-visitor-keys": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-1.3.0.tgz", - "integrity": "sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ==", - "dev": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/esprima": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", - "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "dev": true, - "bin": { - "esparse": "bin/esparse.js", - "esvalidate": "bin/esvalidate.js" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, - "engines": { - "node": ">=4" + "funding": { + "url": "https://opencollective.com/eslint" } }, "node_modules/esquery": { @@ -1374,15 +1254,6 @@ "node": ">=0.10" } }, - "node_modules/esquery/node_modules/estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "dev": true, - "engines": { - "node": ">=4.0" - } - }, "node_modules/esrecurse": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", @@ -1395,7 +1266,7 @@ "node": ">=4.0" } }, - "node_modules/esrecurse/node_modules/estraverse": { + "node_modules/estraverse": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", @@ -1404,15 +1275,6 @@ "node": ">=4.0" } }, - "node_modules/estraverse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", - "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", - "dev": true, - "engines": { - "node": ">=4.0" - } - }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -1450,6 +1312,18 @@ "node": ">=8.6.0" } }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -1495,6 +1369,22 @@ "node": ">=8" } }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/flat-cache": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", @@ -1556,12 +1446,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/functional-red-black-tree": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz", - "integrity": "sha512-dsKNQNdj6xA3T+QlADDA7mOSlX0qiMINjn0cgr+eGHGsbSHzTabcIogz2+p/iqP1Xs6EP/sS2SbqH+brGTbq0g==", - "dev": true - }, "node_modules/functions-have-names": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", @@ -1598,15 +1482,6 @@ "node": ">=8.0.0" } }, - "node_modules/get-stdin": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/get-stdin/-/get-stdin-6.0.0.tgz", - "integrity": "sha512-jp4tHawyV7+fkkSKyvjuLZswblUtz+SQKzSWnBbii16BuZksJlU1wuBYXY75r+duh/llF1ur6oNwi+2ZzjKZ7g==", - "dev": true, - "engines": { - "node": ">=4" - } - }, "node_modules/get-symbol-description": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.0.2.tgz", @@ -1650,15 +1525,37 @@ } }, "node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", "dev": true, "dependencies": { - "is-glob": "^4.0.1" + "is-glob": "^4.0.3" }, "engines": { - "node": ">= 6" + "node": ">=10.13.0" + } + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" } }, "node_modules/globals": { @@ -1729,6 +1626,12 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "dev": true }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true + }, "node_modules/has-bigints": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.0.2.tgz", @@ -1999,15 +1902,6 @@ "node": ">=0.10.0" } }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "engines": { - "node": ">=8" - } - }, "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -2056,6 +1950,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, "node_modules/is-regex": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz", @@ -2156,20 +2059,13 @@ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "dev": true }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true - }, "node_modules/js-yaml": { - "version": "3.14.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", - "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", "dev": true, "dependencies": { - "argparse": "^1.0.7", - "esprima": "^4.0.0" + "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" @@ -2209,12 +2105,12 @@ } }, "node_modules/knex": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/knex/-/knex-2.4.2.tgz", - "integrity": "sha512-tMI1M7a+xwHhPxjbl/H9K1kHX+VncEYcvCx5K00M16bWvpYPKAZd6QrCu68PtHAdIZNQPWZn0GVhqVBEthGWCg==", + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/knex/-/knex-2.5.1.tgz", + "integrity": "sha512-z78DgGKUr4SE/6cm7ku+jHvFT0X97aERh/f0MUKAKgFnwCYBEW4TFBqtHWFYiJFid7fMrtpZ/gxJthvz5mEByA==", "dependencies": { "colorette": "2.0.19", - "commander": "^9.1.0", + "commander": "^10.0.0", "debug": "4.3.4", "escalade": "^3.1.1", "esm": "^3.2.25", @@ -2222,7 +2118,7 @@ "getopts": "2.3.0", "interpret": "^2.2.0", "lodash": "^4.17.21", - "pg-connection-string": "2.5.0", + "pg-connection-string": "2.6.1", "rechoir": "^0.8.0", "resolve-from": "^5.0.0", "tarn": "^3.0.2", @@ -2294,6 +2190,21 @@ "node": ">=4" } }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/lodash": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", @@ -2305,12 +2216,6 @@ "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", "dev": true }, - "node_modules/lodash.truncate": { - "version": "4.4.2", - "resolved": "https://registry.npmjs.org/lodash.truncate/-/lodash.truncate-4.4.2.tgz", - "integrity": "sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==", - "dev": true - }, "node_modules/lru-cache": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", @@ -2361,15 +2266,18 @@ } }, "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "9.0.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.4.tgz", + "integrity": "sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw==", "dev": true, "dependencies": { - "brace-expansion": "^1.1.7" + "brace-expansion": "^2.0.1" }, "engines": { - "node": "*" + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/ms": { @@ -2447,6 +2355,16 @@ "node": ">=4" } }, + "node_modules/npm-run-all/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, "node_modules/npm-run-all/node_modules/chalk": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", @@ -2510,6 +2428,18 @@ "node": ">=4" } }, + "node_modules/npm-run-all/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/npm-run-all/node_modules/path-key": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-2.0.1.tgz", @@ -2641,6 +2571,36 @@ "node": ">= 0.8.0" } }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -2666,6 +2626,15 @@ "node": ">=4" } }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "engines": { + "node": ">=8" + } + }, "node_modules/path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", @@ -2731,9 +2700,9 @@ "optional": true }, "node_modules/pg-connection-string": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.5.0.tgz", - "integrity": "sha512-r5o/V/ORTA6TmUnyWZR9nCj1klXCO2CEKNRlVuJptZe85QuhFayC7WeMic7ndayT5IRIR0S0xFxFi2ousartlQ==" + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.6.1.tgz", + "integrity": "sha512-w6ZzNu6oMmIzEAYVw+RLK0+nqHPt8K3ZnknKi+g48Ak2pr3dtljJW3o+D/n2zzCG07Zoe9VOX3aiKpj+BN0pjg==" }, "node_modules/pg-int8": { "version": "1.0.1", @@ -2766,6 +2735,29 @@ "integrity": "sha512-jPIlvgoD63hrEuihvIg+tJhoGjUsLPn6poJY9N5CnlPd91c2T18T/9zBtLxZSb1EhYxBRoZJtzScCaWlYLtktg==" }, "node_modules/pg-types": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-4.0.2.tgz", + "integrity": "sha512-cRL3JpS3lKMGsKaWndugWQoLOCoP+Cic8oseVcbr0qhPzYD5DWXK+RZ9LY9wxRf7RQia4SCwQlXk0q6FCPrVng==", + "dev": true, + "dependencies": { + "pg-int8": "1.0.1", + "pg-numeric": "1.0.2", + "postgres-array": "~3.0.1", + "postgres-bytea": "~3.0.0", + "postgres-date": "~2.1.0", + "postgres-interval": "^3.0.0", + "postgres-range": "^1.1.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/pg/node_modules/pg-connection-string": { + "version": "2.6.4", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.6.4.tgz", + "integrity": "sha512-v+Z7W/0EO707aNMaAEfiGnGL9sxxumwLl2fJvCQtMn9Fxsg+lPpPkdcyBSv/KFgpGdYkMfn+EI1Or2EHjpgLCA==" + }, + "node_modules/pg/node_modules/pg-types": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", @@ -2780,10 +2772,40 @@ "node": ">=4" } }, - "node_modules/pg/node_modules/pg-connection-string": { - "version": "2.6.4", - "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.6.4.tgz", - "integrity": "sha512-v+Z7W/0EO707aNMaAEfiGnGL9sxxumwLl2fJvCQtMn9Fxsg+lPpPkdcyBSv/KFgpGdYkMfn+EI1Or2EHjpgLCA==" + "node_modules/pg/node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "engines": { + "node": ">=4" + } + }, + "node_modules/pg/node_modules/postgres-bytea": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.0.tgz", + "integrity": "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pg/node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pg/node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } }, "node_modules/pgpass": { "version": "1.0.5", @@ -2793,12 +2815,6 @@ "split2": "^4.1.0" } }, - "node_modules/picocolors": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", - "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==", - "dev": true - }, "node_modules/picomatch": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", @@ -2842,38 +2858,42 @@ } }, "node_modules/postgres-array": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", - "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-3.0.2.tgz", + "integrity": "sha512-6faShkdFugNQCLwucjPcY5ARoW1SlbnrZjmGl0IrrqewpvxvhSLHimCVzqeuULCbG0fQv7Dtk1yDbG3xv7Veog==", + "dev": true, "engines": { - "node": ">=4" + "node": ">=12" } }, "node_modules/postgres-bytea": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.0.tgz", - "integrity": "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-3.0.0.tgz", + "integrity": "sha512-CNd4jim9RFPkObHSjVHlVrxoVQXz7quwNFpz7RY1okNNme49+sVyiTvTRobiLV548Hx/hb1BG+iE7h9493WzFw==", + "dev": true, + "dependencies": { + "obuf": "~1.1.2" + }, "engines": { - "node": ">=0.10.0" + "node": ">= 6" } }, "node_modules/postgres-date": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", - "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-2.1.0.tgz", + "integrity": "sha512-K7Juri8gtgXVcDfZttFKVmhglp7epKb1K4pgrkLxehjqkrgPhfG6OO8LHLkfaqkbpjNRnra018XwAr1yQFWGcA==", + "dev": true, "engines": { - "node": ">=0.10.0" + "node": ">=12" } }, "node_modules/postgres-interval": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", - "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", - "dependencies": { - "xtend": "^4.0.0" - }, + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-3.0.0.tgz", + "integrity": "sha512-BSNDnbyZCXSxgA+1f5UU2GmwhoI0aU5yMxRGO8CdFEcY2BQF9xm/7MqKnYoM1nJDk8nONNWDk9WeSmePFhQdlw==", + "dev": true, "engines": { - "node": ">=0.10.0" + "node": ">=12" } }, "node_modules/postgres-range": { @@ -2919,22 +2939,23 @@ } }, "node_modules/prettier-plugin-organize-imports": { - "version": "2.3.4", - "resolved": "https://registry.npmjs.org/prettier-plugin-organize-imports/-/prettier-plugin-organize-imports-2.3.4.tgz", - "integrity": "sha512-R8o23sf5iVL/U71h9SFUdhdOEPsi3nm42FD/oDYIZ2PQa4TNWWuWecxln6jlIQzpZTDMUeO1NicJP6lLn2TtRw==", + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/prettier-plugin-organize-imports/-/prettier-plugin-organize-imports-3.2.4.tgz", + "integrity": "sha512-6m8WBhIp0dfwu0SkgfOxJqh+HpdyfqSSLfKKRZSFbDuEQXDDndb8fTpRWkUrX/uBenkex3MgnVk0J3b3Y5byog==", "dev": true, "peerDependencies": { + "@volar/vue-language-plugin-pug": "^1.0.4", + "@volar/vue-typescript": "^1.0.4", "prettier": ">=2.0", "typescript": ">=2.9" - } - }, - "node_modules/progress": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", - "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", - "dev": true, - "engines": { - "node": ">=0.4.0" + }, + "peerDependenciesMeta": { + "@volar/vue-language-plugin-pug": { + "optional": true + }, + "@volar/vue-typescript": { + "optional": true + } } }, "node_modules/punycode": { @@ -3021,27 +3042,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/regexpp": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/regexpp/-/regexpp-3.2.0.tgz", - "integrity": "sha512-pq2bWo9mVD43nbts2wGv17XLiNLya+GklZ8kaDLV2Z08gDCsGpnKn9BFMepvWuHCbyVvY7J5o5+BVvoQbmlJLg==", - "dev": true, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/mysticatea" - } - }, - "node_modules/require-from-string": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/resolve": { "version": "1.22.8", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", @@ -3254,23 +3254,6 @@ "node": ">=8" } }, - "node_modules/slice-ansi": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz", - "integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==", - "dev": true, - "dependencies": { - "ansi-styles": "^4.0.0", - "astral-regex": "^2.0.0", - "is-fullwidth-code-point": "^3.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/slice-ansi?sponsor=1" - } - }, "node_modules/spdx-correct": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", @@ -3311,26 +3294,6 @@ "node": ">= 10.x" } }, - "node_modules/sprintf-js": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", - "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", - "dev": true - }, - "node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/string.prototype.padend": { "version": "3.1.6", "resolved": "https://registry.npmjs.org/string.prototype.padend/-/string.prototype.padend-3.1.6.tgz", @@ -3454,44 +3417,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/table": { - "version": "6.8.2", - "resolved": "https://registry.npmjs.org/table/-/table-6.8.2.tgz", - "integrity": "sha512-w2sfv80nrAh2VCbqR5AK27wswXhqcck2AhfnNW76beQXskGZ1V12GwS//yYVa3d3fcvAip2OUnbDAjW2k3v9fA==", - "dev": true, - "dependencies": { - "ajv": "^8.0.1", - "lodash.truncate": "^4.4.2", - "slice-ansi": "^4.0.0", - "string-width": "^4.2.3", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=10.0.0" - } - }, - "node_modules/table/node_modules/ajv": { - "version": "8.12.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", - "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", - "dev": true, - "dependencies": { - "fast-deep-equal": "^3.1.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/table/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true - }, "node_modules/tarn": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/tarn/-/tarn-3.0.2.tgz", @@ -3526,6 +3451,18 @@ "node": ">=8.0" } }, + "node_modules/ts-api-utils": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.3.0.tgz", + "integrity": "sha512-UQMIo7pb8WRomKR1/+MFVLTroIvDVtMX3K6OUir8ynLyzB8Jeriont2bTAtmNPa1ekAgN7YPDyf6V+ygrdU+eQ==", + "dev": true, + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "typescript": ">=4.2.0" + } + }, "node_modules/ts-node": { "version": "10.9.2", "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", @@ -3569,39 +3506,6 @@ } } }, - "node_modules/ts-node/node_modules/acorn": { - "version": "8.11.3", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz", - "integrity": "sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==", - "dev": true, - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/tslib": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", - "dev": true - }, - "node_modules/tsutils": { - "version": "3.21.0", - "resolved": "https://registry.npmjs.org/tsutils/-/tsutils-3.21.0.tgz", - "integrity": "sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==", - "dev": true, - "dependencies": { - "tslib": "^1.8.1" - }, - "engines": { - "node": ">= 6" - }, - "peerDependencies": { - "typescript": ">=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta" - } - }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -3741,12 +3645,6 @@ "punycode": "^2.1.0" } }, - "node_modules/v8-compile-cache": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/v8-compile-cache/-/v8-compile-cache-2.4.0.tgz", - "integrity": "sha512-ocyWc3bAHBB/guyqJQVI5o4BZkPhznPYUG2ea80Gond/BgNWpap8TOmLSeeQG7bnh2KMISxskdADG59j7zruhw==", - "dev": true - }, "node_modules/v8-compile-cache-lib": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", @@ -3841,6 +3739,18 @@ "engines": { "node": ">=6" } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } } } } diff --git a/database/package.json b/database/package.json index 0b83be8310..08fa4c8274 100644 --- a/database/package.json +++ b/database/package.json @@ -18,8 +18,9 @@ "seed": "knex seed:run --knexfile ./src/knexfile.ts", "lint": "eslint src/ --ext .js,.ts", "lint-fix": "npm run lint -- --fix", - "format": "prettier --check \"./src/**/*.{js,jsx,ts,tsx,css,scss}\"", - "format-fix": "prettier --write \"./src/**/*.{js,jsx,ts,tsx,json,css,scss}\"" + "format": "prettier --loglevel=warn --check \"./src/**/*.{js,jsx,ts,tsx,css,scss}\"", + "format-fix": "prettier --loglevel=warn --write \"./src/**/*.{js,jsx,ts,tsx,json,css,scss}\"", + "fix": "npm-run-all -l -s lint-fix format-fix" }, "dependencies": { "knex": "^2.4.2", @@ -30,14 +31,14 @@ "@faker-js/faker": "^8.0.2", "@types/node": "^18.15.3", "@types/pg": "^8.11.4", - "@typescript-eslint/eslint-plugin": "^4.33.0", - "@typescript-eslint/parser": "^4.33.0", - "eslint": "^7.32.0", - "eslint-config-prettier": "^6.15.0", - "eslint-plugin-prettier": "^3.3.1", + "@typescript-eslint/eslint-plugin": "~7.6.0", + "@typescript-eslint/parser": "~7.6.0", + "eslint": "~8.56.0", + "eslint-config-prettier": "~8.10.0", + "eslint-plugin-prettier": "~4.2.1", "npm-run-all": "^4.1.5", - "prettier": "^2.3.2", - "prettier-plugin-organize-imports": "^2.3.4", + "prettier": "^2.8.8", + "prettier-plugin-organize-imports": "^3.2.4", "ts-node": "^10.9.2" } } From aedbe0704879a3c833fba29c7f887e8b455c3b28 Mon Sep 17 00:00:00 2001 From: Macgregor Aubertin-Young <108430771+mauberti-bc@users.noreply.github.com> Date: Tue, 16 Apr 2024 11:44:45 -0700 Subject: [PATCH 03/31] Species data standards (#1270) * standards page and api service * update standards page * standards endpoint and service tests * finish unit tests * linter * add standards to hamburger menu for smaller screens * fix openapi spec * linter * Fix prettier (bump eslint/prettier versions) * Update .eslintrc * Update .eslintrc Fix issues * re-run install * prettier changes & node update * linter * attempt to solve prettier issues * try to fix missing jsdoc issue * try to fix missing jsdoc issue again * move dataloader into page * Fix unrelated unit test (somehow broken) * fix tests --------- Co-authored-by: Nick Phura --- api/package-lock.json | 35 ++- .../{surveyId}/observations/index.test.ts | 44 +--- .../paths/standards/taxon/{tsn}/index.test.ts | 71 ++++++ api/src/paths/standards/taxon/{tsn}/index.ts | 224 ++++++++++++++++++ api/src/services/critterbase-service.test.ts | 2 +- api/src/services/critterbase-service.ts | 4 +- api/src/services/standards-service.test.ts | 71 ++++++ api/src/services/standards-service.ts | 62 +++++ app/package-lock.json | 6 +- app/src/AppRouter.tsx | 7 + app/src/components/layout/Header.test.tsx | 3 + app/src/components/layout/Header.tsx | 10 + .../SpeciesAutoCompleteFormikField.tsx | 2 +- .../standards/SpeciesStandardsPage.tsx | 50 ++++ .../view/SpeciesStandardsResults.tsx | 96 ++++++++ .../MarkingBodyLocationStandardCard.tsx | 29 +++ .../components/MeasurementStandardCard.tsx | 71 ++++++ .../components/SpeciesStandardsToolbar.tsx | 70 ++++++ app/src/hooks/api/useStandardsApi.test.ts | 42 ++++ app/src/hooks/api/useStandardsApi.ts | 28 +++ app/src/hooks/useBioHubApi.ts | 6 +- .../interfaces/useStandardsApi.interface.ts | 26 ++ 22 files changed, 910 insertions(+), 49 deletions(-) create mode 100644 api/src/paths/standards/taxon/{tsn}/index.test.ts create mode 100644 api/src/paths/standards/taxon/{tsn}/index.ts create mode 100644 api/src/services/standards-service.test.ts create mode 100644 api/src/services/standards-service.ts create mode 100644 app/src/features/standards/SpeciesStandardsPage.tsx create mode 100644 app/src/features/standards/view/SpeciesStandardsResults.tsx create mode 100644 app/src/features/standards/view/components/MarkingBodyLocationStandardCard.tsx create mode 100644 app/src/features/standards/view/components/MeasurementStandardCard.tsx create mode 100644 app/src/features/standards/view/components/SpeciesStandardsToolbar.tsx create mode 100644 app/src/hooks/api/useStandardsApi.test.ts create mode 100644 app/src/hooks/api/useStandardsApi.ts create mode 100644 app/src/interfaces/useStandardsApi.interface.ts diff --git a/api/package-lock.json b/api/package-lock.json index 65187482b5..ca49311cdf 100644 --- a/api/package-lock.json +++ b/api/package-lock.json @@ -11243,23 +11243,38 @@ } }, "node_modules/through2-filter": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/through2-filter/-/through2-filter-3.0.0.tgz", - "integrity": "sha512-jaRjI2WxN3W1V8/FMZ9HKIBXixtiqs3SQSX4/YGIiP3gL6djW48VoZq9tDqeCWs3MT8YY5wb/zli8VW8snY1CA==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/through2-filter/-/through2-filter-3.1.0.tgz", + "integrity": "sha512-VhZsTsfrIJjyUi6GeecnwcOJlmoqgIdGFDjqnV5ape+F1DN8GejfPO66XyIhoinxmxGImiUTrq9RwpTN5yszGA==", + "dev": true, + "dependencies": { + "through2": "^4.0.2" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/through2-filter/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", "dev": true, "dependencies": { - "through2": "~2.0.0", - "xtend": "~4.0.0" + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" } }, "node_modules/through2-filter/node_modules/through2": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/through2/-/through2-2.0.5.tgz", - "integrity": "sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==", + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/through2/-/through2-4.0.2.tgz", + "integrity": "sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw==", "dev": true, "dependencies": { - "readable-stream": "~2.3.6", - "xtend": "~4.0.1" + "readable-stream": "3" } }, "node_modules/tildify": { diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/observations/index.test.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/observations/index.test.ts index 8d0f0a0b66..d9a46301fc 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/observations/index.test.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/observations/index.test.ts @@ -5,9 +5,8 @@ import sinonChai from 'sinon-chai'; import * as db from '../../../../../../database/db'; import { HTTPError } from '../../../../../../errors/http-error'; import { ObservationRecordWithSamplingAndSubcountData } from '../../../../../../repositories/observation-repository'; -import { CBMeasurementUnit, CritterbaseService } from '../../../../../../services/critterbase-service'; +import { CritterbaseService } from '../../../../../../services/critterbase-service'; import { ObservationService } from '../../../../../../services/observation-service'; -import { PlatformService } from '../../../../../../services/platform-service'; import { getMockDBConnection, getRequestHandlerMocks } from '../../../../../../__mocks__/db'; import * as observationRecords from './index'; @@ -23,32 +22,14 @@ describe('insertUpdateSurveyObservationsWithMeasurements', () => { sinon.stub(db, 'getDBConnection').returns(dbConnectionObj); + const validateSurveyObservationsStub = sinon + .stub(ObservationService.prototype, 'validateSurveyObservations') + .resolves(true); + const insertUpdateSurveyObservationsStub = sinon .stub(ObservationService.prototype, 'insertUpdateSurveyObservationsWithMeasurements') .resolves(); - sinon.stub(CritterbaseService.prototype, 'getTaxonMeasurements').resolves({ - qualitative: [ - { - itis_tsn: 1, - taxon_measurement_id: '', - measurement_name: '', - measurement_desc: '', - options: [] - } - ], - quantitative: [ - { - itis_tsn: 1, - taxon_measurement_id: '', - measurement_name: '', - measurement_desc: '', - min_value: 0, - max_value: 100, - unit: CBMeasurementUnit.Values.centimeter - } - ] - }); const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); mockReq.params = { @@ -95,8 +76,13 @@ describe('insertUpdateSurveyObservationsWithMeasurements', () => { }; const requestHandler = observationRecords.insertUpdateSurveyObservationsWithMeasurements(); + await requestHandler(mockReq, mockRes, mockNext); + expect(validateSurveyObservationsStub).to.have.been.calledOnceWith( + surveyObservations, + sinon.match.instanceOf(CritterbaseService) + ); expect(insertUpdateSurveyObservationsStub).to.have.been.calledOnceWith(2, surveyObservations); expect(mockRes.statusValue).to.equal(204); expect(mockRes.jsonValue).to.eql(undefined); @@ -107,13 +93,11 @@ describe('insertUpdateSurveyObservationsWithMeasurements', () => { sinon.stub(db, 'getDBConnection').returns(dbConnectionObj); + sinon.stub(ObservationService.prototype, 'validateSurveyObservations').resolves(true); + sinon .stub(ObservationService.prototype, 'insertUpdateSurveyObservationsWithMeasurements') .rejects(new Error('a test error')); - sinon.stub(PlatformService.prototype, 'getTaxonomyByTsns').resolves([ - { tsn: '1234', scientificName: 'scientific name' }, - { tsn: '1234', scientificName: 'scientific name' } - ]); const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); @@ -151,9 +135,7 @@ describe('insertUpdateSurveyObservationsWithMeasurements', () => { } catch (actualError) { expect(dbConnectionObj.release).to.have.been.called; - expect((actualError as HTTPError).message).to.equal( - 'Error connecting to the Critterbase API: Error: API request failed with status code undefined' - ); + expect((actualError as HTTPError).message).to.equal('a test error'); } }); }); diff --git a/api/src/paths/standards/taxon/{tsn}/index.test.ts b/api/src/paths/standards/taxon/{tsn}/index.test.ts new file mode 100644 index 0000000000..faa4059f51 --- /dev/null +++ b/api/src/paths/standards/taxon/{tsn}/index.test.ts @@ -0,0 +1,71 @@ +import chai, { expect } from 'chai'; +import { describe } from 'mocha'; +import sinon from 'sinon'; +import sinonChai from 'sinon-chai'; +import { getSpeciesStandards } from '.'; +import * as db from '../../../../database/db'; +import { CBMeasurementUnit } from '../../../../services/critterbase-service'; +import { StandardsService } from '../../../../services/standards-service'; +import { getMockDBConnection, getRequestHandlerMocks } from '../../../../__mocks__/db'; + +chai.use(sinonChai); + +describe('standards/taxon/{tsn}', () => { + describe('getSpeciesStandards', () => { + afterEach(() => { + sinon.restore(); + }); + + it('get standards for a species', async () => { + const dbConnectionObj = getMockDBConnection(); + + sinon.stub(db, 'getDBConnection').returns(dbConnectionObj); + + const getSpeciesStandardsResult = { + tsn: 420183, + scientificName: 'caribou', + markingBodyLocations: [ + { + id: '', + key: '', + value: 'left ear' + } + ], + measurements: { + quantitative: [ + { + itis_tsn: 420183, + taxon_measurement_id: '', + min_value: 1, + max_value: 10, + measurement_name: 'body mass', + measurement_desc: 'weight of the body', + unit: 'kilogram' as CBMeasurementUnit + } + ], + qualitative: [ + { + itis_tsn: 420183, + taxon_measurement_id: '', + measurement_name: 'life stage', + measurement_desc: 'age class of the individual', + options: [] + } + ] + } + }; + + sinon.stub(StandardsService.prototype, 'getSpeciesStandards').resolves(getSpeciesStandardsResult); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + mockReq.params = { tsn: '123456' }; + + const requestHandler = getSpeciesStandards(); + + await requestHandler(mockReq, mockRes, mockNext); + + expect(mockRes.statusValue).to.equal(200); + expect(mockRes.jsonValue).to.eql(getSpeciesStandardsResult); + }); + }); +}); diff --git a/api/src/paths/standards/taxon/{tsn}/index.ts b/api/src/paths/standards/taxon/{tsn}/index.ts new file mode 100644 index 0000000000..1c08b78ead --- /dev/null +++ b/api/src/paths/standards/taxon/{tsn}/index.ts @@ -0,0 +1,224 @@ +import { RequestHandler } from 'express'; +import { Operation } from 'express-openapi'; +import { SYSTEM_ROLE } from '../../../../constants/roles'; +import { getDBConnection } from '../../../../database/db'; +import { authorizeRequestHandler } from '../../../../request-handlers/security/authorization'; +import { StandardsService } from '../../../../services/standards-service'; +import { getLogger } from '../../../../utils/logger'; + +const defaultLog = getLogger('paths/projects'); + +export const GET: Operation = [ + authorizeRequestHandler(() => { + return { + and: [ + { + validSystemRoles: [SYSTEM_ROLE.SYSTEM_ADMIN, SYSTEM_ROLE.DATA_ADMINISTRATOR], + discriminator: 'SystemRole' + } + ] + }; + }), + getSpeciesStandards() +]; + +GET.apiDoc = { + description: 'Gets lookup values for a tsn to describe what information can be uploaded for a given species.', + tags: ['standards'], + parameters: [ + { + in: 'path', + name: 'tsn', + schema: { + type: 'number' + }, + required: true + } + ], + security: [{ Bearer: [] }], + responses: { + 200: { + description: 'Species data standards response object.', + content: { + 'application/json': { + schema: { + type: 'object', + additionalProperties: false, + required: ['tsn', 'scientificName', 'measurements', 'markingBodyLocations'], + properties: { + tsn: { + type: 'integer' + }, + scientificName: { + type: 'string' + }, + measurements: { + type: 'object', + additionalProperties: false, + required: ['qualitative', 'quantitative'], + properties: { + qualitative: { + description: 'All qualitative measurement type definitions for the survey.', + type: 'array', + items: { + description: 'A qualitative measurement type definition, with array of valid/accepted options', + type: 'object', + additionalProperties: false, + required: ['itis_tsn', 'taxon_measurement_id', 'measurement_name', 'measurement_desc', 'options'], + properties: { + itis_tsn: { + type: 'integer', + nullable: true + }, + taxon_measurement_id: { + type: 'string' + }, + measurement_name: { + type: 'string' + }, + measurement_desc: { + type: 'string', + nullable: true + }, + options: { + description: 'Valid options for the measurement.', + type: 'array', + items: { + type: 'object', + additionalProperties: false, + required: ['qualitative_option_id', 'option_label', 'option_value', 'option_desc'], + properties: { + qualitative_option_id: { + type: 'string' + }, + option_label: { + type: 'string', + nullable: true + }, + option_value: { + type: 'number' + }, + option_desc: { + type: 'string', + nullable: true + } + } + } + } + } + } + }, + quantitative: { + description: 'All quantitative measurement type definitions for the survey.', + type: 'array', + items: { + description: 'A quantitative measurement type definition, with possible min/max constraint.', + type: 'object', + additionalProperties: false, + required: [ + 'itis_tsn', + 'taxon_measurement_id', + 'measurement_name', + 'measurement_desc', + 'min_value', + 'max_value', + 'unit' + ], + properties: { + itis_tsn: { + type: 'integer', + nullable: true + }, + taxon_measurement_id: { + type: 'string' + }, + measurement_name: { + type: 'string' + }, + measurement_desc: { + type: 'string', + nullable: true + }, + min_value: { + type: 'number', + nullable: true + }, + max_value: { + type: 'number', + nullable: true + }, + unit: { + type: 'string', + nullable: true + } + } + } + } + } + }, + markingBodyLocations: { + type: 'array', + items: { + required: ['value', 'key', 'id'], + additionalProperties: false, + type: 'object', + properties: { + value: { type: 'string' }, + key: { type: 'string' }, + id: { 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' + } + } +}; + +/** + * Get species data standards + * + * @returns {RequestHandler} + */ +export function getSpeciesStandards(): RequestHandler { + return async (req, res) => { + // TODO: const connection = getAPIUserDBConnection(); + const connection = getDBConnection(req['keycloak_token']); + + try { + const tsn = Number(req.params.tsn); + + await connection.open(); + + const standardsService = new StandardsService(connection); + + const getSpeciesStandardsResponse = await standardsService.getSpeciesStandards(tsn); + + await connection.commit(); + + return res.status(200).json(getSpeciesStandardsResponse); + } catch (error) { + defaultLog.error({ label: 'getSpeciesStandards', message: 'error', error }); + throw error; + } finally { + connection.release(); + } + }; +} diff --git a/api/src/services/critterbase-service.test.ts b/api/src/services/critterbase-service.test.ts index 64181d831b..b008d630fe 100644 --- a/api/src/services/critterbase-service.test.ts +++ b/api/src/services/critterbase-service.test.ts @@ -102,7 +102,7 @@ describe('CritterbaseService', () => { 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: 'tsn', value: 'asdf' }, { key: 'format', value: 'asSelect' } ]); }); diff --git a/api/src/services/critterbase-service.ts b/api/src/services/critterbase-service.ts index d5d079bb29..96ef217afd 100644 --- a/api/src/services/critterbase-service.ts +++ b/api/src/services/critterbase-service.ts @@ -341,9 +341,9 @@ export class CritterbaseService { return response; } - async getTaxonBodyLocations(taxon_id: string) { + async getTaxonBodyLocations(tsn: string) { return this._makeGetRequest(CbRoutes['taxon-marking-body-locations'], [ - { key: 'taxon_id', value: taxon_id }, + { key: 'tsn', value: tsn }, { key: 'format', value: 'asSelect' } ]); } diff --git a/api/src/services/standards-service.test.ts b/api/src/services/standards-service.test.ts new file mode 100644 index 0000000000..aad35dfbd7 --- /dev/null +++ b/api/src/services/standards-service.test.ts @@ -0,0 +1,71 @@ +import chai, { expect } from 'chai'; +import sinon from 'sinon'; +import sinonChai from 'sinon-chai'; +import { getMockDBConnection } from '../__mocks__/db'; +import { CBMeasurementUnit } from './critterbase-service'; +import { StandardsService } from './standards-service'; + +chai.use(sinonChai); + +describe('StandardsService', () => { + it('constructs', () => { + const mockDBConnection = getMockDBConnection(); + const standardsService = new StandardsService(mockDBConnection); + expect(standardsService).to.be.instanceof(StandardsService); + }); + + afterEach(() => { + sinon.restore(); + }); + + describe('getSpeciesStandards', () => { + it('should get species standards', async () => { + const mockTsn = 123456; + const mockDbConnection = getMockDBConnection(); + + const standardsService = new StandardsService(mockDbConnection); + + const getTaxonomyByTsnsStub = sinon + .stub(standardsService.platformService, 'getTaxonomyByTsns') + .resolves([{ tsn: String(mockTsn), scientificName: 'caribou' }]); + + const getTaxonBodyLocationsStub = sinon + .stub(standardsService.critterbaseService, 'getTaxonBodyLocations') + .resolves({ markingBodyLocations: [{ id: '', key: '', value: 'left ear' }] }); + + const getTaxonMeasurementsStub = sinon + .stub(standardsService.critterbaseService, 'getTaxonMeasurements') + .resolves({ + quantitative: [ + { + taxon_measurement_id: '', + itis_tsn: 0, + measurement_name: 'body mass', + min_value: 0, + max_value: 1, + measurement_desc: '', + unit: 'kilogram' as CBMeasurementUnit + } + ], + qualitative: [ + { + taxon_measurement_id: '', + itis_tsn: 0, + measurement_name: '', + measurement_desc: 'description', + options: [] + } + ] + }); + + const response = await standardsService.getSpeciesStandards(mockTsn); + + expect(getTaxonomyByTsnsStub).to.be.calledOnceWith([mockTsn]); + expect(getTaxonBodyLocationsStub).to.be.calledOnceWith(String(mockTsn)); + expect(getTaxonMeasurementsStub).to.be.calledOnceWith(String(mockTsn)); + + expect(response.measurements.quantitative[0].measurement_name).to.eql('body mass'); + expect(response.measurements.qualitative[0].measurement_desc).to.eql('description'); + }); + }); +}); diff --git a/api/src/services/standards-service.ts b/api/src/services/standards-service.ts new file mode 100644 index 0000000000..76bea02f87 --- /dev/null +++ b/api/src/services/standards-service.ts @@ -0,0 +1,62 @@ +import { IDBConnection } from '../database/db'; +import { + CBQualitativeMeasurementTypeDefinition, + CBQuantitativeMeasurementTypeDefinition, + CritterbaseService +} from './critterbase-service'; +import { DBService } from './db-service'; +import { PlatformService } from './platform-service'; + +export interface ISpeciesStandardsResponse { + tsn: number; + scientificName: string; + measurements: { + quantitative: CBQuantitativeMeasurementTypeDefinition[]; + qualitative: CBQualitativeMeasurementTypeDefinition[]; + }; + markingBodyLocations: { id: string; key: string; value: string }[]; +} + +/** + * Sample Stratum Repository + * + * @export + * @class SampleStratumService + * @extends {DBService} + */ +export class StandardsService extends DBService { + platformService: PlatformService; + critterbaseService: CritterbaseService; + + constructor(connection: IDBConnection) { + super(connection); + this.platformService = new PlatformService(connection); + this.critterbaseService = new CritterbaseService({ + keycloak_guid: this.connection.systemUserGUID(), + username: this.connection.systemUserIdentifier() + }); + } + + /** + * Gets all survey Sample Stratums. + * + * @param {number} surveySampleSiteId + * @return {*} {Promise} + * @memberof standardsService + */ + async getSpeciesStandards(tsn: number): Promise { + // Fetch all measurement type definitions from Critterbase for the unique taxon_measurement_ids + const response = await Promise.all([ + this.platformService.getTaxonomyByTsns([tsn]), + this.critterbaseService.getTaxonBodyLocations(String(tsn)), + this.critterbaseService.getTaxonMeasurements(String(tsn)) + ]); + + return { + tsn: tsn, + scientificName: response[0][0].scientificName, + markingBodyLocations: response[1], + measurements: response[2] + }; + } +} diff --git a/app/package-lock.json b/app/package-lock.json index 4a065a2f83..d494d6c426 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -20568,9 +20568,9 @@ } }, "node_modules/qs": { - "version": "6.12.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.12.0.tgz", - "integrity": "sha512-trVZiI6RMOkO476zLGaBIzszOdFPnCCXHPG9kn0yuS1uz6xdVxPfZdB3vUig9pxPFDM9BRAgz/YUIVQ1/vuiUg==", + "version": "6.12.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.12.1.tgz", + "integrity": "sha512-zWmv4RSuB9r2mYQw3zxQuHWeU+42aKi1wWig/j4ele4ygELZ7PEO6MM7rim9oAQH2A5MWfsAVf/jPvTPgCbvUQ==", "dependencies": { "side-channel": "^1.0.6" }, diff --git a/app/src/AppRouter.tsx b/app/src/AppRouter.tsx index 546faec54f..3a0ce4c006 100644 --- a/app/src/AppRouter.tsx +++ b/app/src/AppRouter.tsx @@ -6,6 +6,7 @@ import AdminUsersRouter from 'features/admin/AdminUsersRouter'; import FundingSourcesRouter from 'features/funding-sources/FundingSourcesRouter'; import ProjectsRouter from 'features/projects/ProjectsRouter'; import ResourcesPage from 'features/resources/ResourcesPage'; +import SpeciesStandardsPage from 'features/standards/SpeciesStandardsPage'; import BaseLayout from 'layouts/BaseLayout'; import AccessDenied from 'pages/403/AccessDenied'; import NotFoundPage from 'pages/404/NotFoundPage'; @@ -98,6 +99,12 @@ const AppRouter: React.FC = () => { + + + + + + diff --git a/app/src/components/layout/Header.test.tsx b/app/src/components/layout/Header.test.tsx index 8357e71f51..1f6af9a314 100644 --- a/app/src/components/layout/Header.test.tsx +++ b/app/src/components/layout/Header.test.tsx @@ -22,6 +22,7 @@ describe('Header', () => { expect(getByText('Projects')).toBeVisible(); expect(getByText('Manage Users')).toBeVisible(); + expect(getByText('Standards')).toBeVisible(); }); it('renders correctly with system admin role (BCeID Business)', () => { @@ -40,6 +41,7 @@ describe('Header', () => { expect(getByText('Projects')).toBeVisible(); expect(getByText('Manage Users')).toBeVisible(); + expect(getByText('Standards')).toBeVisible(); }); it('renders correctly with system admin role (BCeID Basic)', () => { @@ -58,6 +60,7 @@ describe('Header', () => { expect(getByText('Projects')).toBeVisible(); expect(getByText('Manage Users')).toBeVisible(); + expect(getByText('Standards')).toBeVisible(); }); it('renders the username and logout button', () => { diff --git a/app/src/components/layout/Header.tsx b/app/src/components/layout/Header.tsx index 4a8f537391..037e051e30 100644 --- a/app/src/components/layout/Header.tsx +++ b/app/src/components/layout/Header.tsx @@ -277,6 +277,11 @@ const Header: React.FC = () => { Funding Sources + + + Standards + + Support @@ -349,6 +354,11 @@ const Header: React.FC = () => { Funding Sources + + + Standards + + diff --git a/app/src/components/dialog/attachments/ReportAttachmentDetails.tsx b/app/src/components/dialog/attachments/ReportAttachmentDetails.tsx index 10d595fe8e..82bfa42d56 100644 --- a/app/src/components/dialog/attachments/ReportAttachmentDetails.tsx +++ b/app/src/components/dialog/attachments/ReportAttachmentDetails.tsx @@ -16,8 +16,8 @@ const useStyles = () => { return { docTitle: { display: '-webkit-box', - '-webkit-line-clamp': 2, - '-webkit-box-orient': 'vertical', + WebkitLineClamp: '2', + WebkitBoxOrient: 'vertical', overflow: 'hidden' }, docDL: { diff --git a/app/src/components/map/components/MarkerWithResizableRadius.tsx b/app/src/components/map/components/MarkerWithResizableRadius.tsx index 73316fe510..0fcc99f4ec 100644 --- a/app/src/components/map/components/MarkerWithResizableRadius.tsx +++ b/app/src/components/map/components/MarkerWithResizableRadius.tsx @@ -84,7 +84,7 @@ const MarkerWithResizableRadius = (props: IClickMarkerProps): JSX.Element => { return ( <> - {props?.radius ? ( + {radius ? ( void; + isDisabled: boolean; + setIsDisabled: (isDisabled: boolean) => void; +}; + +export const ObservationsPageContext = createContext(undefined); + +export const ObservationsPageContextProvider = (props: PropsWithChildren>) => { + const [isLoading, setIsLoading] = useState(false); + const [isDisabled, setIsDisabled] = useState(false); + + return ( + + {props.children} + + ); +}; diff --git a/app/src/contexts/observationsTableContext.tsx b/app/src/contexts/observationsTableContext.tsx index 213fb4ddb3..c09f4d1078 100644 --- a/app/src/contexts/observationsTableContext.tsx +++ b/app/src/contexts/observationsTableContext.tsx @@ -25,7 +25,7 @@ import { import { APIError } from 'hooks/api/useAxios'; import { IObservationTableRowToSave, SubcountToSave } from 'hooks/api/useObservationApi'; import { useBiohubApi } from 'hooks/useBioHubApi'; -import { useObservationsContext, useTaxonomyContext } from 'hooks/useContext'; +import { useObservationsContext, useObservationsPageContext, useTaxonomyContext } from 'hooks/useContext'; import { useCritterbaseApi } from 'hooks/useCritterbaseApi'; import { CBMeasurementType, @@ -256,20 +256,24 @@ export type IObservationsTableContext = { /** * Used to disable the entire table. */ - disabled: boolean; + isDisabled: boolean; /** * Sets the disabled state of the table. */ - setDisabled: React.Dispatch>; + setIsDisabled: React.Dispatch>; }; +export type IObservationsTableContextProviderProps = PropsWithChildren; + export const ObservationsTableContext = createContext(undefined); -export const ObservationsTableContextProvider = (props: PropsWithChildren) => { +export const ObservationsTableContextProvider = (props: IObservationsTableContextProviderProps) => { const { projectId, surveyId } = useContext(SurveyContext); const _muiDataGridApiRef = useGridApiRef(); + const observationsPageContext = useObservationsPageContext(); + const { observationsDataLoader: { data: observationsData, @@ -278,6 +282,7 @@ export const ObservationsTableContextProvider = (props: PropsWithChildren) => { refresh: refreshObservationsData } } = useObservationsContext(); + const critterbaseApi = useCritterbaseApi(); const { cacheSpeciesTaxonomyByIds } = useTaxonomyContext(); @@ -318,10 +323,14 @@ export const ObservationsTableContextProvider = (props: PropsWithChildren) => { // Stores any measurement columns that are not part of the default observation table columns const [measurementColumns, setMeasurementColumns] = useState([]); - const _hasLoadedMeasurementColumns = useRef(false); + + // Internal disabled state for the observations table, should not be used outside of this context + const [_isDisabled, setIsDisabled] = useState(false); // Global disabled state for the observations table - const [disabled, setDisabled] = useState(false); + const isDisabled = useMemo(() => { + return _isDisabled || observationsPageContext.isDisabled; + }, [_isDisabled, observationsPageContext.isDisabled]); // Column visibility model const [columnVisibilityModel, setColumnVisibilityModel] = useState({}); @@ -363,7 +372,7 @@ export const ObservationsTableContextProvider = (props: PropsWithChildren) => { const refreshObservationRecords = useCallback(async () => { const sort = firstOrNull(sortModel); - const response = await refreshObservationsData({ + return refreshObservationsData({ limit: paginationModel.pageSize, sort: sort?.field || undefined, order: sort?.sort || undefined, @@ -371,39 +380,7 @@ export const ObservationsTableContextProvider = (props: PropsWithChildren) => { // API pagination pages begin at 1, but MUI DataGrid pagination begins at 0. page: paginationModel.page + 1 }); - - if (response) { - setMeasurementColumns(() => { - // Existing measurement definitions from the observations data - const existingMeasurementDefinitions = [ - ...response.supplementaryObservationData.qualitative_measurements, - ...response.supplementaryObservationData.quantitative_measurements - ]; - - // Get all measurement definitions from local storage, if any - const measurementDefinitionsStringified = sessionStorage.getItem( - getSurveySessionStorageKey(surveyId, SIMS_OBSERVATIONS_MEASUREMENT_COLUMNS) - ); - - let localStorageMeasurementDefinitions: CBMeasurementType[] = []; - if (measurementDefinitionsStringified) { - localStorageMeasurementDefinitions = JSON.parse(measurementDefinitionsStringified) as CBMeasurementType[]; - } - - // Remove any duplicate measurement definitions that already exist in the observations data - localStorageMeasurementDefinitions = localStorageMeasurementDefinitions.filter((item1) => { - return !existingMeasurementDefinitions.some( - (item2) => item2.taxon_measurement_id === item1.taxon_measurement_id - ); - }); - - // Set measurement columns, including both existing and local storage measurement definitions - return [...existingMeasurementDefinitions, ...localStorageMeasurementDefinitions]; - }); - } - - return response; - }, [hasError, paginationModel.page, paginationModel.pageSize, refreshObservationsData, sortModel, surveyId]); + }, [paginationModel.page, paginationModel.pageSize, refreshObservationsData, sortModel]); /** * Gets all rows from the table, including values that have been edited in the table. @@ -427,16 +404,19 @@ export const ObservationsTableContextProvider = (props: PropsWithChildren) => { /** * Fetches measurement definitions from Critterbase for a given itis_tsn number */ - const tsnMeasurements = useCallback(async (tsn: number): Promise => { - const currentMap = tsnMeasurementMapRef.current; - if (!currentMap[tsn]) { - const response = await critterbaseApi.xref.getTaxonMeasurements(tsn); - - currentMap[String(tsn)] = response; - tsnMeasurementMapRef.current = currentMap; - } - return currentMap[tsn]; - }, []); + const tsnMeasurements = useCallback( + async (tsn: number): Promise => { + const currentMap = tsnMeasurementMapRef.current; + if (!currentMap[tsn]) { + const response = await critterbaseApi.xref.getTaxonMeasurements(tsn); + + currentMap[String(tsn)] = response; + tsnMeasurementMapRef.current = currentMap; + } + return currentMap[tsn]; + }, + [critterbaseApi.xref] + ); /** * Validates all rows belonging to the table. Returns null if validation passes, otherwise @@ -492,7 +472,7 @@ export const ObservationsTableContextProvider = (props: PropsWithChildren) => { setValidationModel(validation); return Object.keys(validation).length > 0 ? validation : null; - }, [_getRowsWithEditedValues, _muiDataGridApiRef]); + }, [_getRowsWithEditedValues, _muiDataGridApiRef, tsnMeasurements]); /** * Deletes the given records from the server and removes them from the table. @@ -830,7 +810,7 @@ export const ObservationsTableContextProvider = (props: PropsWithChildren) => { // Store ids of rows that were in edit mode setModifiedRowIds(editingIdsToSave); - }, [_validateRows, _muiDataGridApiRef, savedRows, stagedRows]); + }, [_muiDataGridApiRef, _validateRows, setErrorDialog, savedRows, stagedRows]); /** * Transition all rows tracked by `modifiedRowIds` to edit mode. @@ -864,8 +844,8 @@ export const ObservationsTableContextProvider = (props: PropsWithChildren) => { // True if the taxonomy cache is still initializing or the observations data is still loading const isLoading: boolean = useMemo(() => { - return !taxonomyCacheStatus.isInitialized || isLoadingObservationsData; - }, [isLoadingObservationsData, taxonomyCacheStatus.isInitialized]); + return !taxonomyCacheStatus.isInitialized || isLoadingObservationsData || observationsPageContext.isLoading; + }, [isLoadingObservationsData, observationsPageContext.isLoading, taxonomyCacheStatus.isInitialized]); // True if the save process has started const isSaving: boolean = _isSavingData.current || _isStoppingEdit.current; @@ -1078,24 +1058,6 @@ export const ObservationsTableContextProvider = (props: PropsWithChildren) => { return rowsToDisplay; }, []); - /** - * Load and set the initial measurement columns, if any. - * Should only run once on initial page load. - */ - useEffect(() => { - if (isLoadingObservationsData || !observationsData) { - // Observations data is still loading - return; - } - - if (_hasLoadedMeasurementColumns.current) { - // Already loaded measurement definitions - return; - } - - _hasLoadedMeasurementColumns.current = true; - }, [isLoadingObservationsData, observationsData, hasError, surveyId, measurementColumns.length]); - /** * Fetch new rows based on sort/ pagination model changes */ @@ -1105,6 +1067,44 @@ export const ObservationsTableContextProvider = (props: PropsWithChildren) => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [paginationModel, sortModel]); + /** + * Runs when the observations data is loaded or refreshed. + * Set the measurement columns. + */ + useEffect(() => { + if (!observationsData) { + return; + } + + setMeasurementColumns(() => { + // Existing measurement definitions from the observations data + const existingMeasurementDefinitions = [ + ...observationsData.supplementaryObservationData.qualitative_measurements, + ...observationsData.supplementaryObservationData.quantitative_measurements + ]; + + // Get all measurement definitions from local storage, if any + const measurementDefinitionsStringified = sessionStorage.getItem( + getSurveySessionStorageKey(surveyId, SIMS_OBSERVATIONS_MEASUREMENT_COLUMNS) + ); + + let localStorageMeasurementDefinitions: CBMeasurementType[] = []; + if (measurementDefinitionsStringified) { + localStorageMeasurementDefinitions = JSON.parse(measurementDefinitionsStringified) as CBMeasurementType[]; + } + + // Remove any duplicate measurement definitions that already exist in the observations data + localStorageMeasurementDefinitions = localStorageMeasurementDefinitions.filter((item1) => { + return !existingMeasurementDefinitions.some( + (item2) => item2.taxon_measurement_id === item1.taxon_measurement_id + ); + }); + + // Set measurement columns, including both existing and local storage measurement definitions + return [...existingMeasurementDefinitions, ...localStorageMeasurementDefinitions]; + }); + }, [observationsData, surveyId]); + /** * Runs when observation context data has changed. This does not occur when records are * deleted; Only on initial page load, and whenever records are saved. @@ -1251,8 +1251,8 @@ export const ObservationsTableContextProvider = (props: PropsWithChildren) => { sortModel, measurementColumns, setMeasurementColumns, - disabled, - setDisabled + isDisabled, + setIsDisabled }), [ _muiDataGridApiRef, @@ -1277,7 +1277,7 @@ export const ObservationsTableContextProvider = (props: PropsWithChildren) => { hasError, sortModel, measurementColumns, - disabled + isDisabled ] ); diff --git a/app/src/features/resources/ResourcesPage.tsx b/app/src/features/resources/ResourcesPage.tsx index 0d8f8261c0..8aa5e091a5 100644 --- a/app/src/features/resources/ResourcesPage.tsx +++ b/app/src/features/resources/ResourcesPage.tsx @@ -31,8 +31,8 @@ const useStyles = () => { }, pageTitle: { display: '-webkit-box', - '-webkit-line-clamp': 2, - '-webkit-box-orient': 'vertical', + WebkitLineClamp: '2', + WebkitBoxOrient: 'vertical', paddingTop: theme.spacing(0.5), paddingBottom: theme.spacing(0.5), overflow: 'hidden' diff --git a/app/src/features/surveys/observations/SurveyObservationPage.tsx b/app/src/features/surveys/observations/SurveyObservationPage.tsx index 944799cc4f..db48531090 100644 --- a/app/src/features/surveys/observations/SurveyObservationPage.tsx +++ b/app/src/features/surveys/observations/SurveyObservationPage.tsx @@ -2,13 +2,14 @@ import Box from '@mui/material/Box'; import CircularProgress from '@mui/material/CircularProgress'; import Stack from '@mui/material/Stack'; import { DialogContextProvider } from 'contexts/dialogContext'; +import { ObservationsPageContextProvider } from 'contexts/observationsPageContext'; import { ObservationsTableContext, ObservationsTableContextProvider } from 'contexts/observationsTableContext'; import { ProjectContext } from 'contexts/projectContext'; import { SurveyContext } from 'contexts/surveyContext'; import { TaxonomyContextProvider } from 'contexts/taxonomyContext'; import { useContext } from 'react'; import ObservationsTableContainer from './observations-table/ObservationsTableContainer'; -import SamplingSiteList from './sampling-sites/SamplingSiteList'; +import SamplingSiteList from './sampling-sites/list/SamplingSiteList'; import SurveyObservationHeader from './SurveyObservationHeader'; export const SurveyObservationPage = () => { @@ -36,39 +37,42 @@ export const SurveyObservationPage = () => { survey_name={surveyContext.surveyDataLoader.data.surveyData.survey_details.survey_name} /> - - {/* Sampling Site List */} - - - - - + + + {/* Sampling Site List */} + + + + + - {/* Observations Component */} - - - - - - {(context) => { - if (!context?._muiDataGridApiRef.current) { - return ; - } + {/* Observations Table */} + + + + + + {(context) => { + if (!context?._muiDataGridApiRef.current) { + // Delay rendering the ObservationsTable until the DataGrid API is available + return ; + } - return ; - }} - - - - - - + return ; + }} + + + + + + + ); }; diff --git a/app/src/features/surveys/observations/observations-table/ObservationsTableContainer.tsx b/app/src/features/surveys/observations/observations-table/ObservationsTableContainer.tsx index 0565338c5d..02b7b0f7b9 100644 --- a/app/src/features/surveys/observations/observations-table/ObservationsTableContainer.tsx +++ b/app/src/features/surveys/observations/observations-table/ObservationsTableContainer.tsx @@ -31,9 +31,9 @@ import { SampleSiteColDef, TaxonomyColDef } from 'features/surveys/observations/observations-table/grid-column-definitions/GridColumnDefinitions'; -import { ImportObservationsButton } from 'features/surveys/observations/observations-table/import-observations/ImportObservationsButton'; +import { ImportObservationsButton } from 'features/surveys/observations/observations-table/import-obsevations/ImportObservationsButton'; import ObservationsTable from 'features/surveys/observations/observations-table/ObservationsTable'; -import { useCodesContext, useObservationsTableContext } from 'hooks/useContext'; +import { useCodesContext, useObservationsPageContext, useObservationsTableContext } from 'hooks/useContext'; import { IGetSampleLocationDetails, IGetSampleMethodRecord, @@ -49,6 +49,7 @@ const ObservationComponent = () => { const surveyContext = useContext(SurveyContext); + const observationsPageContext = useObservationsPageContext(); const observationsTableContext = useObservationsTableContext(); // Collect sample sites @@ -127,38 +128,41 @@ const ObservationComponent = () => { observationsTableContext.setDisabled(true)} + disabled={observationsTableContext.isSaving || observationsTableContext.isDisabled} + onStart={() => observationsPageContext.setIsDisabled(true)} onSuccess={() => observationsTableContext.refreshObservationRecords()} - onFinish={() => observationsTableContext.setDisabled(false)} + onFinish={() => observationsPageContext.setIsDisabled(false)} /> observationsTableContext.saveObservationRecords()} - disabled={observationsTableContext.isSaving}> + disabled={observationsTableContext.isSaving || observationsTableContext.isDisabled}> Save observationsTableContext.discardChanges()} /> - - + + @@ -175,7 +179,7 @@ const ObservationComponent = () => { isLoading={ observationsTableContext.isLoading || observationsTableContext.isSaving || - observationsTableContext.disabled + observationsTableContext.isDisabled } columns={columns} /> diff --git a/app/src/features/surveys/observations/observations-table/bulk-actions/BulkActionsButton.tsx b/app/src/features/surveys/observations/observations-table/bulk-actions/BulkActionsButton.tsx index cd1fb20ccc..f511606c9a 100644 --- a/app/src/features/surveys/observations/observations-table/bulk-actions/BulkActionsButton.tsx +++ b/app/src/features/surveys/observations/observations-table/bulk-actions/BulkActionsButton.tsx @@ -36,7 +36,8 @@ export const BulkActionsButton = (props: IBulkActionsButtonProps) => { }} edge="end" disabled={numSelectedRows === 0} - aria-label="observation options"> + aria-label="observation options" + title="Bulk Actions">