diff --git a/api/src/database-models/environment_qualitative.ts b/api/src/database-models/environment_qualitative.ts new file mode 100644 index 0000000000..13aa2ad611 --- /dev/null +++ b/api/src/database-models/environment_qualitative.ts @@ -0,0 +1,35 @@ +import { z } from 'zod'; + +/** + * Environment Qualitative Model. + * + * @description Data model for `environment_qualitative`. + */ +export const EnvironmentQualitativeModel = z.object({ + environment_qualitative_id: z.string().uuid(), + name: z.string(), + description: z.string().nullable(), + record_end_date: 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() +}); + +export type EnvironmentQualitativeModel = z.infer; + +/** + * Environment Qualitative Record. + * + * @description Data record for `environment_qualitative`. + */ +export const EnvironmentQualitativeRecord = EnvironmentQualitativeModel.omit({ + create_date: true, + create_user: true, + update_date: true, + update_user: true, + revision_count: true +}); + +export type EnvironmentQualitativeRecord = z.infer; diff --git a/api/src/database-models/environment_qualitative_option.ts b/api/src/database-models/environment_qualitative_option.ts new file mode 100644 index 0000000000..197c700a30 --- /dev/null +++ b/api/src/database-models/environment_qualitative_option.ts @@ -0,0 +1,36 @@ +import { z } from 'zod'; + +/** + * Environment Qualitative Option Model. + * + * @description Data model for `environment_qualitative_option`. + */ +export const EnvironmentQualitativeOptionModel = z.object({ + environment_qualitative_option_id: z.string(), + environment_qualitative_id: z.string(), + name: z.string(), + description: z.string().nullable(), + record_end_date: 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() +}); + +export type EnvironmentQualitativeOptionModel = z.infer; + +/** + * Environment Qualitative Option Record. + * + * @description Data record for `environment_qualitative_option`. + */ +export const EnvironmentQualitativeOptionRecord = EnvironmentQualitativeOptionModel.omit({ + create_date: true, + create_user: true, + update_date: true, + update_user: true, + revision_count: true +}); + +export type EnvironmentQualitativeOptionRecord = z.infer; diff --git a/api/src/database-models/environment_quantitative.ts b/api/src/database-models/environment_quantitative.ts new file mode 100644 index 0000000000..c920e42d4b --- /dev/null +++ b/api/src/database-models/environment_quantitative.ts @@ -0,0 +1,39 @@ +import { z } from 'zod'; +import { EnvironmentUnit } from '../database-units/environment_unit'; + +/** + * Environment Quantitative Model. + * + * @description Data model for `environment_quantitative`. + */ +export const EnvironmentQuantitativeModel = 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(), + record_end_date: 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() +}); + +export type EnvironmentQuantitativeModel = z.infer; + +/** + * Environment Quantitative Record. + * + * @description Data record for `environment_quantitative`. + */ +export const EnvironmentQuantitativeRecord = EnvironmentQuantitativeModel.omit({ + create_date: true, + create_user: true, + update_date: true, + update_user: true, + revision_count: true +}); + +export type EnvironmentQuantitativeRecord = z.infer; diff --git a/api/src/database-models/observation_environment_qualitative.ts b/api/src/database-models/observation_environment_qualitative.ts new file mode 100644 index 0000000000..2b04501f7c --- /dev/null +++ b/api/src/database-models/observation_environment_qualitative.ts @@ -0,0 +1,35 @@ +import { z } from 'zod'; + +/** + * Observation Environment Qualitative Model. + * + * @description Data model for `observation_environment_qualitative`. + */ +export const ObservationEnvironmentQualitativeModel = z.object({ + observation_environment_qualitative_id: z.number(), + survey_observation_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 ObservationEnvironmentQualitativeModel = z.infer; + +/** + * Observation Environment Qualitative Record. + * + * @description Data record for `observation_environment_qualitative`. + */ +export const ObservationEnvironmentQualitativeRecord = ObservationEnvironmentQualitativeModel.omit({ + create_date: true, + create_user: true, + update_date: true, + update_user: true, + revision_count: true +}); + +export type ObservationEnvironmentQualitativeRecord = z.infer; diff --git a/api/src/database-models/observation_environment_quantitative.ts b/api/src/database-models/observation_environment_quantitative.ts new file mode 100644 index 0000000000..c01e68e940 --- /dev/null +++ b/api/src/database-models/observation_environment_quantitative.ts @@ -0,0 +1,35 @@ +import { z } from 'zod'; + +/** + * Observation Environment Quantitative Model. + * + * @description Data model for `observation_environment_quantitative`. + */ +export const ObservationEnvironmentQuantitativeModel = z.object({ + observation_environment_quantitative_id: z.number(), + survey_observation_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 ObservationEnvironmentQuantitativeModel = z.infer; + +/** + * Observation Environment Quantitative Record. + * + * @description Data record for `observation_environment_quantitative`. + */ +export const ObservationEnvironmentQuantitativeRecord = ObservationEnvironmentQuantitativeModel.omit({ + create_date: true, + create_user: true, + update_date: true, + update_user: true, + revision_count: true +}); + +export type ObservationEnvironmentQuantitativeRecord = z.infer; diff --git a/api/src/database-models/observation_sign.ts b/api/src/database-models/observation_sign.ts new file mode 100644 index 0000000000..ef9027391c --- /dev/null +++ b/api/src/database-models/observation_sign.ts @@ -0,0 +1,35 @@ +import { z } from 'zod'; + +/** + * Observation Sign Model. + * + * @description Data model for `observation_sign`. + */ +export const ObservationSignModel = z.object({ + observation_sign_id: z.number(), + name: z.string(), + description: z.string().nullable(), + record_end_date: 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() +}); + +export type ObservationSignModel = z.infer; + +/** + * Observation Sign Record. + * + * @description Data record for `observation_sign`. + */ +export const ObservationSignRecord = ObservationSignModel.omit({ + create_date: true, + create_user: true, + update_date: true, + update_user: true, + revision_count: true +}); + +export type ObservationSignRecord = z.infer; diff --git a/api/src/database-models/observation_subcount.ts b/api/src/database-models/observation_subcount.ts new file mode 100644 index 0000000000..c248af9f68 --- /dev/null +++ b/api/src/database-models/observation_subcount.ts @@ -0,0 +1,35 @@ +import { z } from 'zod'; + +/** + * Observation Subcount Model. + * + * @description Data model for `observation_subcount`. + */ +export const ObservationSubcountModel = z.object({ + observation_subcount_id: z.number(), + survey_observation_id: z.number(), + subcount: z.number(), + comment: 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() +}); + +export type ObservationSubcountModel = z.infer; + +/** + * Observation Subcount Record. + * + * @description Data record for `observation_subcount`. + */ +export const ObservationSubcountRecord = ObservationSubcountModel.omit({ + create_date: true, + create_user: true, + update_date: true, + update_user: true, + revision_count: true +}); + +export type ObservationSubcountRecord = z.infer; diff --git a/api/src/database-models/subcount_critter.ts b/api/src/database-models/subcount_critter.ts new file mode 100644 index 0000000000..5defa6e303 --- /dev/null +++ b/api/src/database-models/subcount_critter.ts @@ -0,0 +1,34 @@ +import { z } from 'zod'; + +/** + * Subcount Critter Model. + * + * @description Data model for `subcount_critter`. + */ +export const SubcountCritterModel = z.object({ + subcount_critter_id: z.number(), + observation_subcount_id: z.number(), + critter_id: 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 SubcountCritterModel = z.infer; + +/** + * Subcount Critter Record. + * + * @description Data record for `subcount_critter`. + */ +export const SubcountCritterRecord = SubcountCritterModel.omit({ + create_date: true, + create_user: true, + update_date: true, + update_user: true, + revision_count: true +}); + +export type SubcountCritterRecord = z.infer; diff --git a/api/src/database-models/survey_observation.ts b/api/src/database-models/survey_observation.ts index 49f901027a..d7659cd07e 100644 --- a/api/src/database-models/survey_observation.ts +++ b/api/src/database-models/survey_observation.ts @@ -16,6 +16,7 @@ export const SurveyObservationModel = z.object({ count: z.number(), observation_time: z.string().nullable(), observation_date: z.string().nullable(), + observation_sign_id: z.number().nullable(), create_date: z.string(), create_user: z.number(), update_date: z.string().nullable(), diff --git a/api/src/database-units/environment_unit.ts b/api/src/database-units/environment_unit.ts new file mode 100644 index 0000000000..3a23b45f7d --- /dev/null +++ b/api/src/database-units/environment_unit.ts @@ -0,0 +1,23 @@ +import { z } from 'zod'; + +/** + * Environment Unit Data Type. + * + * @description Data type for `environment_unit`. + */ +export const EnvironmentUnit = z.enum([ + 'millimeter', + 'centimeter', + 'meter', + 'milligram', + 'gram', + 'kilogram', + 'percent', + 'celsius', + 'ppt', + 'SCF', + 'degrees', + 'pH' +]); + +export type EnvironmentUnit = z.infer; diff --git a/api/src/models/biohub-create.test.ts b/api/src/models/biohub-create.test.ts index eb62c4d649..b056f7bf1a 100644 --- a/api/src/models/biohub-create.test.ts +++ b/api/src/models/biohub-create.test.ts @@ -24,7 +24,8 @@ describe('PostSurveyObservationToBiohubObject', () => { itis_tsn: 1, itis_scientific_name: 'itis_scientific_name', observation_time: 'observation_time', - observation_date: 'observation_date' + observation_date: 'observation_date', + observation_sign_id: 1 }; before(() => { @@ -81,7 +82,8 @@ describe('PostSurveyToBiohubObject', () => { itis_tsn: 1, itis_scientific_name: 'itis_scientific_name', observation_time: 'observation_time', - observation_date: 'observation_date' + observation_date: 'observation_date', + observation_sign_id: 1 }; const survey_obj: GetSurveyData = { @@ -153,7 +155,8 @@ describe('PostSurveySubmissionToBioHubObject', () => { itis_tsn: 2, itis_scientific_name: 'itis_scientific_name', observation_time: 'observation_time', - observation_date: 'observation_date' + observation_date: 'observation_date', + observation_sign_id: 1 } ]; diff --git a/api/src/openapi/schemas/observation.ts b/api/src/openapi/schemas/observation.ts index de96532500..c6ce98e416 100644 --- a/api/src/openapi/schemas/observation.ts +++ b/api/src/openapi/schemas/observation.ts @@ -1,5 +1,105 @@ import { OpenAPIV3 } from 'openapi-types'; +const ObservationQualitativeEnvironment: OpenAPIV3.SchemaObject = { + type: 'object', + additionalProperties: false, + required: [ + 'observation_environment_qualitative_id', + 'environment_qualitative_id', + 'environment_qualitative_option_id' + ], + properties: { + observation_environment_qualitative_id: { + type: 'integer', + minimum: 1 + }, + environment_qualitative_id: { + type: 'string', + format: 'uuid' + }, + environment_qualitative_option_id: { + type: 'string', + format: 'uuid' + } + } +}; + +const ObservationQuantitativeEnvironment: OpenAPIV3.SchemaObject = { + type: 'object', + additionalProperties: false, + required: ['observation_environment_quantitative_id', 'environment_quantitative_id', 'value'], + properties: { + observation_environment_quantitative_id: { + type: 'integer', + minimum: 1 + }, + environment_quantitative_id: { + type: 'string', + format: 'uuid' + }, + value: { + type: 'number' + } + } +}; + +const ObservationSubcountSchema: OpenAPIV3.SchemaObject = { + type: 'object', + description: + 'An observation subcount record. Each subcount defines additional attributes/details about a subset of the observed taxa in the observation.', + additionalProperties: false, + required: ['observation_subcount_id', 'subcount', 'comment', 'qualitative_measurements', 'quantitative_measurements'], + properties: { + observation_subcount_id: { + type: 'integer', + minimum: 1 + }, + comment: { + type: 'string', + nullable: true, + description: 'A comment or note about the subcount record.' + }, + 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' + } + } + } + } + } +}; + export const findObservationsSchema: OpenAPIV3.SchemaObject = { type: 'array', items: { @@ -15,13 +115,16 @@ export const findObservationsSchema: OpenAPIV3.SchemaObject = { 'count', 'observation_date', 'observation_time', + 'observation_sign_id', 'subcounts', 'survey_sample_site_id', 'survey_sample_site_name', 'method_technique_id', 'method_technique_name', 'survey_sample_period_id', - 'survey_sample_period_start_datetime' + 'survey_sample_period_start_datetime', + 'qualitative_environments', + 'quantitative_environments' ], properties: { survey_observation_id: { @@ -33,7 +136,8 @@ export const findObservationsSchema: OpenAPIV3.SchemaObject = { minimum: 1 }, itis_tsn: { - type: 'integer' + type: 'integer', + minimum: 1 }, itis_scientific_name: { type: 'string', @@ -62,122 +166,137 @@ export const findObservationsSchema: OpenAPIV3.SchemaObject = { type: 'string', nullable: true }, + observation_sign_id: { + type: 'integer', + minimum: 1, + description: + 'The observation sign ID, indicating whether the observation was a direct sighting, footprints, scat, etc.' + }, + qualitative_environments: { + type: 'array', + items: ObservationQualitativeEnvironment + }, + quantitative_environments: { + type: 'array', + items: ObservationQuantitativeEnvironment + }, subcounts: { type: 'array', - items: { - type: 'object', - additionalProperties: false, - required: [ - 'observation_subcount_id', - 'subcount', - 'observation_subcount_sign_id', - 'comment', - 'qualitative_measurements', - 'quantitative_measurements', - 'qualitative_environments', - 'quantitative_environments' - ], - properties: { - observation_subcount_id: { - type: 'integer' - }, - observation_subcount_sign_id: { - type: 'integer', - minimum: 1, - description: - 'The observation subcount sign ID, indicating whether the subcount was a direct sighting, footprints, scat, etc.' - }, - comment: { - type: 'string', - nullable: true, - description: 'A comment or note about the subcount record.' - }, - 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: [ - 'observation_subcount_qualitative_environment_id', - '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: ['observation_subcount_quantitative_environment_id', 'environment_quantitative_id', 'value'], - properties: { - observation_subcount_quantitative_environment_id: { - type: 'integer' - }, - environment_quantitative_id: { - type: 'string', - format: 'uuid' - }, - value: { - type: 'number' - } - } - } - } - } - } + description: 'All subcount records for the observation.', + items: ObservationSubcountSchema + }, + survey_sample_site_id: { + type: 'integer', + minimum: 1, + nullable: true + }, + survey_sample_site_name: { + type: 'string', + nullable: true + }, + method_technique_id: { + type: 'integer', + minimum: 1, + nullable: true + }, + method_technique_name: { + type: 'string', + nullable: true + }, + survey_sample_period_id: { + type: 'integer', + minimum: 1, + nullable: true + }, + survey_sample_period_start_datetime: { + type: 'string', + nullable: true + } + } + } +}; + +export const findFlattenedObservationsSchema: OpenAPIV3.SchemaObject = { + type: 'array', + items: { + type: 'object', + additionalProperties: false, + required: [ + 'survey_observation_id', + 'survey_id', + 'itis_tsn', + 'itis_scientific_name', + 'latitude', + 'longitude', + 'count', + 'observation_date', + 'observation_time', + 'observation_sign_id', + 'subcount', + 'survey_sample_site_id', + 'survey_sample_site_name', + 'method_technique_id', + 'method_technique_name', + 'survey_sample_period_id', + 'survey_sample_period_start_datetime', + 'qualitative_environments', + 'quantitative_environments' + ], + properties: { + survey_observation_id: { + type: 'integer', + minimum: 1 + }, + survey_id: { + type: 'integer', + minimum: 1 + }, + itis_tsn: { + type: 'integer', + minimum: 1 + }, + itis_scientific_name: { + type: 'string', + nullable: true + }, + latitude: { + type: 'number', + nullable: true, + minimum: -90, + maximum: 90 + }, + longitude: { + type: 'number', + nullable: true, + minimum: -180, + maximum: 180 + }, + count: { + type: 'integer' + }, + observation_date: { + type: 'string', + nullable: true + }, + observation_time: { + type: 'string', + nullable: true + }, + observation_sign_id: { + type: 'integer', + minimum: 1, + description: + 'The observation sign ID, indicating whether the observation was a direct sighting, footprints, scat, etc.' + }, + qualitative_environments: { + type: 'array', + items: ObservationQualitativeEnvironment + }, + quantitative_environments: { + type: 'array', + items: ObservationQuantitativeEnvironment }, + subcount: ObservationSubcountSchema, survey_sample_site_id: { type: 'integer', minimum: 1, diff --git a/api/src/paths/codes.ts b/api/src/paths/codes.ts index ca5f7966ca..7926117b4b 100644 --- a/api/src/paths/codes.ts +++ b/api/src/paths/codes.ts @@ -40,7 +40,7 @@ GET.apiDoc = { 'survey_progress', 'method_response_metrics', 'attractants', - 'observation_subcount_signs', + 'observation_signs', 'telemetry_device_makes', 'frequency_units', 'alert_types', @@ -368,10 +368,10 @@ GET.apiDoc = { } } }, - observation_subcount_signs: { + observation_signs: { type: 'array', description: - 'Possible observation subcount sign ids, indicating whether the subcount was a direct sighting, footprints, scat, etc.', + 'Possible observation sign ids, indicating whether the observation was a direct sighting, footprints, scat, etc.', items: { type: 'object', additionalProperties: false, diff --git a/api/src/paths/observation/flattened/index.test.ts b/api/src/paths/observation/flattened/index.test.ts new file mode 100644 index 0000000000..14878ffd10 --- /dev/null +++ b/api/src/paths/observation/flattened/index.test.ts @@ -0,0 +1,220 @@ +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 { FlattenedObservationRecordWithSamplingAndSubcountData } from '../../../repositories/observation-repository/observation-repository'; +import { ObservationService } from '../../../services/observation-services/observation-service'; +import { KeycloakUserInformation } from '../../../utils/keycloak-utils'; +import { getMockDBConnection, getRequestHandlerMocks } from '../../../__mocks__/db'; +import { findFlattenedObservations } from './index'; + +chai.use(sinonChai); + +describe('findFlattenedObservations', () => { + afterEach(() => { + sinon.restore(); + }); + + it('finds and returns flattened observations', async () => { + const mockFindFlattenedObservationsResponse: FlattenedObservationRecordWithSamplingAndSubcountData[] = [ + { + 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_site_id: 7, + survey_sample_site_name: 'SITE_NAME', + method_technique_id: 8, + method_technique_name: 'TECHNIQUE_NAME', + survey_sample_period_id: 1, + survey_sample_period_start_datetime: '2000-01-01 00:00:00', + observation_sign_id: 1, + qualitative_environments: [], + quantitative_environments: [], + subcount: { + observation_subcount_id: 9, + subcount: 5, + comment: 'comment', + qualitative_measurements: [], + quantitative_measurements: [] + } + } + ]; + + const mockDBConnection = getMockDBConnection({ + open: sinon.stub(), + commit: sinon.stub(), + release: sinon.stub(), + systemUserId: () => 20 + }); + + sinon.stub(db, 'getDBConnection').returns(mockDBConnection); + + const findFlattenedObservationsStub = sinon + .stub(ObservationService.prototype, 'findFlattenedObservations') + .resolves(mockFindFlattenedObservationsResponse); + + const findFlattenedObservationsCountStub = sinon + .stub(ObservationService.prototype, 'findFlattenedObservationsCount') + .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 = {} as KeycloakUserInformation; + mockReq.system_user = { + system_user_id: 20, + user_guid: '123-456-789', + user_identifier: 'test-identifier', + identity_source: 'IDIR', + display_name: 'test-user', + given_name: 'test-given', + family_name: 'test-family', + email: 'test-email', + agency: 'test-agency', + record_end_date: null, + role_ids: [1], + role_names: [SYSTEM_ROLE.SYSTEM_ADMIN] + }; + + const requestHandler = findFlattenedObservations(); + + await requestHandler(mockReq, mockRes, mockNext); + + expect(mockDBConnection.open).to.have.been.calledOnce; + expect(mockDBConnection.commit).to.have.been.calledOnce; + + expect(findFlattenedObservationsStub).to.have.been.calledOnceWith(true, 20, sinon.match.object, sinon.match.object); + expect(findFlattenedObservationsCountStub).to.have.been.calledOnceWith(true, 20, sinon.match.object); + + expect(mockRes.jsonValue.surveyObservations).to.eql(mockFindFlattenedObservationsResponse); + expect(mockRes.jsonValue.pagination).not.to.be.null; + + expect(mockDBConnection.release).to.have.been.calledOnce; + }); + + it('catches and re-throws error', async () => { + const mockFindFlattenedObservationsResponse: FlattenedObservationRecordWithSamplingAndSubcountData[] = [ + { + 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_site_id: 7, + survey_sample_site_name: 'SITE_NAME', + method_technique_id: 8, + method_technique_name: 'TECHNIQUE_NAME', + survey_sample_period_id: 1, + survey_sample_period_start_datetime: '2000-01-01 00:00:00', + observation_sign_id: 1, + qualitative_environments: [], + quantitative_environments: [], + subcount: { + observation_subcount_id: 9, + subcount: 5, + comment: 'comment', + qualitative_measurements: [], + quantitative_measurements: [] + } + } + ]; + + const mockDBConnection = getMockDBConnection({ + open: sinon.stub(), + commit: sinon.stub(), + rollback: sinon.stub(), + release: sinon.stub(), + systemUserId: () => 20 + }); + + sinon.stub(db, 'getDBConnection').returns(mockDBConnection); + + const findFlattenedObservationsStub = sinon + .stub(ObservationService.prototype, 'findFlattenedObservations') + .resolves(mockFindFlattenedObservationsResponse); + + const findFlattenedObservationsCountStub = sinon + .stub(ObservationService.prototype, 'findFlattenedObservationsCount') + .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 = {} as KeycloakUserInformation; + mockReq.system_user = { + system_user_id: 20, + user_guid: '123-456-789', + user_identifier: 'test-identifier', + identity_source: 'IDIR', + display_name: 'test-user', + given_name: 'test-given', + family_name: 'test-family', + email: 'test-email', + agency: 'test-agency', + record_end_date: null, + role_ids: [3], + role_names: [SYSTEM_ROLE.PROJECT_CREATOR] + }; + + const requestHandler = findFlattenedObservations(); + + try { + await requestHandler(mockReq, mockRes, mockNext); + expect.fail(); + } catch (actualError) { + expect(mockDBConnection.open).to.have.been.calledOnce; + + expect(findFlattenedObservationsStub).to.have.been.calledOnceWith( + false, + 20, + sinon.match.object, + sinon.match.object + ); + expect(findFlattenedObservationsCountStub).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/flattened/index.ts b/api/src/paths/observation/flattened/index.ts new file mode 100644 index 0000000000..4e7869f097 --- /dev/null +++ b/api/src/paths/observation/flattened/index.ts @@ -0,0 +1,254 @@ +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 { findFlattenedObservationsSchema } from '../../../openapi/schemas/observation'; +import { paginationRequestQueryParamSchema, paginationResponseSchema } from '../../../openapi/schemas/pagination'; +import { authorizeRequestHandler, userHasValidRole } from '../../../request-handlers/security/authorization'; +import { ObservationService } from '../../../services/observation-services/observation-service'; +import { getLogger } from '../../../utils/logger'; +import { + ensureCompletePaginationOptions, + makePaginationOptionsFromRequest, + makePaginationResponse +} from '../../../utils/pagination'; +import { getSystemUserFromRequest } from '../../../utils/request'; + +const defaultLog = getLogger('paths/observation/index'); + +export const GET: Operation = [ + authorizeRequestHandler(() => { + return { + and: [ + { + discriminator: 'SystemUser' + } + ] + }; + }), + findFlattenedObservations() +]; + +GET.apiDoc = { + description: "Gets a list of flattened observation records 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', + minimum: 1 + }, + nullable: true + } + }, + { + in: 'query', + name: 'itis_tsn', + description: 'ITIS TSN number', + required: false, + schema: { + type: 'integer', + minimum: 1, + 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: 'Flattened observation response object.', + content: { + 'application/json': { + schema: { + type: 'object', + additionalProperties: false, + required: ['surveyObservations', 'pagination'], + properties: { + surveyObservations: findFlattenedObservationsSchema, + 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 observations for the current user, based on their permissions and filter criteria. + * + * @returns {RequestHandler} + */ +export function findFlattenedObservations(): RequestHandler { + return async (req, res) => { + defaultLog.debug({ label: 'getObservations' }); + + const connection = getDBConnection(req.keycloak_token); + + try { + await connection.open(); + + const systemUserId = connection.systemUserId(); + + const systemUser = getSystemUserFromRequest(req); + + const isUserAdmin = userHasValidRole( + [SYSTEM_ROLE.SYSTEM_ADMIN, SYSTEM_ROLE.DATA_ADMINISTRATOR], + systemUser.role_names + ); + + const filterFields = parseQueryParams(req); + + const paginationOptions = makePaginationOptionsFromRequest(req); + + const observationService = new ObservationService(connection); + + const [observations, observationsTotalCount] = await Promise.all([ + observationService.findFlattenedObservations( + isUserAdmin, + systemUserId, + filterFields, + ensureCompletePaginationOptions(paginationOptions) + ), + observationService.findFlattenedObservationsCount(isUserAdmin, systemUserId, filterFields) + ]); + + await connection.commit(); + + const response = { + surveyObservations: observations, + 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: 'findFlattenedObservations', 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 && Number(req.query.system_user_id)) ?? undefined + }; +} diff --git a/api/src/paths/observation/index.test.ts b/api/src/paths/observation/index.test.ts index 5cbe6bded9..b2152c9076 100644 --- a/api/src/paths/observation/index.test.ts +++ b/api/src/paths/observation/index.test.ts @@ -36,16 +36,16 @@ describe('findObservations', () => { method_technique_name: 'TECHNIQUE_NAME', survey_sample_period_id: 1, survey_sample_period_start_datetime: '2000-01-01 00:00:00', + observation_sign_id: 1, + qualitative_environments: [], + quantitative_environments: [], subcounts: [ { observation_subcount_id: 9, subcount: 5, - observation_subcount_sign_id: 1, comment: 'comment', qualitative_measurements: [], - quantitative_measurements: [], - qualitative_environments: [], - quantitative_environments: [] + quantitative_measurements: [] } ] } @@ -132,16 +132,16 @@ describe('findObservations', () => { method_technique_name: 'TECHNIQUE_NAME', survey_sample_period_id: 1, survey_sample_period_start_datetime: '2000-01-01 00:00:00', + observation_sign_id: 1, + qualitative_environments: [], + quantitative_environments: [], subcounts: [ { observation_subcount_id: 9, subcount: 5, - observation_subcount_sign_id: 1, comment: 'comment', qualitative_measurements: [], - quantitative_measurements: [], - qualitative_environments: [], - quantitative_environments: [] + quantitative_measurements: [] } ] } diff --git a/api/src/paths/observation/index.ts b/api/src/paths/observation/index.ts index 885a5e2c4b..a91827d59d 100644 --- a/api/src/paths/observation/index.ts +++ b/api/src/paths/observation/index.ts @@ -220,7 +220,7 @@ export function findObservations(): RequestHandler { return res.status(200).json(response); } catch (error) { - defaultLog.error({ label: 'getObservations', message: 'error', error }); + defaultLog.error({ label: 'findObservations', message: 'error', error }); await connection.rollback(); throw error; } finally { diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/observations/delete.test.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/observations/delete.test.ts index efe3982cc8..4acb792dbc 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/observations/delete.test.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/observations/delete.test.ts @@ -24,7 +24,7 @@ describe('deleteSurveyObservations', () => { }); sinon.stub(db, 'getDBConnection').returns(mockDBConnection); - const deleteTechniquesStub = sinon + const deleteObservationsByIdsStub = sinon .stub(ObservationService.prototype, 'deleteObservationsByIds') .rejects(new Error('a test error')); @@ -47,7 +47,7 @@ describe('deleteSurveyObservations', () => { } catch (actualError) { expect(mockDBConnection.open).to.have.been.calledOnce; - expect(deleteTechniquesStub).to.have.been.calledOnce; + expect(deleteObservationsByIdsStub).to.have.been.calledOnce; expect(mockDBConnection.rollback).to.have.been.calledOnce; expect(mockDBConnection.release).to.have.been.calledOnce; @@ -60,7 +60,7 @@ describe('deleteSurveyObservations', () => { const mockDBConnection = getMockDBConnection({ open: sinon.stub(), commit: sinon.stub(), release: sinon.stub() }); sinon.stub(db, 'getDBConnection').returns(mockDBConnection); - const deleteTechniquesStub = sinon.stub(ObservationService.prototype, 'deleteObservationsByIds').resolves(); + const deleteObservationsByIdsStub = sinon.stub(ObservationService.prototype, 'deleteObservationsByIds').resolves(); const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); @@ -79,7 +79,7 @@ describe('deleteSurveyObservations', () => { expect(mockDBConnection.open).to.have.been.calledOnce; - expect(deleteTechniquesStub).to.have.been.calledOnce; + expect(deleteObservationsByIdsStub).to.have.been.calledOnce; expect(mockDBConnection.commit).to.have.been.calledOnce; 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 index c18190f846..19daf1e960 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/observations/environments/delete.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/observations/environments/delete.ts @@ -3,7 +3,7 @@ import { Operation } from 'express-openapi'; import { PROJECT_PERMISSION, SYSTEM_ROLE } from '../../../../../../../constants/roles'; import { getDBConnection } from '../../../../../../../database/db'; import { authorizeRequestHandler } from '../../../../../../../request-handlers/security/authorization'; -import { ObservationSubCountEnvironmentService } from '../../../../../../../services/observation-subcount-environment-service'; +import { ObservationEnvironmentService } from '../../../../../../../services/observation-environment-service'; import { getLogger } from '../../../../../../../utils/logger'; const defaultLog = getLogger('/api/project/{projectId}/survey/{surveyId}/observations/environments'); @@ -129,7 +129,7 @@ export function deleteObservationEnvironments(): RequestHandler { defaultLog.debug({ label: 'deleteObservationEnvironments', surveyId }); await connection.open(); - const service = new ObservationSubCountEnvironmentService(connection); + const service = new ObservationEnvironmentService(connection); await service.deleteEnvironmentsForEnvironmentIds(surveyId, environmentIds); await connection.commit(); diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/observations/flattened/index.test.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/observations/flattened/index.test.ts new file mode 100644 index 0000000000..0a2d73478f --- /dev/null +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/observations/flattened/index.test.ts @@ -0,0 +1,241 @@ +import chai, { expect } from 'chai'; +import { describe } from 'mocha'; +import sinon from 'sinon'; +import sinonChai from 'sinon-chai'; +import * as db from '../../../../../../../database/db'; +import { HTTPError } from '../../../../../../../errors/http-error'; +import { FlattenedObservationRecordWithSamplingAndSubcountData } from '../../../../../../../repositories/observation-repository/observation-repository'; +import { ObservationService } from '../../../../../../../services/observation-services/observation-service'; +import { getMockDBConnection, getRequestHandlerMocks } from '../../../../../../../__mocks__/db'; +import { getSurveyFlattenedObservations } from './index'; + +chai.use(sinonChai); + +describe('getSurveyObservations', () => { + afterEach(() => { + sinon.restore(); + }); + + it('retrieves survey observations with pagination', async () => { + const dbConnectionObj = getMockDBConnection(); + + sinon.stub(db, 'getDBConnection').returns(dbConnectionObj); + + const getSurveyFlattenedObservationsWithSupplementaryAndSamplingDataAndAttributeDataStub = sinon + .stub( + ObservationService.prototype, + 'getSurveyFlattenedObservationsWithSupplementaryAndSamplingDataAndAttributeData' + ) + .resolves({ + surveyObservations: [ + { survey_observation_id: 11 }, + { survey_observation_id: 12 } + ] as unknown as FlattenedObservationRecordWithSamplingAndSubcountData[], + supplementaryObservationData: { + observationCount: 59, + qualitative_measurements: [], + quantitative_measurements: [], + qualitative_environments: [], + quantitative_environments: [], + sampling_data: [] + } + }); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + + mockReq.params = { + projectId: '1', + surveyId: '2' + }; + + mockReq.query = { + page: '4', + limit: '10', + sort: 'count', + order: 'asc' + }; + + const requestHandler = getSurveyFlattenedObservations(); + await requestHandler(mockReq, mockRes, mockNext); + + expect( + getSurveyFlattenedObservationsWithSupplementaryAndSamplingDataAndAttributeDataStub + ).to.have.been.calledOnceWith(2); + expect(mockRes.statusValue).to.equal(200); + expect(mockRes.jsonValue).to.eql({ + surveyObservations: [{ survey_observation_id: 11 }, { survey_observation_id: 12 }], + supplementaryObservationData: { + observationCount: 59, + qualitative_measurements: [], + quantitative_measurements: [], + qualitative_environments: [], + quantitative_environments: [], + sampling_data: [] + }, + pagination: { + total: 59, + current_page: 4, + last_page: 6, + order: 'asc', + per_page: 10, + sort: 'count' + } + }); + }); + + it('retrieves survey observations with some pagination options', async () => { + const dbConnectionObj = getMockDBConnection(); + + sinon.stub(db, 'getDBConnection').returns(dbConnectionObj); + + const getSurveyFlattenedObservationsWithSupplementaryAndSamplingDataAndAttributeDataStub = sinon + .stub( + ObservationService.prototype, + 'getSurveyFlattenedObservationsWithSupplementaryAndSamplingDataAndAttributeData' + ) + .resolves({ + surveyObservations: [ + { survey_observation_id: 16 }, + { survey_observation_id: 17 } + ] as unknown as FlattenedObservationRecordWithSamplingAndSubcountData[], + supplementaryObservationData: { + observationCount: 50, + qualitative_measurements: [], + quantitative_measurements: [], + qualitative_environments: [], + quantitative_environments: [], + sampling_data: [] + } + }); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + + mockReq.params = { + projectId: '1', + surveyId: '2' + }; + + mockReq.query = { + page: '2', + limit: '15' + }; + + const requestHandler = getSurveyFlattenedObservations(); + await requestHandler(mockReq, mockRes, mockNext); + + expect( + getSurveyFlattenedObservationsWithSupplementaryAndSamplingDataAndAttributeDataStub + ).to.have.been.calledOnceWith(2); + expect(mockRes.statusValue).to.equal(200); + expect(mockRes.jsonValue).to.eql({ + surveyObservations: [{ survey_observation_id: 16 }, { survey_observation_id: 17 }], + supplementaryObservationData: { + observationCount: 50, + qualitative_measurements: [], + quantitative_measurements: [], + qualitative_environments: [], + quantitative_environments: [], + sampling_data: [] + }, + pagination: { + total: 50, + current_page: 2, + last_page: 4, + order: undefined, + per_page: 15, + sort: undefined + } + }); + }); + + it('retrieves survey observations with no pagination', async () => { + const dbConnectionObj = getMockDBConnection(); + + sinon.stub(db, 'getDBConnection').returns(dbConnectionObj); + + const getSurveyFlattenedObservationsWithSupplementaryAndSamplingDataAndAttributeDataStub = sinon + .stub( + ObservationService.prototype, + 'getSurveyFlattenedObservationsWithSupplementaryAndSamplingDataAndAttributeData' + ) + .resolves({ + surveyObservations: [ + { survey_observation_id: 16 }, + { survey_observation_id: 17 } + ] as unknown as FlattenedObservationRecordWithSamplingAndSubcountData[], + supplementaryObservationData: { + observationCount: 2, + qualitative_measurements: [], + quantitative_measurements: [], + qualitative_environments: [], + quantitative_environments: [], + sampling_data: [] + } + }); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + + mockReq.params = { + projectId: '1', + surveyId: '2' + }; + + const requestHandler = getSurveyFlattenedObservations(); + await requestHandler(mockReq, mockRes, mockNext); + + expect( + getSurveyFlattenedObservationsWithSupplementaryAndSamplingDataAndAttributeDataStub + ).to.have.been.calledOnceWith(2); + expect(mockRes.statusValue).to.equal(200); + expect(mockRes.jsonValue).to.eql({ + surveyObservations: [{ survey_observation_id: 16 }, { survey_observation_id: 17 }], + supplementaryObservationData: { + observationCount: 2, + qualitative_measurements: [], + quantitative_measurements: [], + qualitative_environments: [], + quantitative_environments: [], + sampling_data: [] + }, + pagination: { + total: 2, + current_page: 1, + last_page: 1, + per_page: 2, + order: undefined, + sort: undefined + } + }); + }); + + it('catches and re-throws error', async () => { + const dbConnectionObj = getMockDBConnection({ release: sinon.stub() }); + + sinon.stub(db, 'getDBConnection').returns(dbConnectionObj); + + sinon + .stub( + ObservationService.prototype, + 'getSurveyFlattenedObservationsWithSupplementaryAndSamplingDataAndAttributeData' + ) + .rejects(new Error('a test error')); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + + mockReq.params = { + projectId: '1', + surveyId: '2' + }; + + try { + const requestHandler = getSurveyFlattenedObservations(); + + await requestHandler(mockReq, mockRes, mockNext); + expect.fail(); + } 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/{projectId}/survey/{surveyId}/observations/flattened/index.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/observations/flattened/index.ts new file mode 100644 index 0000000000..1cb3e3369f --- /dev/null +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/observations/flattened/index.ts @@ -0,0 +1,171 @@ +import { RequestHandler } from 'express'; +import { Operation } from 'express-openapi'; +import { PROJECT_PERMISSION, SYSTEM_ROLE } from '../../../../../../../constants/roles'; +import { getDBConnection } from '../../../../../../../database/db'; +import { + findFlattenedObservationsSchema, + observationsSupplementaryDataSchema +} from '../../../../../../../openapi/schemas/observation'; +import { + paginationRequestQueryParamSchema, + paginationResponseSchema +} from '../../../../../../../openapi/schemas/pagination'; +import { authorizeRequestHandler } from '../../../../../../../request-handlers/security/authorization'; +import { ObservationService } from '../../../../../../../services/observation-services/observation-service'; +import { getLogger } from '../../../../../../../utils/logger'; +import { + ensureCompletePaginationOptions, + makePaginationOptionsFromRequest, + makePaginationResponse +} from '../../../../../../../utils/pagination'; + +const defaultLog = getLogger('/api/project/{projectId}/survey/{surveyId}/observation'); + +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' + } + ] + }; + }), + getSurveyFlattenedObservations() +]; + +GET.apiDoc = { + description: 'Get all flattened observations for the survey.', + 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 + }, + ...paginationRequestQueryParamSchema + ], + responses: { + 200: { + description: 'Survey flattened observations get response.', + content: { + 'application/json': { + schema: { + type: 'object', + additionalProperties: false, + required: ['surveyObservations', 'supplementaryObservationData', 'pagination'], + properties: { + surveyObservations: findFlattenedObservationsSchema, + supplementaryObservationData: observationsSupplementaryDataSchema, + 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' + } + } +}; + +/** + * This record maps observation table sampling site site ID columns to sampling data + * columns that can be sorted on. + * + * TODO We should probably modify frontend functionality to make requests to sort on these + * columns. + */ +const samplingSiteSortingColumnName: Record = { + survey_sample_site_id: 'survey_sample_site_name', + method_technique_id: 'method_technique_name', + survey_sample_period_id: 'survey_sample_period_start_datetime' +}; + +/** + * Fetch all flattened observations for a survey. + * + * @export + * @return {*} {RequestHandler} + */ +export function getSurveyFlattenedObservations(): RequestHandler { + return async (req, res) => { + const surveyId = Number(req.params.surveyId); + defaultLog.debug({ label: 'getSurveyObservations', surveyId }); + + const paginationOptions = makePaginationOptionsFromRequest(req); + if (paginationOptions.sort && samplingSiteSortingColumnName[paginationOptions.sort]) { + paginationOptions.sort = samplingSiteSortingColumnName[paginationOptions.sort]; + } + + const connection = getDBConnection(req.keycloak_token); + + try { + await connection.open(); + + const observationService = new ObservationService(connection); + + const observationData = + await observationService.getSurveyFlattenedObservationsWithSupplementaryAndSamplingDataAndAttributeData( + surveyId, + ensureCompletePaginationOptions(paginationOptions) + ); + + await connection.commit(); + + const observationCount = observationData.supplementaryObservationData.observationCount; + + return res.status(200).json({ + surveyObservations: observationData.surveyObservations, + supplementaryObservationData: observationData.supplementaryObservationData, + pagination: makePaginationResponse(observationCount, paginationOptions) + }); + } catch (error) { + defaultLog.error({ label: 'getSurveyObservations', 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 0677e69437..f311668a46 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,7 +5,6 @@ import sinonChai from 'sinon-chai'; import * as db from '../../../../../../database/db'; import { HTTPError } from '../../../../../../errors/http-error'; import { ObservationRecordWithSamplingAndSubcountData } from '../../../../../../repositories/observation-repository/observation-repository'; -import { CritterbaseService } from '../../../../../../services/critterbase-service'; import { ObservationService } from '../../../../../../services/observation-services/observation-service'; import { getMockDBConnection, getRequestHandlerMocks } from '../../../../../../__mocks__/db'; import * as observationRecords from './index'; @@ -22,10 +21,6 @@ describe('insertUpdateManualSurveyObservations', () => { sinon.stub(db, 'getDBConnection').returns(dbConnectionObj); - const validateSurveyObservationsStub = sinon - .stub(ObservationService.prototype, 'validateSurveyObservations') - .resolves(true); - const insertUpdateSurveyObservationsStub = sinon .stub(ObservationService.prototype, 'insertUpdateManualSurveyObservations') .resolves(); @@ -79,10 +74,6 @@ describe('insertUpdateManualSurveyObservations', () => { 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); @@ -93,8 +84,6 @@ describe('insertUpdateManualSurveyObservations', () => { sinon.stub(db, 'getDBConnection').returns(dbConnectionObj); - sinon.stub(ObservationService.prototype, 'validateSurveyObservations').resolves(true); - sinon.stub(ObservationService.prototype, 'insertUpdateManualSurveyObservations').rejects(new Error('a test error')); const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); 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 0c7df95448..ea8941b232 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/observations/index.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/observations/index.ts @@ -11,12 +11,10 @@ import { paginationResponseSchema } from '../../../../../../openapi/schemas/pagination'; import { authorizeRequestHandler } from '../../../../../../request-handlers/security/authorization'; -import { CritterbaseService, getCritterbaseUser } from '../../../../../../services/critterbase-service'; import { InsertUpdateObservations, ObservationService } from '../../../../../../services/observation-services/observation-service'; -import { ObservationSubCountEnvironmentService } from '../../../../../../services/observation-subcount-environment-service'; import { getLogger } from '../../../../../../utils/logger'; import { ensureCompletePaginationOptions, @@ -68,6 +66,25 @@ export const PUT: Operation = [ putObservations() ]; +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' + } + ] + }; + }), + postObservations() +]; + GET.apiDoc = { description: 'Get all observations for the survey.', tags: ['observation'], @@ -177,12 +194,16 @@ PUT.apiDoc = { additionalProperties: false, required: [ 'itis_tsn', + 'itis_scientific_name', 'survey_sample_period_id', 'count', 'latitude', 'longitude', 'observation_date', - 'observation_time' + 'observation_time', + 'observation_sign_id', + 'qualitative_environments', + 'quantitative_environments' ], properties: { survey_observation_id: { @@ -220,6 +241,47 @@ PUT.apiDoc = { observation_time: { type: 'string' }, + observation_sign_id: { + type: 'integer', + minimum: 1, + description: + 'The observation sign ID, indicating whether the observation was a direct sighting, footprints, scat, etc.' + }, + qualitative_environments: { + type: 'array', + items: { + type: 'object', + additionalProperties: false, + required: ['environment_qualitative_id', 'environment_qualitative_option_id'], + 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, + required: ['environment_quantitative_id', 'value'], + properties: { + environment_quantitative_id: { + type: 'string', + format: 'uuid' + }, + value: { + type: 'number' + } + } + } + }, revision_count: { type: 'integer', minimum: 0 @@ -232,15 +294,7 @@ PUT.apiDoc = { items: { type: 'object', additionalProperties: false, - required: [ - 'subcount', - 'observation_subcount_sign_id', - 'comment', - 'qualitative_measurements', - 'quantitative_measurements', - 'qualitative_environments', - 'quantitative_environments' - ], + required: ['subcount', 'comment', 'qualitative_measurements', 'quantitative_measurements'], properties: { observation_subcount_id: { type: 'integer', @@ -249,12 +303,6 @@ PUT.apiDoc = { description: 'The observation subcount ID. If provided, the mataching existing subcount record will be updated. If not provided, a new subcount record will be inserted.' }, - observation_subcount_sign_id: { - type: 'integer', - minimum: 1, - description: - 'The observation subcount sign ID, indicating whether the subcount was a direct sighting, footprints, scat, etc.' - }, comment: { type: 'string', nullable: true, @@ -269,6 +317,7 @@ PUT.apiDoc = { items: { type: 'object', additionalProperties: false, + required: ['measurement_id', 'measurement_option_id'], properties: { measurement_id: { type: 'string' @@ -284,6 +333,7 @@ PUT.apiDoc = { items: { type: 'object', additionalProperties: false, + required: ['measurement_id', 'measurement_value'], properties: { measurement_id: { type: 'string' @@ -293,39 +343,209 @@ PUT.apiDoc = { } } } + } + } + } + } + } + } + } + } + } + } + } + }, + responses: { + 204: { + description: 'Update 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' + } + } +}; + +POST.apiDoc = { + description: 'Insert/update/delete observations for the survey.', + tags: ['observation'], + security: [ + { + Bearer: [] + } + ], + parameters: [ + { + in: 'path', + name: 'projectId', + required: true + }, + { + in: 'path', + name: 'surveyId', + required: true + } + ], + requestBody: { + description: 'Survey observation record data', + required: true, + content: { + 'application/json': { + schema: { + description: 'A single survey observation record.', + type: 'object', + additionalProperties: false, + required: ['standardColumns', 'subcounts'], + properties: { + standardColumns: { + description: 'Standard column data for an observation record.', + type: 'object', + additionalProperties: false, + required: [ + 'itis_tsn', + 'itis_scientific_name', + 'survey_sample_period_id', + 'count', + 'latitude', + 'longitude', + 'observation_date', + 'observation_time', + 'observation_sign_id', + 'qualitative_environments', + 'quantitative_environments' + ], + properties: { + itis_tsn: { + type: 'integer' + }, + itis_scientific_name: { + type: 'string', + nullable: true + }, + survey_sample_period_id: { + type: 'integer', + minimum: 1, + nullable: true + }, + count: { + type: 'integer', + description: "The observation record's count.", + nullable: true + }, + latitude: { + type: 'number', + nullable: true + }, + longitude: { + type: 'number', + nullable: true + }, + observation_date: { + type: 'string', + nullable: true + }, + observation_time: { + type: 'string', + nullable: true + }, + observation_sign_id: { + type: 'integer', + minimum: 1, + description: + 'The observation observation sign ID, indicating whether the observation was a direct sighting, footprints, scat, etc.', + nullable: true + }, + qualitative_environments: { + type: 'array', + items: { + type: 'object', + additionalProperties: false, + required: ['environment_qualitative_id', 'environment_qualitative_option_id'], + 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, + required: ['environment_quantitative_id', 'value'], + properties: { + environment_quantitative_id: { + type: 'string', + format: 'uuid' + }, + value: { + type: 'number' + } + } + } + } + } + }, + subcounts: { + description: 'An array of observation subcount records.', + type: 'array', + items: { + type: 'object', + additionalProperties: false, + required: ['subcount', 'comment', 'qualitative_measurements', 'quantitative_measurements'], + properties: { + subcount: { + type: 'number', + description: "The subcount record's count." + }, + comment: { + type: 'string', + nullable: true, + description: 'A comment or note about the subcount' + }, + qualitative_measurements: { + type: 'array', + items: { + type: 'object', + additionalProperties: false, + properties: { + measurement_id: { + type: 'string' }, - 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' - } - } - } + measurement_option_id: { + type: 'string' + } + } + } + }, + quantitative_measurements: { + type: 'array', + items: { + type: 'object', + additionalProperties: false, + properties: { + measurement_id: { + type: 'string' }, - quantitative_environments: { - type: 'array', - items: { - type: 'object', - additionalProperties: false, - properties: { - environment_quantitative_id: { - type: 'string', - format: 'uuid' - }, - value: { - type: 'number' - } - } - } + measurement_value: { + type: 'number' } } } @@ -340,7 +560,7 @@ PUT.apiDoc = { }, responses: { 204: { - description: 'Update OK' + description: 'Create observations OK' }, 400: { $ref: '#/components/responses/400' @@ -436,34 +656,56 @@ export function putObservations(): RequestHandler { const surveyId = Number(req.params.surveyId); const observationRows: InsertUpdateObservations[] = req.body.surveyObservations; - defaultLog.debug({ label: 'insertUpdateSurveyObservations', surveyId }); + defaultLog.debug({ label: 'putObservations', surveyId }); await connection.open(); const observationService = new ObservationService(connection); - // Validate measurement data against fetched measurement definition - const critterBaseService = new CritterbaseService(getCritterbaseUser(req)); - const observationSubCountEnvironmentService = new ObservationSubCountEnvironmentService(connection); + // Insert/update observation records + await observationService.insertUpdateManualSurveyObservations(surveyId, observationRows); - const isValid = await observationService.validateSurveyObservations( - observationRows, - critterBaseService, - observationSubCountEnvironmentService - ); + await connection.commit(); - if (!isValid) { - throw new Error('Failed to save observation data, failed data validation.'); - } + return res.status(204).send(); + } catch (error) { + defaultLog.error({ label: 'putObservations', message: 'error', error }); + await connection.rollback(); + throw error; + } finally { + connection.release(); + } + }; +} + +/** + * Inserts new observation records. + * + * @export + * @return {*} {RequestHandler} + */ +export function postObservations(): RequestHandler { + return async (req, res) => { + const connection = getDBConnection(req.keycloak_token); + + try { + const surveyId = Number(req.params.surveyId); + const observationRow: InsertUpdateObservations = req.body; + + defaultLog.debug({ label: 'postObservations', surveyId }); + + await connection.open(); + + const observationService = new ObservationService(connection); // Insert/update observation records - await observationService.insertUpdateManualSurveyObservations(surveyId, observationRows); + await observationService.insertUpdateManualSurveyObservations(surveyId, [observationRow]); await connection.commit(); return res.status(204).send(); } catch (error) { - defaultLog.error({ label: 'putObservations', message: 'error', error }); + defaultLog.error({ label: 'postObservations', message: 'error', error }); await connection.rollback(); throw error; } finally { 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 deleted file mode 100644 index 49c413deba..0000000000 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/observations/process.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -import chai, { expect } from 'chai'; -import { describe } from 'mocha'; -import sinon from 'sinon'; -import sinonChai from 'sinon-chai'; -import * as db from '../../../../../../database/db'; -import { HTTPError } from '../../../../../../errors/http-error'; -import { ObservationService } from '../../../../../../services/observation-services/observation-service'; -import { getMockDBConnection, getRequestHandlerMocks } from '../../../../../../__mocks__/db'; -import * as process from './process'; - -chai.use(sinonChai); - -describe('processFile', () => { - afterEach(() => { - sinon.restore(); - }); - - const dbConnectionObj = getMockDBConnection(); - - const mockReq = { - keycloak_token: {}, - params: { - projectId: 1, - surveyId: 2 - }, - body: {} - } as any; - - it('should throw an error if failure occurs', async () => { - sinon.stub(db, 'getDBConnection').returns({ - ...dbConnectionObj, - systemUserId: () => { - return 20; - } - }); - - const expectedError = new Error('Error'); - sinon.stub(ObservationService.prototype, 'processObservationCsvSubmission').rejects(expectedError); - - try { - const result = process.processFile(); - - 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); - } - }); - - it('should succeed with valid params', async () => { - const getDBConnectionStub = sinon.stub(db, 'getDBConnection').returns({ - ...dbConnectionObj, - systemUserId: () => { - return 20; - } - }); - sinon.stub(ObservationService.prototype, 'processObservationCsvSubmission').resolves({} as any); - const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); - const requestHandler = process.processFile(); - - await requestHandler(mockReq, mockRes, mockNext); - - expect(getDBConnectionStub).to.have.been.calledOnce; - expect(mockRes.status).to.be.calledWith(200); - expect(mockRes.json).not.to.have.been.called; - }); -}); diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/observations/process.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/observations/process.ts deleted file mode 100644 index c86fd000a9..0000000000 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/observations/process.ts +++ /dev/null @@ -1,128 +0,0 @@ -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 { ObservationService } from '../../../../../../services/observation-services/observation-service'; -import { getLogger } from '../../../../../../utils/logger'; - -const defaultLog = getLogger('/api/project/{projectId}/survey/{surveyId}/observation/process'); - -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' - } - ] - }; - }), - processFile() -]; - -POST.apiDoc = { - description: 'Processes and validates observation CSV submission', - tags: ['survey', 'observation', 'csv'], - security: [ - { - Bearer: [] - } - ], - parameters: [ - { - in: 'path', - name: 'projectId', - required: true - }, - { - in: 'path', - name: 'surveyId', - required: true - } - ], - requestBody: { - description: 'Request body', - required: true, - content: { - 'application/json': { - schema: { - type: 'object', - additionalProperties: false, - required: ['observation_submission_id'], - properties: { - observation_submission_id: { - description: 'The ID of the submission to validate', - type: 'integer' - }, - options: { - type: 'object', - additionalProperties: false, - properties: { - surveySamplePeriodId: { - type: 'integer', - description: - 'The optional ID of a survey sample period to associate the parsed observation records with. This is used when uploading all observations to a specific sample period, not when each record is for a different sample period.' - } - } - } - } - } - } - } - }, - responses: { - 200: { - description: 'Process Observation File 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' - } - } -}; - -export function processFile(): RequestHandler { - return async (req, res) => { - const surveyId = Number(req.params.surveyId); - const submissionId = req.body.observation_submission_id; - - const connection = getDBConnection(req.keycloak_token); - try { - await connection.open(); - - const options = req.body.options || undefined; - - const observationService = new ObservationService(connection); - - await observationService.processObservationCsvSubmission(surveyId, submissionId, options); - - await connection.commit(); - - res.status(200).send(); - } catch (error) { - defaultLog.error({ label: 'processFile', message: 'error', error }); - await connection.rollback(); - throw error; - } finally { - connection.release(); - } - }; -} 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 9d4b55f322..2eeaaaa572 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 @@ -79,21 +79,15 @@ GET.apiDoc = { required: [ 'survey_observation_id', 'survey_id', - 'latitude', - 'longitude', - 'count', 'itis_tsn', 'itis_scientific_name', + 'survey_sample_period_id', + 'count', + 'latitude', + 'longitude', 'observation_date', 'observation_time', - 'survey_sample_site_id', - 'method_technique_id', - 'survey_sample_period_id', - 'create_user', - 'create_date', - 'update_user', - 'update_date', - 'revision_count' + 'observation_sign_id' ], properties: { survey_observation_id: { @@ -102,15 +96,6 @@ GET.apiDoc = { survey_id: { type: 'integer' }, - latitude: { - type: 'number' - }, - longitude: { - type: 'number' - }, - count: { - type: 'integer' - }, itis_tsn: { type: 'integer' }, @@ -118,45 +103,28 @@ GET.apiDoc = { type: 'string', nullable: true }, - observation_date: { - type: 'string' - }, - observation_time: { - type: 'string' - }, - survey_sample_site_id: { - type: 'integer', - nullable: true - }, - method_technique_id: { - type: 'integer', - nullable: true - }, survey_sample_period_id: { type: 'integer', nullable: true }, - create_date: { - type: 'string', - description: 'ISO 8601 date string' + count: { + type: 'integer' }, - create_user: { - type: 'integer', - minimum: 1 + latitude: { + type: 'number' }, - update_date: { - type: 'string', - description: 'ISO 8601 date string', - nullable: true + longitude: { + type: 'number' }, - update_user: { - type: 'integer', - minimum: 1, - nullable: true + observation_date: { + type: 'string' + }, + observation_time: { + type: 'string' }, - revision_count: { + observation_sign_id: { type: 'integer', - minimum: 0 + minimum: 1 } } } diff --git a/api/src/paths/reference/search/environment.ts b/api/src/paths/reference/search/environment.ts index f1be6803e5..b7b55cdbf0 100644 --- a/api/src/paths/reference/search/environment.ts +++ b/api/src/paths/reference/search/environment.ts @@ -6,10 +6,10 @@ import { getLogger } from '../../../utils/logger'; const defaultLog = getLogger('paths/reference/search/environment'); -export const GET: Operation = [findSubcountEnvironments()]; +export const GET: Operation = [findEnvironmentReferenceData()]; GET.apiDoc = { - description: 'Find subcount environment data.', + description: 'Find environment reference data.', tags: ['reference'], parameters: [ { @@ -23,7 +23,7 @@ GET.apiDoc = { ], responses: { 200: { - description: 'Subcount environment data response object.', + description: 'Environment reference data response object.', content: { 'application/json': { schema: { @@ -133,11 +133,11 @@ GET.apiDoc = { }; /** - * Find all subcount environments based on the given search term. + * Find all environment reference data based on the given search term. * * @returns {RequestHandler} */ -export function findSubcountEnvironments(): RequestHandler { +export function findEnvironmentReferenceData(): RequestHandler { return async (req, res) => { const connection = getAPIUserDBConnection(); @@ -148,7 +148,7 @@ export function findSubcountEnvironments(): RequestHandler { const codeService = new CodeService(connection); - const response = await codeService.findSubcountEnvironments([searchTerm]); + const response = await codeService.findEnvironmentReferenceData([searchTerm]); await connection.commit(); @@ -157,7 +157,7 @@ export function findSubcountEnvironments(): RequestHandler { return res.status(200).json(response); } catch (error) { - defaultLog.error({ label: 'findSubcountEnvironments', message: 'error', error }); + defaultLog.error({ label: 'findEnvironmentReferenceData', message: 'error', error }); await connection.rollback(); throw error; } finally { diff --git a/api/src/repositories/code-repository.ts b/api/src/repositories/code-repository.ts index 7dff82bbcc..6aa94e91ee 100644 --- a/api/src/repositories/code-repository.ts +++ b/api/src/repositories/code-repository.ts @@ -50,7 +50,7 @@ export const IAllCodeSets = z.object({ survey_progress: CodeSet(SurveyProgressCode.shape), method_response_metrics: CodeSet(MethodResponseMetricsCode.shape), attractants: CodeSet(AttractantCode.shape), - observation_subcount_signs: CodeSet(ObservationSubcountSignCode.shape), + observation_signs: CodeSet(ObservationSubcountSignCode.shape), telemetry_device_makes: CodeSet(DeviceMakeCode.shape), frequency_units: CodeSet(FrequencyUnitCode.shape), alert_types: CodeSet(AlertTypeCode.shape), @@ -455,18 +455,18 @@ export class CodeRepository extends BaseRepository { } /** - * Fetch observation subcount sign codes. + * Fetch observation sign codes. * * @return {*} * @memberof CodeRepository */ - async getObservationSubcountSigns() { + async getObservationSigns() { const sqlStatement = SQL` SELECT - observation_subcount_sign_id AS id, + observation_sign_id AS id, name, description - FROM observation_subcount_sign + FROM observation_sign WHERE record_end_date IS null; `; diff --git a/api/src/repositories/observation-subcount-environment-repository.ts b/api/src/repositories/observation-environment-repository.ts similarity index 62% rename from api/src/repositories/observation-subcount-environment-repository.ts rename to api/src/repositories/observation-environment-repository.ts index fe138034e7..b5e0755712 100644 --- a/api/src/repositories/observation-subcount-environment-repository.ts +++ b/api/src/repositories/observation-environment-repository.ts @@ -1,123 +1,57 @@ import SQL from 'sql-template-strings'; import { z } from 'zod'; +import { EnvironmentQualitativeRecord } from '../database-models/environment_qualitative'; +import { EnvironmentQualitativeOptionRecord } from '../database-models/environment_qualitative_option'; +import { EnvironmentQuantitativeRecord } from '../database-models/environment_quantitative'; +import { ObservationEnvironmentQualitativeModel } from '../database-models/observation_environment_qualitative'; +import { ObservationEnvironmentQuantitativeModel } from '../database-models/observation_environment_quantitative'; 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 const QualitativeEnvironmentTypeDefinition = EnvironmentQualitativeRecord.omit({ + record_end_date: true +}).extend({ + options: z.array( + EnvironmentQualitativeOptionRecord.omit({ + record_end_date: true + }) + ) }); 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() +const QuantitativeEnvironmentTypeDefinition = EnvironmentQuantitativeRecord.omit({ + record_end_date: true }); 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; +export interface InsertObservationQualitativeEnvironmentRecord { + survey_observation_id: number; environment_qualitative_id: string; environment_qualitative_option_id: string; } -export interface InsertObservationSubCountQuantitativeEnvironmentRecord { - observation_subcount_id: number; +export interface InsertObservationQuantitativeEnvironmentRecord { + survey_observation_id: number; environment_quantitative_id: string; value: number; } -export class ObservationSubCountEnvironmentRepository extends BaseRepository { +export class ObservationEnvironmentRepository extends BaseRepository { /** * Insert qualitative environment records. * - * @param {InsertObservationSubCountQualitativeEnvironmentRecord[]} record - * @return {*} {Promise} - * @memberof ObservationSubCountEnvironmentRepository + * @param {InsertObservationQualitativeEnvironmentRecord[]} record + * @return {*} {Promise} + * @memberof ObservationEnvironmentRepository */ async insertObservationQualitativeEnvironmentRecords( - record: InsertObservationSubCountQualitativeEnvironmentRecord[] - ): Promise { - const qb = getKnex() - .queryBuilder() - .insert(record) - .into('observation_subcount_qualitative_environment') - .returning('*'); + record: InsertObservationQualitativeEnvironmentRecord[] + ): Promise { + const qb = getKnex().queryBuilder().insert(record).into('observation_environment_qualitative').returning('*'); - const response = await this.connection.knex(qb, ObservationSubCountQualitativeEnvironmentRecord); + const response = await this.connection.knex(qb, ObservationEnvironmentQualitativeModel); return response.rows; } @@ -125,20 +59,16 @@ export class ObservationSubCountEnvironmentRepository extends BaseRepository { /** * Insert quantitative environment records. * - * @param {InsertObservationSubCountQuantitativeEnvironmentRecord[]} record - * @return {*} {Promise} - * @memberof ObservationSubCountEnvironmentRepository + * @param {InsertObservationQuantitativeEnvironmentRecord[]} record + * @return {*} {Promise} + * @memberof ObservationEnvironmentRepository */ async insertObservationQuantitativeEnvironmentRecords( - record: InsertObservationSubCountQuantitativeEnvironmentRecord[] - ): Promise { - const qb = getKnex() - .queryBuilder() - .insert(record) - .into('observation_subcount_quantitative_environment') - .returning('*'); + record: InsertObservationQuantitativeEnvironmentRecord[] + ): Promise { + const qb = getKnex().queryBuilder().insert(record).into('observation_environment_quantitative').returning('*'); - const response = await this.connection.knex(qb, ObservationSubCountQuantitativeEnvironmentRecord); + const response = await this.connection.knex(qb, ObservationEnvironmentQuantitativeModel); return response.rows; } @@ -148,11 +78,13 @@ export class ObservationSubCountEnvironmentRepository extends BaseRepository { * * @param {number} surveyId * @param {number[]} surveyObservationId - * @memberof ObservationSubCountEnvironmentRepository + * @memberof ObservationEnvironmentRepository */ async deleteObservationEnvironments(surveyId: number, surveyObservationId: number[]) { - await this.deleteObservationQualitativeEnvironmentRecordsForSurveyObservationIds(surveyObservationId, surveyId); - await this.deleteObservationQuantitativeEnvironmentRecordsForSurveyObservationIds(surveyObservationId, surveyId); + await Promise.all([ + this.deleteObservationQualitativeEnvironmentRecordsForSurveyObservationIds(surveyObservationId, surveyId), + this.deleteObservationQuantitativeEnvironmentRecordsForSurveyObservationIds(surveyObservationId, surveyId) + ]); } /** @@ -161,7 +93,7 @@ export class ObservationSubCountEnvironmentRepository extends BaseRepository { * * @param {string[]} environmentQualitativeIds * @return {*} {Promise} - * @memberof ObservationSubCountEnvironmentRepository + * @memberof ObservationEnvironmentRepository */ async getQualitativeEnvironmentTypeDefinitions( environmentQualitativeIds: string[] @@ -200,7 +132,7 @@ export class ObservationSubCountEnvironmentRepository extends BaseRepository { * * @param {string[]} environmentQuantitativeIds * @return {*} {Promise} - * @memberof ObservationSubCountEnvironmentRepository + * @memberof ObservationEnvironmentRepository */ async getQuantitativeEnvironmentTypeDefinitions( environmentQuantitativeIds: string[] @@ -230,19 +162,18 @@ export class ObservationSubCountEnvironmentRepository extends BaseRepository { * * @param {number} surveyId * @return {*} {Promise} - * @memberof ObservationSubCountEnvironmentRepository + * @memberof ObservationEnvironmentRepository */ async getQualitativeEnvironmentTypeDefinitionsForSurvey( surveyId: number ): Promise { const sqlStatement = SQL` - WITH w_observation_subcount_qualitative_environment AS ( + WITH w_observation_environment_qualitative 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 + LEFT JOIN observation_environment_qualitative ON observation_environment_qualitative.survey_observation_id = survey_observation.survey_observation_id WHERE survey_observation.survey_id = ${surveyId} ) @@ -259,8 +190,8 @@ export class ObservationSubCountEnvironmentRepository extends BaseRepository { ) ) 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 + w_observation_environment_qualitative + INNER JOIN environment_qualitative ON environment_qualitative.environment_qualitative_id = w_observation_environment_qualitative.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, @@ -279,7 +210,7 @@ export class ObservationSubCountEnvironmentRepository extends BaseRepository { * * @param {number} surveyId * @return {*} {Promise} - * @memberof ObservationSubCountEnvironmentRepository + * @memberof ObservationEnvironmentRepository */ async getQuantitativeEnvironmentTypeDefinitionsForSurvey( surveyId: number @@ -294,12 +225,10 @@ export class ObservationSubCountEnvironmentRepository extends BaseRepository { 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 observation_environment_quantitative + ON survey_observation.survey_observation_id = observation_environment_quantitative.survey_observation_id INNER JOIN environment_quantitative - ON observation_subcount_quantitative_environment.environment_quantitative_id = environment_quantitative.environment_quantitative_id + ON observation_environment_quantitative.environment_quantitative_id = environment_quantitative.environment_quantitative_id WHERE survey_observation.survey_id = ${surveyId}; `; @@ -314,7 +243,7 @@ export class ObservationSubCountEnvironmentRepository extends BaseRepository { * * @param {string[]} searchTerms * @return {*} {Promise} - * @memberof ObservationSubCountEnvironmentRepository + * @memberof ObservationEnvironmentRepository */ async findQualitativeEnvironmentTypeDefinitions( searchTerms: string[] @@ -385,7 +314,7 @@ export class ObservationSubCountEnvironmentRepository extends BaseRepository { * * @param {string[]} searchTerms * @return {*} {Promise} - * @memberof ObservationSubCountEnvironmentRepository + * @memberof ObservationEnvironmentRepository */ async findQuantitativeEnvironmentTypeDefinitions( searchTerms: string[] @@ -429,7 +358,7 @@ export class ObservationSubCountEnvironmentRepository extends BaseRepository { * @param {number[]} surveyObservationId * @param {number} surveyId * @return {*} {Promise} - * @memberof ObservationSubCountEnvironmentRepository + * @memberof ObservationEnvironmentRepository */ async deleteObservationQualitativeEnvironmentRecordsForSurveyObservationIds( surveyObservationId: number[], @@ -438,14 +367,12 @@ export class ObservationSubCountEnvironmentRepository extends BaseRepository { 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) + .from('observation_environment_qualitative') + .using(['survey_observation']) + .whereRaw('observation_environment_qualitative.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; @@ -457,7 +384,7 @@ export class ObservationSubCountEnvironmentRepository extends BaseRepository { * @param {number[]} surveyObservationId * @param {number} surveyId * @return {*} {Promise} - * @memberof ObservationSubCountEnvironmentRepository + * @memberof ObservationEnvironmentRepository */ async deleteObservationQuantitativeEnvironmentRecordsForSurveyObservationIds( surveyObservationId: number[], @@ -466,13 +393,10 @@ export class ObservationSubCountEnvironmentRepository extends BaseRepository { 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) + .from('observation_environment_quantitative') + .using(['survey_observation']) + .whereRaw('observation_environment_quantitative.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); @@ -489,7 +413,7 @@ export class ObservationSubCountEnvironmentRepository extends BaseRepository { * environment_quantitative_id: string[]; * }} environmentIds * @return {*} {Promise} - * @memberof ObservationSubCountEnvironmentRepository + * @memberof ObservationEnvironmentRepository */ async deleteEnvironmentsForEnvironmentIds( surveyId: number, @@ -511,7 +435,7 @@ export class ObservationSubCountEnvironmentRepository extends BaseRepository { * @param {number} surveyId * @param {string[]} environment_qualitative_id * @return {*} {Promise} - * @memberof ObservationSubCountEnvironmentRepository + * @memberof ObservationEnvironmentRepository */ async deleteQualitativeEnvironmentForEnvironmentIds( surveyId: number, @@ -520,14 +444,11 @@ export class ObservationSubCountEnvironmentRepository extends BaseRepository { 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') + .from('observation_environment_qualitative') + .using(['survey_observation']) + .andWhere('observation_environment_qualitative.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); + .whereIn('observation_environment_qualitative.environment_qualitative_id', environment_qualitative_ids); const response = await this.connection.knex(qb); @@ -541,7 +462,7 @@ export class ObservationSubCountEnvironmentRepository extends BaseRepository { * @param {number} surveyId * @param {string[]} environment_quantitative_id * @return {*} {Promise} - * @memberof ObservationSubCountEnvironmentRepository + * @memberof ObservationEnvironmentRepository */ async deleteQuantitativeEnvironmentForEnvironmentIds( surveyId: number, @@ -550,17 +471,11 @@ export class ObservationSubCountEnvironmentRepository extends BaseRepository { 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') + .from('observation_environment_quantitative') + .using(['survey_observation']) + .andWhere('observation_environment_quantitative.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 - ); + .whereIn('observation_environment_quantitative.environment_quantitative_id', environment_quantitative_ids); const response = await this.connection.knex(qb); diff --git a/api/src/repositories/observation-repository/observation-repository.test.ts b/api/src/repositories/observation-repository/observation-repository.test.ts index 89f5eb8c3c..fec4b168f3 100644 --- a/api/src/repositories/observation-repository/observation-repository.test.ts +++ b/api/src/repositories/observation-repository/observation-repository.test.ts @@ -146,7 +146,7 @@ describe('ObservationRepository', () => { }); }); - describe('getSurveyObservationsWithSamplingDataWithAttributesData', () => { + describe('getSurveyObservations', () => { 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; @@ -159,7 +159,7 @@ describe('ObservationRepository', () => { const surveyId = 1; - const response = await repository.getSurveyObservationsWithSamplingDataWithAttributesData(surveyId); + const response = await repository.getSurveyObservations(surveyId); expect(response).to.be.eql(mockRows); }); @@ -176,13 +176,13 @@ describe('ObservationRepository', () => { const surveyId = 1; - const response = await repository.getSurveyObservationsWithSamplingDataWithAttributesData(surveyId); + const response = await repository.getSurveyObservations(surveyId); expect(response).to.be.eql(mockRows); }); }); - describe('getSurveyObservationCount', () => { + describe('getSurveyObservationsCount', () => { it('gets the count of survey observations for the given survey', async () => { const mockQueryResponse = { rows: [{ count: 1 }] } as unknown as QueryResult; @@ -192,7 +192,7 @@ describe('ObservationRepository', () => { const repo = new ObservationRepository(mockDBConnection); - const response = await repo.getSurveyObservationCount(1); + const response = await repo.getSurveyObservationsCount(1); expect(response).to.eql(1); }); diff --git a/api/src/repositories/observation-repository/observation-repository.ts b/api/src/repositories/observation-repository/observation-repository.ts index a2a81a2d64..c270a68fef 100644 --- a/api/src/repositories/observation-repository/observation-repository.ts +++ b/api/src/repositories/observation-repository/observation-repository.ts @@ -1,5 +1,8 @@ import SQL from 'sql-template-strings'; import { z } from 'zod'; +import { ObservationEnvironmentQualitativeRecord } from '../../database-models/observation_environment_qualitative'; +import { ObservationEnvironmentQuantitativeRecord } from '../../database-models/observation_environment_quantitative'; +import { ObservationSubcountRecord } from '../../database-models/observation_subcount'; import { SurveyObservationModel, SurveyObservationRecord } from '../../database-models/survey_observation'; import { getKnex } from '../../database/db'; import { ApiExecuteSQLError } from '../../errors/api-error'; @@ -8,16 +11,16 @@ 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 } from '../observation-subcount-measurement-repository'; -import { ObservationSubCountRecord } from '../subcount-repository'; -import { getSurveyObservationsBaseQuery, makeFindObservationsQuery } from './utils'; +import { + getSurveyFlattenedObservationsBaseQuery, + getSurveyObservationsBaseQuery, + makeFindFlattenedObservationsQuery, + makeFindObservationsQuery +} from './utils'; const defaultLog = getLogger('repositories/observation-repository'); @@ -36,37 +39,40 @@ const ObservationSamplingData = z.object({ survey_sample_period_start_datetime: z.string().nullable() }); -const ObservationSubcountQualitativeMeasurementObject = ObservationSubCountQualitativeMeasurementRecord.pick({ - critterbase_taxon_measurement_id: true, - critterbase_measurement_qualitative_option_id: true +const ObservationEnvironmentQualitativeObject = ObservationEnvironmentQualitativeRecord.pick({ + observation_environment_qualitative_id: true, + environment_qualitative_id: true, + environment_qualitative_option_id: true }); -const ObservationSubcountQuantitativeMeasurementObject = ObservationSubCountQuantitativeMeasurementRecord.pick({ - critterbase_taxon_measurement_id: true, +const ObservationEnvironmentQuantitativeObject = ObservationEnvironmentQuantitativeRecord.pick({ + observation_environment_quantitative_id: true, + environment_quantitative_id: true, value: true }); -const ObservationSubcountQualitativeEnvironmentObject = ObservationSubCountQualitativeEnvironmentRecord.pick({ - observation_subcount_qualitative_environment_id: true, - environment_qualitative_id: true, - environment_qualitative_option_id: true +const ObservationEnvironmentData = z.object({ + qualitative_environments: z.array(ObservationEnvironmentQualitativeObject), + quantitative_environments: z.array(ObservationEnvironmentQuantitativeObject) }); -const ObservationSubcountQuantitativeEnvironmentObject = ObservationSubCountQuantitativeEnvironmentRecord.pick({ - observation_subcount_quantitative_environment_id: true, - environment_quantitative_id: true, +const ObservationSubcountQualitativeMeasurementObject = ObservationSubCountQualitativeMeasurementRecord.pick({ + critterbase_taxon_measurement_id: true, + critterbase_measurement_qualitative_option_id: true +}); + +const ObservationSubcountQuantitativeMeasurementObject = ObservationSubCountQuantitativeMeasurementRecord.pick({ + critterbase_taxon_measurement_id: true, value: true }); -const ObservationSubcountObject = z.object({ - observation_subcount_id: ObservationSubCountRecord.shape.observation_subcount_id, - observation_subcount_sign_id: ObservationSubCountRecord.shape.observation_subcount_sign_id, - comment: ObservationSubCountRecord.shape.comment, - subcount: ObservationSubCountRecord.shape.subcount, +const ObservationSubcountObject = ObservationSubcountRecord.pick({ + observation_subcount_id: true, + comment: true, + subcount: true +}).extend({ qualitative_measurements: z.array(ObservationSubcountQualitativeMeasurementObject), - quantitative_measurements: z.array(ObservationSubcountQuantitativeMeasurementObject), - qualitative_environments: z.array(ObservationSubcountQualitativeEnvironmentObject), - quantitative_environments: z.array(ObservationSubcountQuantitativeEnvironmentObject) + quantitative_measurements: z.array(ObservationSubcountQuantitativeMeasurementObject) }); const ObservationSubcountsObject = z.object({ @@ -78,13 +84,33 @@ const ObservationSubcountsObject = z.object({ * Includes: * - 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 + * - additional fields about the subcount record(s) for the observation record */ export const ObservationRecordWithSamplingAndSubcountData = SurveyObservationRecord.extend( ObservationSamplingData.shape -).extend(ObservationSubcountsObject.shape); +) + .extend(ObservationEnvironmentData.shape) + .extend(ObservationSubcountsObject.shape); export type ObservationRecordWithSamplingAndSubcountData = z.infer; +/** + * An extended flattened observation record. + * Includes: + * - fields from the observation record + * - additional fields about the survey_sample_* data for the observation record + * - additional fields about the subcount record for the observation record + */ +export const FlattenedObservationRecordWithSamplingAndSubcountData = SurveyObservationRecord.extend( + ObservationSamplingData.shape +) + .extend(ObservationEnvironmentData.shape) + .extend({ + subcount: ObservationSubcountObject + }); +export type FlattenedObservationRecordWithSamplingAndSubcountData = z.infer< + typeof FlattenedObservationRecordWithSamplingAndSubcountData +>; + export const ObservationGeometryRecord = z.object({ survey_observation_id: z.number(), geometry: GeoJSONPointZodSchema @@ -92,36 +118,39 @@ export const ObservationGeometryRecord = z.object({ export type ObservationGeometryRecord = z.infer; /** - * Interface reflecting survey observations that are being inserted into the database + * Interface reflecting structure of observations that are being inserted into the database. */ -export type InsertObservation = Pick< - SurveyObservationRecord, - | 'itis_tsn' - | 'itis_scientific_name' - | 'survey_id' - | 'latitude' - | 'longitude' - | 'count' - | 'observation_date' - | 'observation_time' - | 'survey_sample_period_id' ->; +export const InsertObservation = SurveyObservationRecord.pick({ + survey_id: true, + itis_tsn: true, + itis_scientific_name: true, + latitude: true, + longitude: true, + count: true, + observation_date: true, + observation_time: true, + survey_sample_period_id: true, + observation_sign_id: true +}).extend(ObservationEnvironmentData.shape); +export type InsertObservation = z.infer; /** - * Interface reflecting survey observations that are being updated in the database + * Interface reflecting structure of observations that are being updated in the database. */ -export type UpdateObservation = Pick< - SurveyObservationRecord, - | 'itis_tsn' - | 'itis_scientific_name' - | 'survey_observation_id' - | 'latitude' - | 'longitude' - | 'count' - | 'observation_date' - | 'observation_time' - | 'survey_sample_period_id' ->; +export const UpdateObservation = SurveyObservationRecord.pick({ + survey_observation_id: true, + survey_id: true, + itis_tsn: true, + itis_scientific_name: true, + latitude: true, + longitude: true, + count: true, + observation_date: true, + observation_time: true, + survey_sample_period_id: true, + observation_sign_id: true +}).extend(ObservationEnvironmentData.shape); +export type UpdateObservation = z.infer; /** * Interface reflecting survey observations retrieved from the database @@ -169,6 +198,40 @@ export class ObservationRepository extends BaseRepository { return response.rows; } + /** 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 findFlattenedObservations( + isUserAdmin: boolean, + systemUserId: number | null, + filterFields?: IObservationAdvancedFilters, + pagination?: ApiPaginationOptions + ): Promise { + const query = makeFindFlattenedObservationsQuery(isUserAdmin, systemUserId, filterFields); + + if (pagination) { + query.limit(pagination.limit).offset((pagination.page - 1) * pagination.limit); + + if (pagination.sort && pagination.order) { + if (pagination.sort === 'subcount') { + const knex = getKnex(); + query.orderByRaw(knex.raw(`(subcount->>?)::numeric ${pagination.order}`, [pagination.sort])); + } else { + query.orderBy(pagination.sort, pagination.order); + } + } + } + + const response = await this.connection.knex(query, FlattenedObservationRecordWithSamplingAndSubcountData); + + return response.rows; + } + /** * Retrieves a paginated set of observation records for the given survey, including data for * associated sampling records. @@ -178,7 +241,7 @@ export class ObservationRepository extends BaseRepository { * @return {Promise} A promise resolving to the list of observations. * @memberof ObservationRepository */ - async getSurveyObservationsWithSamplingDataWithAttributesData( + async getSurveyObservations( surveyId: number, pagination?: ApiPaginationOptions ): Promise { @@ -197,7 +260,45 @@ export class ObservationRepository extends BaseRepository { } } - const response = await this.connection.knex(query); + const response = await this.connection.knex(query, ObservationRecordWithSamplingAndSubcountData); + + return response.rows; + } + + /** + * Retrieves a paginated set of flattened 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 getSurveyFlattenedObservations( + surveyId: number, + pagination?: ApiPaginationOptions + ): Promise { + const knex = getKnex(); + + const query = getSurveyFlattenedObservationsBaseQuery( + 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) { + if (pagination.sort === 'subcount') { + const knex = getKnex(); + query.orderByRaw(knex.raw(`(subcount->>?)::numeric ${pagination.order}`, [pagination.sort])); + } else { + query.orderBy(pagination.sort, pagination.order); + } + } + } + + const response = await this.connection.knex(query, FlattenedObservationRecordWithSamplingAndSubcountData); return response.rows; } @@ -262,7 +363,8 @@ export class ObservationRepository extends BaseRepository { observation_date, observation_time, itis_tsn, - itis_scientific_name + itis_scientific_name, + observation_sign_id ) OVERRIDING SYSTEM VALUE VALUES @@ -283,7 +385,8 @@ export class ObservationRepository extends BaseRepository { observation.observation_date ? `'${observation.observation_date}'` : 'NULL', observation.observation_time ? `'${observation.observation_time}'` : 'NULL', observation.itis_tsn ?? 'NULL', - observation.itis_scientific_name ? `'${observation.itis_scientific_name}'` : 'NULL' + observation.itis_scientific_name ? `'${observation.itis_scientific_name}'` : 'NULL', + observation.observation_sign_id ?? 'NULL' ].join(', ')})`; }) .join(', ') @@ -314,7 +417,8 @@ export class ObservationRepository extends BaseRepository { longitude, count, observation_time, - observation_date; + observation_date, + observation_sign_id; `); const response = await this.connection.sql(sqlStatement, SurveyObservationRecord); @@ -371,7 +475,8 @@ export class ObservationRepository extends BaseRepository { 'longitude', 'count', 'observation_time', - 'observation_date' + 'observation_date', + 'observation_sign_id' ]) .from('survey_observation') .where('survey_observation_id', surveyObservationId) @@ -410,7 +515,8 @@ export class ObservationRepository extends BaseRepository { 'longitude', 'count', 'observation_time', - 'observation_date' + 'observation_date', + 'observation_sign_id' ]) .from('survey_observation') .where('survey_id', surveyId); @@ -445,7 +551,7 @@ export class ObservationRepository extends BaseRepository { * @return {*} {Promise} * @memberof ObservationRepository */ - async getSurveyObservationCount(surveyId: number): Promise { + async getSurveyObservationsCount(surveyId: number): Promise { const knex = getKnex(); const sqlStatement = knex .queryBuilder() @@ -458,6 +564,31 @@ export class ObservationRepository extends BaseRepository { return response.rows[0].count; } + /** + * Retrieves the count of flattened survey observations for the given survey. + * + * @param {number} surveyId + * @return {*} {Promise} + * @memberof ObservationRepository + */ + async getSurveyFlattenedObservationsCount(surveyId: number): Promise { + const knex = getKnex(); + const sqlStatement = knex + .queryBuilder() + .select(knex.raw('COUNT(observation_subcount_id)::integer as count')) + .from('observation_subcount') + .innerJoin( + 'survey_observation', + 'observation_subcount.survey_observation_id', + 'survey_observation.survey_observation_id' + ) + .where('survey_observation.survey_id', surveyId); + + const response = await this.connection.knex(sqlStatement, z.object({ count: z.number() })); + + return response.rows[0].count; + } + /** * Retrieves the total count of all observations that are available to the user based on the user's permissions and * filter criteria. @@ -482,8 +613,43 @@ export class ObservationRepository extends BaseRepository { 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', + throw new ApiExecuteSQLError('Failed to get observations count', [ + 'ObservationRepository->findObservationsCount', + 'rows was null or undefined, expected rows != null' + ]); + } + + return response.rows[0].count; + } + + /** + * Retrieves the total count of all flattened observations that are available to the user based on the user's + * permissions and filter criteria. + * + * @param {boolean} isUserAdmin + * @param {(number | null)} systemUserId + * @param {IObservationAdvancedFilters} filterFields + * @return {*} {Promise} + * @memberof ObservationRepository + */ + async findFlattenedObservationsCount( + isUserAdmin: boolean, + systemUserId: number | null, + filterFields: IObservationAdvancedFilters + ): Promise { + const findFlattenedObservationsQuery = makeFindFlattenedObservationsQuery(isUserAdmin, systemUserId, filterFields); + + const knex = getKnex(); + + const queryBuilder = knex + .from(findFlattenedObservationsQuery.as('foq')) + .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 observations count', [ + 'ObservationRepository->findFlattenedObservationsCount', 'rows was null or undefined, expected rows != null' ]); } diff --git a/api/src/repositories/observation-repository/utils.test.ts b/api/src/repositories/observation-repository/utils.test.ts index 8cc8c2889e..3fc89ad83d 100644 --- a/api/src/repositories/observation-repository/utils.test.ts +++ b/api/src/repositories/observation-repository/utils.test.ts @@ -27,7 +27,7 @@ describe('Utils', () => { expect(normalize(queryBuilder.toSQL().toNative().sql)).to.equal( normalize( - `with "w_sampling_data" as (select "survey_sample_period"."survey_sample_period_id", (survey_sample_period.start_date::date + COALESCE(survey_sample_period.start_time, '00:00:00')::time)::timestamp as survey_sample_period_start_datetime, "survey_sample_period"."survey_sample_site_id", "survey_sample_site"."name" as "survey_sample_site_name", "survey_sample_period"."method_technique_id", "method_technique"."name" as "method_technique_name" from "survey_sample_period" left join "survey_sample_site" on "survey_sample_site"."survey_sample_site_id" = "survey_sample_period"."survey_sample_site_id" left join "method_technique" on "method_technique"."method_technique_id" = "survey_sample_period"."method_technique_id" where "survey_sample_period"."survey_id" in (select "survey_id" from "survey" where "survey"."project_id" in (select "project"."project_id" from "project" left join "project_participation" on "project_participation"."project_id" = "project"."project_id" where "project_participation"."system_user_id" is null))), "w_qualitative_measurements" as (select "observation_subcount_id", 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" where "observation_subcount_id" in (select "observation_subcount_id" from "observation_subcount" where "survey_observation_id" in (select "survey_observation_id" from "survey_observation" where "survey_id" in (select "survey_id" from "survey" where "survey"."project_id" in (select "project"."project_id" from "project" left join "project_participation" on "project_participation"."project_id" = "project"."project_id" where "project_participation"."system_user_id" is null)))) group by "observation_subcount_id"), "w_quantitative_measurements" as (select "observation_subcount_id", json_agg(json_build_object( 'critterbase_taxon_measurement_id', critterbase_taxon_measurement_id, 'value', value )) as quantitative_measurements from "observation_subcount_quantitative_measurement" where "observation_subcount_id" in (select "observation_subcount_id" from "observation_subcount" where "survey_observation_id" in (select "survey_observation_id" from "survey_observation" where "survey_id" in (select "survey_id" from "survey" where "survey"."project_id" in (select "project"."project_id" from "project" left join "project_participation" on "project_participation"."project_id" = "project"."project_id" where "project_participation"."system_user_id" is null)))) group by "observation_subcount_id"), "w_qualitative_environments" as (select "observation_subcount_id", 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" where "observation_subcount_id" in (select "observation_subcount_id" from "observation_subcount" where "survey_observation_id" in (select "survey_observation_id" from "survey_observation" where "survey_id" in (select "survey_id" from "survey" where "survey"."project_id" in (select "project"."project_id" from "project" left join "project_participation" on "project_participation"."project_id" = "project"."project_id" where "project_participation"."system_user_id" is null)))) group by "observation_subcount_id"), "w_quantitative_environments" as (select "observation_subcount_id", 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" where "observation_subcount_id" in (select "observation_subcount_id" from "observation_subcount" where "survey_observation_id" in (select "survey_observation_id" from "survey_observation" where "survey_id" in (select "survey_id" from "survey" where "survey"."project_id" in (select "project"."project_id" from "project" left join "project_participation" on "project_participation"."project_id" = "project"."project_id" where "project_participation"."system_user_id" is null)))) group by "observation_subcount_id"), "w_subcounts" as (select "survey_observation_id", json_agg(json_build_object( 'observation_subcount_id', observation_subcount.observation_subcount_id, 'observation_subcount_sign_id', observation_subcount.observation_subcount_sign_id, 'comment', observation_subcount.comment, '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" left join "w_qualitative_measurements" on "observation_subcount"."observation_subcount_id" = "w_qualitative_measurements"."observation_subcount_id" left join "w_quantitative_measurements" on "observation_subcount"."observation_subcount_id" = "w_quantitative_measurements"."observation_subcount_id" left join "w_qualitative_environments" on "observation_subcount"."observation_subcount_id" = "w_qualitative_environments"."observation_subcount_id" left join "w_quantitative_environments" on "observation_subcount"."observation_subcount_id" = "w_quantitative_environments"."observation_subcount_id" where "survey_observation_id" in (select "survey_observation_id" from "survey_observation" where "survey_id" in (select "survey_id" from "survey" where "survey"."project_id" in (select "project"."project_id" from "project" left join "project_participation" on "project_participation"."project_id" = "project"."project_id" where "project_participation"."system_user_id" is null))) group by "survey_observation_id") select "survey_observation"."survey_observation_id", "survey_observation"."survey_id", "survey_observation"."itis_tsn", "survey_observation"."itis_scientific_name", "survey_observation"."latitude", "survey_observation"."longitude", "survey_observation"."count", "survey_observation"."observation_date", "survey_observation"."observation_time", COALESCE(w_subcounts.subcounts, '[]'::json) as subcounts, "w_sampling_data"."survey_sample_site_id", "w_sampling_data"."survey_sample_site_name", "w_sampling_data"."method_technique_id", "w_sampling_data"."method_technique_name", "w_sampling_data"."survey_sample_period_id", "w_sampling_data"."survey_sample_period_start_datetime" from "survey_observation" left join "w_sampling_data" on "survey_observation"."survey_sample_period_id" = "w_sampling_data"."survey_sample_period_id" inner join "w_subcounts" on "w_subcounts"."survey_observation_id" = "survey_observation"."survey_observation_id" where "survey_observation"."survey_id" in (select "survey_id" from "survey" where "survey"."project_id" in (select "project"."project_id" from "project" left join "project_participation" on "project_participation"."project_id" = "project"."project_id" where "project_participation"."system_user_id" is null))` + `with "w_sampling_data" as (select "survey_sample_period"."survey_sample_period_id", (survey_sample_period.start_date::date + COALESCE(survey_sample_period.start_time, '00:00:00')::time)::timestamp as survey_sample_period_start_datetime, "survey_sample_period"."survey_sample_site_id", "survey_sample_site"."name" as "survey_sample_site_name", "survey_sample_period"."method_technique_id", "method_technique"."name" as "method_technique_name" from "survey_sample_period" left join "survey_sample_site" on "survey_sample_site"."survey_sample_site_id" = "survey_sample_period"."survey_sample_site_id" left join "method_technique" on "method_technique"."method_technique_id" = "survey_sample_period"."method_technique_id" where "survey_sample_period"."survey_id" in (select "survey_id" from "survey" where "survey"."project_id" in (select "project"."project_id" from "project" left join "project_participation" on "project_participation"."project_id" = "project"."project_id" where "project_participation"."system_user_id" is null))), "w_qualitative_measurements" as (select "observation_subcount_id", 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" where "observation_subcount_id" in (select "observation_subcount_id" from "observation_subcount" where "survey_observation_id" in (select "survey_observation_id" from "survey_observation" where "survey_id" in (select "survey_id" from "survey" where "survey"."project_id" in (select "project"."project_id" from "project" left join "project_participation" on "project_participation"."project_id" = "project"."project_id" where "project_participation"."system_user_id" is null)))) group by "observation_subcount_id"), "w_quantitative_measurements" as (select "observation_subcount_id", json_agg(json_build_object( 'critterbase_taxon_measurement_id', critterbase_taxon_measurement_id, 'value', value )) as quantitative_measurements from "observation_subcount_quantitative_measurement" where "observation_subcount_id" in (select "observation_subcount_id" from "observation_subcount" where "survey_observation_id" in (select "survey_observation_id" from "survey_observation" where "survey_id" in (select "survey_id" from "survey" where "survey"."project_id" in (select "project"."project_id" from "project" left join "project_participation" on "project_participation"."project_id" = "project"."project_id" where "project_participation"."system_user_id" is null)))) group by "observation_subcount_id"), "w_qualitative_environments" as (select "survey_observation_id", json_agg(json_build_object( 'observation_environment_qualitative_id', observation_environment_qualitative_id, 'environment_qualitative_id', environment_qualitative_id, 'environment_qualitative_option_id', environment_qualitative_option_id )) as qualitative_environments from "observation_environment_qualitative" where "survey_observation_id" in (select "survey_observation_id" from "survey_observation" where "survey_id" in (select "survey_id" from "survey" where "survey"."project_id" in (select "project"."project_id" from "project" left join "project_participation" on "project_participation"."project_id" = "project"."project_id" where "project_participation"."system_user_id" is null))) group by "survey_observation_id"), "w_quantitative_environments" as (select "survey_observation_id", json_agg(json_build_object( 'observation_environment_quantitative_id', observation_environment_quantitative_id, 'environment_quantitative_id', environment_quantitative_id, 'value', value )) as quantitative_environments from "observation_environment_quantitative" where "survey_observation_id" in (select "survey_observation_id" from "survey_observation" where "survey_id" in (select "survey_id" from "survey" where "survey"."project_id" in (select "project"."project_id" from "project" left join "project_participation" on "project_participation"."project_id" = "project"."project_id" where "project_participation"."system_user_id" is null))) group by "survey_observation_id"), "w_subcounts" as (select "survey_observation_id", json_agg(json_build_object( 'observation_subcount_id', observation_subcount.observation_subcount_id, 'comment', observation_subcount.comment, 'subcount', subcount, 'qualitative_measurements', COALESCE(w_qualitative_measurements.qualitative_measurements, '[]'::json), 'quantitative_measurements', COALESCE(w_quantitative_measurements.quantitative_measurements, '[]'::json) )) as subcounts from "observation_subcount" left join "w_qualitative_measurements" on "observation_subcount"."observation_subcount_id" = "w_qualitative_measurements"."observation_subcount_id" left join "w_quantitative_measurements" on "observation_subcount"."observation_subcount_id" = "w_quantitative_measurements"."observation_subcount_id" where "survey_observation_id" in (select "survey_observation_id" from "survey_observation" where "survey_id" in (select "survey_id" from "survey" where "survey"."project_id" in (select "project"."project_id" from "project" left join "project_participation" on "project_participation"."project_id" = "project"."project_id" where "project_participation"."system_user_id" is null))) group by "survey_observation_id") select "survey_observation"."survey_observation_id", "survey_observation"."survey_id", "survey_observation"."itis_tsn", "survey_observation"."itis_scientific_name", "survey_observation"."latitude", "survey_observation"."longitude", "survey_observation"."count", "survey_observation"."observation_date", "survey_observation"."observation_time", "survey_observation"."observation_sign_id", COALESCE(w_qualitative_environments.qualitative_environments, '[]'::json) as qualitative_environments, COALESCE(w_quantitative_environments.quantitative_environments, '[]'::json) as quantitative_environments, COALESCE(w_subcounts.subcounts, '[]'::json) as subcounts, "w_sampling_data"."survey_sample_site_id", "w_sampling_data"."survey_sample_site_name", "w_sampling_data"."method_technique_id", "w_sampling_data"."method_technique_name", "w_sampling_data"."survey_sample_period_id", "w_sampling_data"."survey_sample_period_start_datetime" from "survey_observation" left join "w_sampling_data" on "survey_observation"."survey_sample_period_id" = "w_sampling_data"."survey_sample_period_id" left join "w_qualitative_environments" on "survey_observation"."survey_observation_id" = "w_qualitative_environments"."survey_observation_id" left join "w_quantitative_environments" on "survey_observation"."survey_observation_id" = "w_quantitative_environments"."survey_observation_id" inner join "w_subcounts" on "w_subcounts"."survey_observation_id" = "survey_observation"."survey_observation_id" where "survey_observation"."survey_id" in (select "survey_id" from "survey" where "survey"."project_id" in (select "project"."project_id" from "project" left join "project_participation" on "project_participation"."project_id" = "project"."project_id" where "project_participation"."system_user_id" is null))` ) ); expect(queryBuilder.toSQL().toNative().bindings).to.eql([]); @@ -57,7 +57,7 @@ describe('Utils', () => { expect(normalize(queryBuilder.toSQL().toNative().sql)).to.equal( normalize( - `with "w_sampling_data" as (select "survey_sample_period"."survey_sample_period_id", (survey_sample_period.start_date::date + COALESCE(survey_sample_period.start_time, '00:00:00')::time)::timestamp as survey_sample_period_start_datetime, "survey_sample_period"."survey_sample_site_id", "survey_sample_site"."name" as "survey_sample_site_name", "survey_sample_period"."method_technique_id", "method_technique"."name" as "method_technique_name" from "survey_sample_period" left join "survey_sample_site" on "survey_sample_site"."survey_sample_site_id" = "survey_sample_period"."survey_sample_site_id" left join "method_technique" on "method_technique"."method_technique_id" = "survey_sample_period"."method_technique_id" where "survey_sample_period"."survey_id" in (select "survey_id" from "survey" where "p"."project_id" in (select "project_id" from "project_participation" where "system_user_id" = $1))), "w_qualitative_measurements" as (select "observation_subcount_id", 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" where "observation_subcount_id" in (select "observation_subcount_id" from "observation_subcount" where "survey_observation_id" in (select "survey_observation_id" from "survey_observation" where "survey_id" in (select "survey_id" from "survey" where "p"."project_id" in (select "project_id" from "project_participation" where "system_user_id" = $2)))) group by "observation_subcount_id"), "w_quantitative_measurements" as (select "observation_subcount_id", json_agg(json_build_object( 'critterbase_taxon_measurement_id', critterbase_taxon_measurement_id, 'value', value )) as quantitative_measurements from "observation_subcount_quantitative_measurement" where "observation_subcount_id" in (select "observation_subcount_id" from "observation_subcount" where "survey_observation_id" in (select "survey_observation_id" from "survey_observation" where "survey_id" in (select "survey_id" from "survey" where "p"."project_id" in (select "project_id" from "project_participation" where "system_user_id" = $3)))) group by "observation_subcount_id"), "w_qualitative_environments" as (select "observation_subcount_id", 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" where "observation_subcount_id" in (select "observation_subcount_id" from "observation_subcount" where "survey_observation_id" in (select "survey_observation_id" from "survey_observation" where "survey_id" in (select "survey_id" from "survey" where "p"."project_id" in (select "project_id" from "project_participation" where "system_user_id" = $4)))) group by "observation_subcount_id"), "w_quantitative_environments" as (select "observation_subcount_id", 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" where "observation_subcount_id" in (select "observation_subcount_id" from "observation_subcount" where "survey_observation_id" in (select "survey_observation_id" from "survey_observation" where "survey_id" in (select "survey_id" from "survey" where "p"."project_id" in (select "project_id" from "project_participation" where "system_user_id" = $5)))) group by "observation_subcount_id"), "w_subcounts" as (select "survey_observation_id", json_agg(json_build_object( 'observation_subcount_id', observation_subcount.observation_subcount_id, 'observation_subcount_sign_id', observation_subcount.observation_subcount_sign_id, 'comment', observation_subcount.comment, '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" left join "w_qualitative_measurements" on "observation_subcount"."observation_subcount_id" = "w_qualitative_measurements"."observation_subcount_id" left join "w_quantitative_measurements" on "observation_subcount"."observation_subcount_id" = "w_quantitative_measurements"."observation_subcount_id" left join "w_qualitative_environments" on "observation_subcount"."observation_subcount_id" = "w_qualitative_environments"."observation_subcount_id" left join "w_quantitative_environments" on "observation_subcount"."observation_subcount_id" = "w_quantitative_environments"."observation_subcount_id" where "survey_observation_id" in (select "survey_observation_id" from "survey_observation" where "survey_id" in (select "survey_id" from "survey" where "p"."project_id" in (select "project_id" from "project_participation" where "system_user_id" = $6))) group by "survey_observation_id") select "survey_observation"."survey_observation_id", "survey_observation"."survey_id", "survey_observation"."itis_tsn", "survey_observation"."itis_scientific_name", "survey_observation"."latitude", "survey_observation"."longitude", "survey_observation"."count", "survey_observation"."observation_date", "survey_observation"."observation_time", COALESCE(w_subcounts.subcounts, '[]'::json) as subcounts, "w_sampling_data"."survey_sample_site_id", "w_sampling_data"."survey_sample_site_name", "w_sampling_data"."method_technique_id", "w_sampling_data"."method_technique_name", "w_sampling_data"."survey_sample_period_id", "w_sampling_data"."survey_sample_period_start_datetime" from "survey_observation" left join "w_sampling_data" on "survey_observation"."survey_sample_period_id" = "w_sampling_data"."survey_sample_period_id" inner join "w_subcounts" on "w_subcounts"."survey_observation_id" = "survey_observation"."survey_observation_id" where "survey_observation"."survey_id" in (select "survey_id" from "survey" where "p"."project_id" in (select "project_id" from "project_participation" where "system_user_id" = $7)) and "observation_date" >= $8 and "observation_date" <= $9 and ("itis_scientific_name" ilike $10) and "time" >= $11 and "time" <= $12 and "itis_tsn" in ($13)` + `with "w_sampling_data" as (select "survey_sample_period"."survey_sample_period_id", (survey_sample_period.start_date::date + COALESCE(survey_sample_period.start_time, '00:00:00')::time)::timestamp as survey_sample_period_start_datetime, "survey_sample_period"."survey_sample_site_id", "survey_sample_site"."name" as "survey_sample_site_name", "survey_sample_period"."method_technique_id", "method_technique"."name" as "method_technique_name" from "survey_sample_period" left join "survey_sample_site" on "survey_sample_site"."survey_sample_site_id" = "survey_sample_period"."survey_sample_site_id" left join "method_technique" on "method_technique"."method_technique_id" = "survey_sample_period"."method_technique_id" where "survey_sample_period"."survey_id" in (select "survey_id" from "survey" where "p"."project_id" in (select "project_id" from "project_participation" where "system_user_id" = $1))), "w_qualitative_measurements" as (select "observation_subcount_id", 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" where "observation_subcount_id" in (select "observation_subcount_id" from "observation_subcount" where "survey_observation_id" in (select "survey_observation_id" from "survey_observation" where "survey_id" in (select "survey_id" from "survey" where "p"."project_id" in (select "project_id" from "project_participation" where "system_user_id" = $2)))) group by "observation_subcount_id"), "w_quantitative_measurements" as (select "observation_subcount_id", json_agg(json_build_object( 'critterbase_taxon_measurement_id', critterbase_taxon_measurement_id, 'value', value )) as quantitative_measurements from "observation_subcount_quantitative_measurement" where "observation_subcount_id" in (select "observation_subcount_id" from "observation_subcount" where "survey_observation_id" in (select "survey_observation_id" from "survey_observation" where "survey_id" in (select "survey_id" from "survey" where "p"."project_id" in (select "project_id" from "project_participation" where "system_user_id" = $3)))) group by "observation_subcount_id"), "w_qualitative_environments" as (select "survey_observation_id", json_agg(json_build_object( 'observation_environment_qualitative_id', observation_environment_qualitative_id, 'environment_qualitative_id', environment_qualitative_id, 'environment_qualitative_option_id', environment_qualitative_option_id )) as qualitative_environments from "observation_environment_qualitative" where "survey_observation_id" in (select "survey_observation_id" from "survey_observation" where "survey_id" in (select "survey_id" from "survey" where "p"."project_id" in (select "project_id" from "project_participation" where "system_user_id" = $4))) group by "survey_observation_id"), "w_quantitative_environments" as (select "survey_observation_id", json_agg(json_build_object( 'observation_environment_quantitative_id', observation_environment_quantitative_id, 'environment_quantitative_id', environment_quantitative_id, 'value', value )) as quantitative_environments from "observation_environment_quantitative" where "survey_observation_id" in (select "survey_observation_id" from "survey_observation" where "survey_id" in (select "survey_id" from "survey" where "p"."project_id" in (select "project_id" from "project_participation" where "system_user_id" = $5))) group by "survey_observation_id"), "w_subcounts" as (select "survey_observation_id", json_agg(json_build_object( 'observation_subcount_id', observation_subcount.observation_subcount_id, 'comment', observation_subcount.comment, 'subcount', subcount, 'qualitative_measurements', COALESCE(w_qualitative_measurements.qualitative_measurements, '[]'::json), 'quantitative_measurements', COALESCE(w_quantitative_measurements.quantitative_measurements, '[]'::json) )) as subcounts from "observation_subcount" left join "w_qualitative_measurements" on "observation_subcount"."observation_subcount_id" = "w_qualitative_measurements"."observation_subcount_id" left join "w_quantitative_measurements" on "observation_subcount"."observation_subcount_id" = "w_quantitative_measurements"."observation_subcount_id" where "survey_observation_id" in (select "survey_observation_id" from "survey_observation" where "survey_id" in (select "survey_id" from "survey" where "p"."project_id" in (select "project_id" from "project_participation" where "system_user_id" = $6))) group by "survey_observation_id") select "survey_observation"."survey_observation_id", "survey_observation"."survey_id", "survey_observation"."itis_tsn", "survey_observation"."itis_scientific_name", "survey_observation"."latitude", "survey_observation"."longitude", "survey_observation"."count", "survey_observation"."observation_date", "survey_observation"."observation_time", "survey_observation"."observation_sign_id", COALESCE(w_qualitative_environments.qualitative_environments, '[]'::json) as qualitative_environments, COALESCE(w_quantitative_environments.quantitative_environments, '[]'::json) as quantitative_environments, COALESCE(w_subcounts.subcounts, '[]'::json) as subcounts, "w_sampling_data"."survey_sample_site_id", "w_sampling_data"."survey_sample_site_name", "w_sampling_data"."method_technique_id", "w_sampling_data"."method_technique_name", "w_sampling_data"."survey_sample_period_id", "w_sampling_data"."survey_sample_period_start_datetime" from "survey_observation" left join "w_sampling_data" on "survey_observation"."survey_sample_period_id" = "w_sampling_data"."survey_sample_period_id" left join "w_qualitative_environments" on "survey_observation"."survey_observation_id" = "w_qualitative_environments"."survey_observation_id" left join "w_quantitative_environments" on "survey_observation"."survey_observation_id" = "w_quantitative_environments"."survey_observation_id" inner join "w_subcounts" on "w_subcounts"."survey_observation_id" = "survey_observation"."survey_observation_id" where "survey_observation"."survey_id" in (select "survey_id" from "survey" where "p"."project_id" in (select "project_id" from "project_participation" where "system_user_id" = $7)) and "observation_date" >= $8 and "observation_date" <= $9 and ("itis_scientific_name" ilike $10) and "time" >= $11 and "time" <= $12 and "itis_tsn" in ($13)` ) ); expect(queryBuilder.toSQL().toNative().bindings).to.eql([ @@ -97,7 +97,7 @@ describe('Utils', () => { expect(normalize(queryBuilder.toSQL().toNative().sql)).to.equal( normalize( - `with "w_sampling_data" as (select "survey_sample_period"."survey_sample_period_id", (survey_sample_period.start_date::date + COALESCE(survey_sample_period.start_time, '00:00:00')::time)::timestamp as survey_sample_period_start_datetime, "survey_sample_period"."survey_sample_site_id", "survey_sample_site"."name" as "survey_sample_site_name", "survey_sample_period"."method_technique_id", "method_technique"."name" as "method_technique_name" from "survey_sample_period" left join "survey_sample_site" on "survey_sample_site"."survey_sample_site_id" = "survey_sample_period"."survey_sample_site_id" left join "method_technique" on "method_technique"."method_technique_id" = "survey_sample_period"."method_technique_id" where "survey_sample_period"."survey_id" in (select "survey_id" from "survey" where "survey_id" = $1)), "w_qualitative_measurements" as (select "observation_subcount_id", 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" where "observation_subcount_id" in (select "observation_subcount_id" from "observation_subcount" where "survey_observation_id" in (select "survey_observation_id" from "survey_observation" where "survey_id" in (select "survey_id" from "survey" where "survey_id" = $2))) group by "observation_subcount_id"), "w_quantitative_measurements" as (select "observation_subcount_id", json_agg(json_build_object( 'critterbase_taxon_measurement_id', critterbase_taxon_measurement_id, 'value', value )) as quantitative_measurements from "observation_subcount_quantitative_measurement" where "observation_subcount_id" in (select "observation_subcount_id" from "observation_subcount" where "survey_observation_id" in (select "survey_observation_id" from "survey_observation" where "survey_id" in (select "survey_id" from "survey" where "survey_id" = $3))) group by "observation_subcount_id"), "w_qualitative_environments" as (select "observation_subcount_id", 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" where "observation_subcount_id" in (select "observation_subcount_id" from "observation_subcount" where "survey_observation_id" in (select "survey_observation_id" from "survey_observation" where "survey_id" in (select "survey_id" from "survey" where "survey_id" = $4))) group by "observation_subcount_id"), "w_quantitative_environments" as (select "observation_subcount_id", 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" where "observation_subcount_id" in (select "observation_subcount_id" from "observation_subcount" where "survey_observation_id" in (select "survey_observation_id" from "survey_observation" where "survey_id" in (select "survey_id" from "survey" where "survey_id" = $5))) group by "observation_subcount_id"), "w_subcounts" as (select "survey_observation_id", json_agg(json_build_object( 'observation_subcount_id', observation_subcount.observation_subcount_id, 'observation_subcount_sign_id', observation_subcount.observation_subcount_sign_id, 'comment', observation_subcount.comment, '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" left join "w_qualitative_measurements" on "observation_subcount"."observation_subcount_id" = "w_qualitative_measurements"."observation_subcount_id" left join "w_quantitative_measurements" on "observation_subcount"."observation_subcount_id" = "w_quantitative_measurements"."observation_subcount_id" left join "w_qualitative_environments" on "observation_subcount"."observation_subcount_id" = "w_qualitative_environments"."observation_subcount_id" left join "w_quantitative_environments" on "observation_subcount"."observation_subcount_id" = "w_quantitative_environments"."observation_subcount_id" where "survey_observation_id" in (select "survey_observation_id" from "survey_observation" where "survey_id" in (select "survey_id" from "survey" where "survey_id" = $6)) group by "survey_observation_id") select "survey_observation"."survey_observation_id", "survey_observation"."survey_id", "survey_observation"."itis_tsn", "survey_observation"."itis_scientific_name", "survey_observation"."latitude", "survey_observation"."longitude", "survey_observation"."count", "survey_observation"."observation_date", "survey_observation"."observation_time", COALESCE(w_subcounts.subcounts, '[]'::json) as subcounts, "w_sampling_data"."survey_sample_site_id", "w_sampling_data"."survey_sample_site_name", "w_sampling_data"."method_technique_id", "w_sampling_data"."method_technique_name", "w_sampling_data"."survey_sample_period_id", "w_sampling_data"."survey_sample_period_start_datetime" from "survey_observation" left join "w_sampling_data" on "survey_observation"."survey_sample_period_id" = "w_sampling_data"."survey_sample_period_id" inner join "w_subcounts" on "w_subcounts"."survey_observation_id" = "survey_observation"."survey_observation_id" where "survey_observation"."survey_id" in (select "survey_id" from "survey" where "survey_id" = $7)` + `with "w_sampling_data" as (select "survey_sample_period"."survey_sample_period_id", (survey_sample_period.start_date::date + COALESCE(survey_sample_period.start_time, '00:00:00')::time)::timestamp as survey_sample_period_start_datetime, "survey_sample_period"."survey_sample_site_id", "survey_sample_site"."name" as "survey_sample_site_name", "survey_sample_period"."method_technique_id", "method_technique"."name" as "method_technique_name" from "survey_sample_period" left join "survey_sample_site" on "survey_sample_site"."survey_sample_site_id" = "survey_sample_period"."survey_sample_site_id" left join "method_technique" on "method_technique"."method_technique_id" = "survey_sample_period"."method_technique_id" where "survey_sample_period"."survey_id" in (select "survey_id" from "survey" where "survey_id" = $1)), "w_qualitative_measurements" as (select "observation_subcount_id", 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" where "observation_subcount_id" in (select "observation_subcount_id" from "observation_subcount" where "survey_observation_id" in (select "survey_observation_id" from "survey_observation" where "survey_id" in (select "survey_id" from "survey" where "survey_id" = $2))) group by "observation_subcount_id"), "w_quantitative_measurements" as (select "observation_subcount_id", json_agg(json_build_object( 'critterbase_taxon_measurement_id', critterbase_taxon_measurement_id, 'value', value )) as quantitative_measurements from "observation_subcount_quantitative_measurement" where "observation_subcount_id" in (select "observation_subcount_id" from "observation_subcount" where "survey_observation_id" in (select "survey_observation_id" from "survey_observation" where "survey_id" in (select "survey_id" from "survey" where "survey_id" = $3))) group by "observation_subcount_id"), "w_qualitative_environments" as (select "survey_observation_id", json_agg(json_build_object( 'observation_environment_qualitative_id', observation_environment_qualitative_id, 'environment_qualitative_id', environment_qualitative_id, 'environment_qualitative_option_id', environment_qualitative_option_id )) as qualitative_environments from "observation_environment_qualitative" where "survey_observation_id" in (select "survey_observation_id" from "survey_observation" where "survey_id" in (select "survey_id" from "survey" where "survey_id" = $4)) group by "survey_observation_id"), "w_quantitative_environments" as (select "survey_observation_id", json_agg(json_build_object( 'observation_environment_quantitative_id', observation_environment_quantitative_id, 'environment_quantitative_id', environment_quantitative_id, 'value', value )) as quantitative_environments from "observation_environment_quantitative" where "survey_observation_id" in (select "survey_observation_id" from "survey_observation" where "survey_id" in (select "survey_id" from "survey" where "survey_id" = $5)) group by "survey_observation_id"), "w_subcounts" as (select "survey_observation_id", json_agg(json_build_object( 'observation_subcount_id', observation_subcount.observation_subcount_id, 'comment', observation_subcount.comment, 'subcount', subcount, 'qualitative_measurements', COALESCE(w_qualitative_measurements.qualitative_measurements, '[]'::json), 'quantitative_measurements', COALESCE(w_quantitative_measurements.quantitative_measurements, '[]'::json) )) as subcounts from "observation_subcount" left join "w_qualitative_measurements" on "observation_subcount"."observation_subcount_id" = "w_qualitative_measurements"."observation_subcount_id" left join "w_quantitative_measurements" on "observation_subcount"."observation_subcount_id" = "w_quantitative_measurements"."observation_subcount_id" where "survey_observation_id" in (select "survey_observation_id" from "survey_observation" where "survey_id" in (select "survey_id" from "survey" where "survey_id" = $6)) group by "survey_observation_id") select "survey_observation"."survey_observation_id", "survey_observation"."survey_id", "survey_observation"."itis_tsn", "survey_observation"."itis_scientific_name", "survey_observation"."latitude", "survey_observation"."longitude", "survey_observation"."count", "survey_observation"."observation_date", "survey_observation"."observation_time", "survey_observation"."observation_sign_id", COALESCE(w_qualitative_environments.qualitative_environments, '[]'::json) as qualitative_environments, COALESCE(w_quantitative_environments.quantitative_environments, '[]'::json) as quantitative_environments, COALESCE(w_subcounts.subcounts, '[]'::json) as subcounts, "w_sampling_data"."survey_sample_site_id", "w_sampling_data"."survey_sample_site_name", "w_sampling_data"."method_technique_id", "w_sampling_data"."method_technique_name", "w_sampling_data"."survey_sample_period_id", "w_sampling_data"."survey_sample_period_start_datetime" from "survey_observation" left join "w_sampling_data" on "survey_observation"."survey_sample_period_id" = "w_sampling_data"."survey_sample_period_id" left join "w_qualitative_environments" on "survey_observation"."survey_observation_id" = "w_qualitative_environments"."survey_observation_id" left join "w_quantitative_environments" on "survey_observation"."survey_observation_id" = "w_quantitative_environments"."survey_observation_id" inner join "w_subcounts" on "w_subcounts"."survey_observation_id" = "survey_observation"."survey_observation_id" where "survey_observation"."survey_id" in (select "survey_id" from "survey" where "survey_id" = $7)` ) ); expect(queryBuilder.toSQL().toNative().bindings).to.eql([1, 1, 1, 1, 1, 1, 1]); diff --git a/api/src/repositories/observation-repository/utils.ts b/api/src/repositories/observation-repository/utils.ts index 5c8f897207..557ecee094 100644 --- a/api/src/repositories/observation-repository/utils.ts +++ b/api/src/repositories/observation-repository/utils.ts @@ -17,32 +17,12 @@ export function makeFindObservationsQuery( ): Knex.QueryBuilder { const knex = getKnex(); - const getSurveyIdsQuery = knex.select(['survey_id']).from('survey'); + const authorizedSurveyIdsQuery = _getAuthorizedSurveyIdsQuery(isUserAdmin, systemUserId, filterFields); - // 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); + const getObservationsQuery = getSurveyObservationsBaseQuery(knex, authorizedSurveyIdsQuery); if (filterFields?.min_count) { - getObservationsQuery.andWhere('subcount', '>=', filterFields.min_count); + getObservationsQuery.andWhere('count', '>=', filterFields.min_count); } if (filterFields?.start_date) { @@ -82,6 +62,66 @@ export function makeFindObservationsQuery( return getObservationsQuery; } +/** + * Generate the flattened 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 makeFindFlattenedObservationsQuery( + isUserAdmin: boolean, + systemUserId: number | null, + filterFields?: IObservationAdvancedFilters +): Knex.QueryBuilder { + const knex = getKnex(); + + const authorizedSurveyIdsQuery = _getAuthorizedSurveyIdsQuery(isUserAdmin, systemUserId, filterFields); + + const getFlattenedObservationsQuery = getSurveyFlattenedObservationsBaseQuery(knex, authorizedSurveyIdsQuery); + + if (filterFields?.min_count) { + getFlattenedObservationsQuery.andWhereRaw(knex.raw(`subcount->>subcount >= ${filterFields.min_count}`)); + } + + if (filterFields?.start_date) { + getFlattenedObservationsQuery.andWhere('observation_date', '>=', filterFields.start_date); + } + + if (filterFields?.end_date) { + getFlattenedObservationsQuery.andWhere('observation_date', '<=', filterFields.end_date); + } + + if (filterFields?.keyword) { + getFlattenedObservationsQuery.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) { + getFlattenedObservationsQuery.andWhere('time', '>=', filterFields.start_time); + } + + if (filterFields?.end_time) { + getFlattenedObservationsQuery.andWhere('time', '<=', filterFields.end_time); + } + + // Focal Species filter + if (filterFields?.itis_tsns?.length) { + // multiple + getFlattenedObservationsQuery.whereIn('itis_tsn', filterFields.itis_tsns); + } else if (filterFields?.itis_tsn) { + // single + getFlattenedObservationsQuery.where('itis_tsn', filterFields.itis_tsn); + } + + return getFlattenedObservationsQuery; +} + /** * Get the base query for retrieving survey observations with sampling data. * @@ -93,216 +133,428 @@ export function makeFindObservationsQuery( */ export function getSurveyObservationsBaseQuery( knex: Knex, - getSurveyIdsQuery: Knex.QueryBuilder + authorizedSurveyIdsQuery: Knex.QueryBuilder ): Knex.QueryBuilder { return ( knex // Get all sampling information (sites, periods, techniques) for the matching observations - .with( - 'w_sampling_data', - knex - .select( - // Period data - '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` - ), - // Site data - 'survey_sample_period.survey_sample_site_id', - 'survey_sample_site.name as survey_sample_site_name', - // Technique data - 'survey_sample_period.method_technique_id', - 'method_technique.name as method_technique_name' - ) - .from('survey_sample_period') - .leftJoin( - 'survey_sample_site', - 'survey_sample_site.survey_sample_site_id', - 'survey_sample_period.survey_sample_site_id' - ) - .leftJoin( - 'method_technique', - 'method_technique.method_technique_id', - 'survey_sample_period.method_technique_id' - ) - .whereIn('survey_sample_period.survey_id', getSurveyIdsQuery) - ) + .with('w_sampling_data', _getSamplingDataQuery(authorizedSurveyIdsQuery)) // 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') - ) + .with('w_qualitative_measurements', _getQualitativeMeasurementsQuery(authorizedSurveyIdsQuery)) // 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(` + .with('w_quantitative_measurements', _getQuantitativeMeasurementsQuery(authorizedSurveyIdsQuery)) + // Get all qualitative environments for all observations associated to the survey + .with('w_qualitative_environments', _getQualitativeEnvironmentsQuery(authorizedSurveyIdsQuery)) + // Get all quantitative environments for all observations associated to the survey + .with('w_quantitative_environments', _getQuantitativeEnvironmentsQuery(authorizedSurveyIdsQuery)) + // Rollup the subcount records into an array of objects for each observation + .with('w_subcounts', _getSubcountsQuery(authorizedSurveyIdsQuery)) + // Return all observations for the surveys, including the additional sampling data, and rolled up subcount data + .modify(_selectObservationColumns, authorizedSurveyIdsQuery) + ); +} + +/** + * Get the base query for retrieving flattened 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 getSurveyFlattenedObservationsBaseQuery( + knex: Knex, + authorizedSurveyIdsQuery: Knex.QueryBuilder +): Knex.QueryBuilder { + return ( + knex + // Get all sampling information (sites, periods, techniques) for the matching observations + .with('w_sampling_data', _getSamplingDataQuery(authorizedSurveyIdsQuery)) + // Get all qualitative measurements for all subcounts associated to all observations for the survey + .with('w_qualitative_measurements', _getQualitativeMeasurementsQuery(authorizedSurveyIdsQuery)) + // Get all quantitative measurements for all subcounts associated to all observations for the survey + .with('w_quantitative_measurements', _getQuantitativeMeasurementsQuery(authorizedSurveyIdsQuery)) + // Get all qualitative environments for all observations associated to the survey + .with('w_qualitative_environments', _getQualitativeEnvironmentsQuery(authorizedSurveyIdsQuery)) + // Get all quantitative environments for all observations associated to the survey + .with('w_quantitative_environments', _getQuantitativeEnvironmentsQuery(authorizedSurveyIdsQuery)) + // Rollup the subcount records into an array of objects for each observation + .with('w_subcount', _getFlattenedSubcountsQuery(authorizedSurveyIdsQuery)) + // Return all observations for the surveys, including the additional sampling data, and flattened subcount data + .modify(_selectFlattenedObservationColumns, authorizedSurveyIdsQuery) + ); +} + +function _getSamplingDataQuery(authorizedSurveyIdsQuery: Knex.QueryBuilder): Knex.QueryBuilder { + const knex = getKnex(); + + const queryBuilder = knex.queryBuilder(); + + queryBuilder + .select( + // Period data + '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` + ), + // Site data + 'survey_sample_period.survey_sample_site_id', + 'survey_sample_site.name as survey_sample_site_name', + // Technique data + 'survey_sample_period.method_technique_id', + 'method_technique.name as method_technique_name' + ) + .from('survey_sample_period') + .leftJoin( + 'survey_sample_site', + 'survey_sample_site.survey_sample_site_id', + 'survey_sample_period.survey_sample_site_id' + ) + .leftJoin('method_technique', 'method_technique.method_technique_id', 'survey_sample_period.method_technique_id') + .whereIn('survey_sample_period.survey_id', authorizedSurveyIdsQuery); + + return queryBuilder; +} + +function _getQualitativeMeasurementsQuery(authorizedSurveyIdsQuery: Knex.QueryBuilder): Knex.QueryBuilder { + const knex = getKnex(); + + const queryBuilder = knex.queryBuilder(); + + queryBuilder + .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', authorizedSurveyIdsQuery); + }); + }) + .groupBy('observation_subcount_id'); + + return queryBuilder; +} + +function _getQuantitativeMeasurementsQuery(authorizedSurveyIdsQuery: Knex.QueryBuilder): Knex.QueryBuilder { + const knex = getKnex(); + + const queryBuilder = knex.queryBuilder(); + + queryBuilder + .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, - 'observation_subcount_sign_id', observation_subcount.observation_subcount_sign_id, - 'comment', observation_subcount.comment, - '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( - // Observation data - 'survey_observation.survey_observation_id', - 'survey_observation.survey_id', - 'survey_observation.itis_tsn', - 'survey_observation.itis_scientific_name', - 'survey_observation.latitude', - 'survey_observation.longitude', - 'survey_observation.count', - 'survey_observation.observation_date', - 'survey_observation.observation_time', - // Observation subcount data - knex.raw(`COALESCE(w_subcounts.subcounts, '[]'::json) as subcounts`), - // Site data - 'w_sampling_data.survey_sample_site_id', - 'w_sampling_data.survey_sample_site_name', - // Technique data - 'w_sampling_data.method_technique_id', - 'w_sampling_data.method_technique_name', - // Period data - 'w_sampling_data.survey_sample_period_id', - 'w_sampling_data.survey_sample_period_start_datetime' - ) - .from('survey_observation') - .leftJoin( - 'w_sampling_data', - 'survey_observation.survey_sample_period_id', - 'w_sampling_data.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) - ); + ) + .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', authorizedSurveyIdsQuery); + }); + }) + .groupBy('observation_subcount_id'); + + return queryBuilder; +} + +function _getQualitativeEnvironmentsQuery(authorizedSurveyIdsQuery: Knex.QueryBuilder): Knex.QueryBuilder { + const knex = getKnex(); + + const queryBuilder = knex.queryBuilder(); + + queryBuilder + .select( + 'survey_observation_id', + knex.raw(` + json_agg(json_build_object( + 'observation_environment_qualitative_id', observation_environment_qualitative_id, + 'environment_qualitative_id', environment_qualitative_id, + 'environment_qualitative_option_id', environment_qualitative_option_id + )) as qualitative_environments + `) + ) + .from('observation_environment_qualitative') + .whereIn('survey_observation_id', (qb1) => { + qb1.select('survey_observation_id').from('survey_observation').whereIn('survey_id', authorizedSurveyIdsQuery); + }) + .groupBy('survey_observation_id'); + + return queryBuilder; +} + +function _getQuantitativeEnvironmentsQuery(authorizedSurveyIdsQuery: Knex.QueryBuilder): Knex.QueryBuilder { + const knex = getKnex(); + + const queryBuilder = knex.queryBuilder(); + + queryBuilder + .select( + 'survey_observation_id', + knex.raw(` + json_agg(json_build_object( + 'observation_environment_quantitative_id', observation_environment_quantitative_id, + 'environment_quantitative_id', environment_quantitative_id, + 'value', value + )) as quantitative_environments + `) + ) + .from('observation_environment_quantitative') + .whereIn('survey_observation_id', (qb1) => { + qb1.select('survey_observation_id').from('survey_observation').whereIn('survey_id', authorizedSurveyIdsQuery); + }) + .groupBy('survey_observation_id'); + + return queryBuilder; +} + +function _getSubcountsQuery(authorizedSurveyIdsQuery: Knex.QueryBuilder): Knex.QueryBuilder { + const knex = getKnex(); + + const queryBuilder = knex.queryBuilder(); + + queryBuilder + .select( + 'survey_observation_id', + knex.raw(` + json_agg(json_build_object( + 'observation_subcount_id', observation_subcount.observation_subcount_id, + 'comment', observation_subcount.comment, + 'subcount', subcount, + 'qualitative_measurements', COALESCE(w_qualitative_measurements.qualitative_measurements, '[]'::json), + 'quantitative_measurements', COALESCE(w_quantitative_measurements.quantitative_measurements, '[]'::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' + ) + .whereIn( + 'survey_observation_id', + knex('survey_observation').select('survey_observation_id').whereIn('survey_id', authorizedSurveyIdsQuery) + ) + .groupBy('survey_observation_id'); + + return queryBuilder; +} + +function _getFlattenedSubcountsQuery(authorizedSurveyIdsQuery: Knex.QueryBuilder): Knex.QueryBuilder { + const knex = getKnex(); + + const queryBuilder = knex.queryBuilder(); + + queryBuilder + .select( + 'survey_observation_id', + knex.raw(` + json_build_object( + 'observation_subcount_id', observation_subcount.observation_subcount_id, + 'comment', observation_subcount.comment, + 'subcount', subcount, + 'qualitative_measurements', COALESCE(w_qualitative_measurements.qualitative_measurements, '[]'::json), + 'quantitative_measurements', COALESCE(w_quantitative_measurements.quantitative_measurements, '[]'::json) + ) as subcount + `) + ) + .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' + ) + .whereIn( + 'survey_observation_id', + knex('survey_observation').select('survey_observation_id').whereIn('survey_id', authorizedSurveyIdsQuery) + ); + + return queryBuilder; +} + +function _selectObservationColumns( + queryBuilder: Knex.QueryBuilder, + authorizedSurveyIdsQuery: Knex.QueryBuilder +): Knex.QueryBuilder { + const knex = getKnex(); + + queryBuilder + .select( + // Observation data + 'survey_observation.survey_observation_id', + 'survey_observation.survey_id', + 'survey_observation.itis_tsn', + 'survey_observation.itis_scientific_name', + 'survey_observation.latitude', + 'survey_observation.longitude', + 'survey_observation.count', + 'survey_observation.observation_date', + 'survey_observation.observation_time', + 'survey_observation.observation_sign_id', + // Observation environment data + knex.raw(`COALESCE(w_qualitative_environments.qualitative_environments, '[]'::json) as qualitative_environments`), + knex.raw( + `COALESCE(w_quantitative_environments.quantitative_environments, '[]'::json) as quantitative_environments` + ), + // Observation subcount data + knex.raw(`COALESCE(w_subcounts.subcounts, '[]'::json) as subcounts`), + // Site data + 'w_sampling_data.survey_sample_site_id', + 'w_sampling_data.survey_sample_site_name', + // Technique data + 'w_sampling_data.method_technique_id', + 'w_sampling_data.method_technique_name', + // Period data + 'w_sampling_data.survey_sample_period_id', + 'w_sampling_data.survey_sample_period_start_datetime' + ) + .from('survey_observation') + .leftJoin( + 'w_sampling_data', + 'survey_observation.survey_sample_period_id', + 'w_sampling_data.survey_sample_period_id' + ) + .leftJoin( + 'w_qualitative_environments', + 'survey_observation.survey_observation_id', + 'w_qualitative_environments.survey_observation_id' + ) + .leftJoin( + 'w_quantitative_environments', + 'survey_observation.survey_observation_id', + 'w_quantitative_environments.survey_observation_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', authorizedSurveyIdsQuery); + + return queryBuilder; +} + +function _selectFlattenedObservationColumns( + queryBuilder: Knex.QueryBuilder, + authorizedSurveyIdsQuery: Knex.QueryBuilder +): Knex.QueryBuilder { + const knex = getKnex(); + + queryBuilder + .select( + // Observation data + 'survey_observation.survey_observation_id', + 'survey_observation.survey_id', + 'survey_observation.itis_tsn', + 'survey_observation.itis_scientific_name', + 'survey_observation.latitude', + 'survey_observation.longitude', + 'survey_observation.count', + 'survey_observation.observation_date', + 'survey_observation.observation_time', + 'survey_observation.observation_sign_id', + // Observation environment data + knex.raw(`COALESCE(w_qualitative_environments.qualitative_environments, '[]'::json) as qualitative_environments`), + knex.raw( + `COALESCE(w_quantitative_environments.quantitative_environments, '[]'::json) as quantitative_environments` + ), + // Observation subcount data + 'w_subcount.subcount as subcount', + // Site data + 'w_sampling_data.survey_sample_site_id', + 'w_sampling_data.survey_sample_site_name', + // Technique data + 'w_sampling_data.method_technique_id', + 'w_sampling_data.method_technique_name', + // Period data + 'w_sampling_data.survey_sample_period_id', + 'w_sampling_data.survey_sample_period_start_datetime' + ) + .from('survey_observation') + .leftJoin( + 'w_sampling_data', + 'survey_observation.survey_sample_period_id', + 'w_sampling_data.survey_sample_period_id' + ) + .leftJoin( + 'w_qualitative_environments', + 'survey_observation.survey_observation_id', + 'w_qualitative_environments.survey_observation_id' + ) + .leftJoin( + 'w_quantitative_environments', + 'survey_observation.survey_observation_id', + 'w_quantitative_environments.survey_observation_id' + ) + // Note: inner join requires every observation record to have at least one subcount record, otherwise use left join + .innerJoin('w_subcount', 'w_subcount.survey_observation_id', 'survey_observation.survey_observation_id') + .whereIn('survey_observation.survey_id', authorizedSurveyIdsQuery); + + return queryBuilder; +} + +/** + * Helper function to generate the query to get the survey IDs that the user is authorized to view. + * + * @export + * @param {boolean} isUserAdmin + * @param {(number | null)} systemUserId + * @param {IObservationAdvancedFilters} [filterFields] + * @return {*} {Knex.QueryBuilder} + */ +function _getAuthorizedSurveyIdsQuery( + isUserAdmin: boolean, + systemUserId: number | null, + filterFields?: IObservationAdvancedFilters +): Knex.QueryBuilder { + const knex = getKnex(); + + const authorizedSurveyIdsQuery = 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) { + authorizedSurveyIdsQuery.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) { + authorizedSurveyIdsQuery.whereIn('p.project_id', (subQueryBuilder) => { + subQueryBuilder + .select('project_id') + .from('project_participation') + .where('system_user_id', filterFields.system_user_id); + }); + } + + return authorizedSurveyIdsQuery; } diff --git a/api/src/repositories/subcount-repository.test.ts b/api/src/repositories/subcount-repository.test.ts index 36165121f1..63d5e2a35b 100644 --- a/api/src/repositories/subcount-repository.test.ts +++ b/api/src/repositories/subcount-repository.test.ts @@ -3,16 +3,11 @@ import { describe } from 'mocha'; import { QueryResult } from 'pg'; import sinon from 'sinon'; import sinonChai from 'sinon-chai'; +import { ObservationSubcountRecord } from '../database-models/observation_subcount'; +import { SubcountCritterRecord } from '../database-models/subcount_critter'; import { ApiExecuteSQLError } from '../errors/api-error'; import { getMockDBConnection } from '../__mocks__/db'; -import { - InsertObservationSubCount, - InsertSubCountEvent, - ObservationSubCountRecord, - SubCountCritterRecord, - SubCountEventRecord, - SubCountRepository -} from './subcount-repository'; +import { InsertObservationSubCount, SubCountRepository } from './subcount-repository'; chai.use(sinonChai); @@ -23,17 +18,11 @@ describe('SubCountRepository', () => { describe('insertObservationSubCount', () => { it('should successfully insert observation subcount', async () => { - const mockSubcount: ObservationSubCountRecord = { + const mockSubcount: ObservationSubcountRecord = { observation_subcount_id: 1, survey_observation_id: 1, comment: 'comment', - subcount: 5, - observation_subcount_sign_id: null, - create_date: '1970-01-01', - create_user: 1, - update_date: null, - update_user: null, - revision_count: 1 + subcount: 5 }; const mockResponse = { @@ -70,69 +59,12 @@ describe('SubCountRepository', () => { }); }); - describe('insertSubCountEvent', () => { - it('should successfully insert subcount_event record', async () => { - const mockInsertSubcountEvent: InsertSubCountEvent = { - observation_subcount_id: 1, - critterbase_event_id: 'aaaa' - }; - - const mockSubcountEvent: SubCountEventRecord = { - observation_subcount_id: 1, - create_date: '1970-01-01', - create_user: 1, - update_date: null, - update_user: null, - revision_count: 1, - subcount_event_id: 1, - critterbase_event_id: 'aaaa' - }; - - const mockResponse = { - rows: [mockSubcountEvent], - rowCount: 1 - } as any as Promise>; - - const dbConnection = getMockDBConnection({ - knex: () => mockResponse - }); - - const repo = new SubCountRepository(dbConnection); - const response = await repo.insertSubCountEvent(mockInsertSubcountEvent); - - expect(response).to.eql(mockSubcountEvent); - }); - - it('should catch query errors and throw an ApiExecuteSQLError', async () => { - const mockResponse = { - rows: [], - rowCount: 0 - } as any as Promise>; - const dbConnection = getMockDBConnection({ - knex: () => mockResponse - }); - - const repo = new SubCountRepository(dbConnection); - try { - 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'); - } - }); - }); - describe('insertSubCountCritter', () => { it('should successfully insert a subcount_critter record', async () => { - const mockSubcountCritterRecord: SubCountCritterRecord = { + const mockSubcountCritterRecord: SubcountCritterRecord = { subcount_critter_id: 1, observation_subcount_id: 1, - critter_id: 1, - create_date: '1970-01-01', - create_user: 1, - update_date: null, - update_user: null, - revision_count: 1 + critter_id: 1 }; const mockResponse = { @@ -161,7 +93,7 @@ describe('SubCountRepository', () => { 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'); diff --git a/api/src/repositories/subcount-repository.ts b/api/src/repositories/subcount-repository.ts index 8329111111..74396aa6a4 100644 --- a/api/src/repositories/subcount-repository.ts +++ b/api/src/repositories/subcount-repository.ts @@ -1,64 +1,26 @@ -import { z } from 'zod'; +import { ObservationSubcountModel, ObservationSubcountRecord } from '../database-models/observation_subcount'; +import { SubcountCritterModel, SubcountCritterRecord } from '../database-models/subcount_critter'; import { getKnex } from '../database/db'; import { ApiExecuteSQLError } from '../errors/api-error'; import { BaseRepository } from './base-repository'; -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(), - comment: 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() -}); -export type ObservationSubCountRecord = z.infer; export type InsertObservationSubCount = Pick< - ObservationSubCountRecord, - 'survey_observation_id' | 'subcount' | 'observation_subcount_sign_id' | 'comment' + ObservationSubcountRecord, + 'survey_observation_id' | 'subcount' | 'comment' >; -export const SubCountEventRecord = z.object({ - subcount_event_id: z.number(), - observation_subcount_id: z.number(), - critterbase_event_id: 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 SubCountEventRecord = z.infer; -export type InsertSubCountEvent = Pick; - -export const SubCountCritterRecord = z.object({ - subcount_critter_id: z.number(), - observation_subcount_id: z.number(), - critter_id: 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 SubCountCritterRecord = z.infer; - export class SubCountRepository extends BaseRepository { /** * Inserts a new observation_subcount record * * @param {InsertObservationSubCount} record - * @returns {*} {Promise} + * @returns {*} {Promise} * @memberof SubCountRepository */ - async insertObservationSubCount(record: InsertObservationSubCount): Promise { + async insertObservationSubCount(record: InsertObservationSubCount): Promise { const queryBuilder = getKnex().insert(record).into('observation_subcount').returning('*'); - const response = await this.connection.knex(queryBuilder, ObservationSubCountRecord); + const response = await this.connection.knex(queryBuilder, ObservationSubcountModel); if (response.rowCount !== 1) { throw new ApiExecuteSQLError('Failed to insert observation subcount', [ @@ -70,39 +32,17 @@ export class SubCountRepository extends BaseRepository { return response.rows[0]; } - /** - * Inserts a new subcount_event record. - * - * @param {InsertSubCountEvent} record - * @returns {*} {Promise} - * @memberof SubCountRepository - */ - async insertSubCountEvent(record: InsertSubCountEvent): Promise { - const queryBuilder = getKnex().insert(record).into('subcount_event').returning('*'); - - const response = await this.connection.knex(queryBuilder, SubCountEventRecord); - - if (response.rowCount !== 1) { - throw new ApiExecuteSQLError('Failed to insert subcount event', [ - 'SubCountRepository->insertSubCountEvent', - `rowCount was ${response.rowCount}, expected rowCount = 1` - ]); - } - - return response.rows[0]; - } - /** * Inserts a new subcount_critter record. * * @param {SubCountCritterRecord} subcountCritter - * @return {*} {Promise} + * @return {*} {Promise} * @memberof SubCountRepository */ - async insertSubCountCritter(subcountCritter: SubCountCritterRecord): Promise { + async insertSubCountCritter(subcountCritter: SubcountCritterRecord): Promise { const queryBuilder = getKnex().insert(subcountCritter).into('subcount_critter').returning('*'); - const response = await this.connection.knex(queryBuilder, SubCountCritterRecord); + const response = await this.connection.knex(queryBuilder, SubcountCritterModel); if (response.rowCount !== 1) { throw new ApiExecuteSQLError('Failed to insert subcount critter', [ @@ -138,34 +78,6 @@ export class SubCountRepository extends BaseRepository { await this.connection.knex(queryBuilder); } - /** - * Delete subcount_event records for the given set of survey observation ids. - * - * @param {number} surveyId - * @param {number[]} surveyObservationIds - * @return {*} - * @memberof SubCountRepository - */ - async deleteSubCountEventRecordsForObservationId(surveyId: number, surveyObservationIds: number[]) { - const queryBuilder = getKnex() - .delete() - .from('subcount_event') - .innerJoin( - 'observation_subcount', - 'observation_subcount.observation_subcount_id', - 'subcount_event.observation_subcount_id' - ) - .innerJoin( - 'survey_observation', - 'observation_subcount.survey_observation_id', - 'survey_observation.survey_observation_id' - ) - .whereIn('observation_subcount.survey_observation_id', surveyObservationIds) - .andWhere('survey_observation.survey_id', surveyId); - - return this.connection.knex(queryBuilder); - } - /** * Delete subcount_critter records for a given set of survey observation ids. * diff --git a/api/src/services/code-service.test.ts b/api/src/services/code-service.test.ts index 4c15920d67..23cb92dfb6 100644 --- a/api/src/services/code-service.test.ts +++ b/api/src/services/code-service.test.ts @@ -45,7 +45,7 @@ describe('CodeService', () => { 'sample_methods', 'survey_progress', 'method_response_metrics', - 'observation_subcount_signs', + 'observation_signs', 'telemetry_device_makes', 'frequency_units', 'alert_types', diff --git a/api/src/services/code-service.ts b/api/src/services/code-service.ts index e52f7bcd5c..dc51f8b693 100644 --- a/api/src/services/code-service.ts +++ b/api/src/services/code-service.ts @@ -1,9 +1,12 @@ import { IDBConnection } from '../database/db'; import { CodeRepository, IAllCodeSets } from '../repositories/code-repository'; -import { EnvironmentType } from '../repositories/observation-subcount-environment-repository'; +import { + QualitativeEnvironmentTypeDefinition, + QuantitativeEnvironmentTypeDefinition +} from '../repositories/observation-environment-repository'; import { getLogger } from '../utils/logger'; import { DBService } from './db-service'; -import { ObservationSubCountEnvironmentService } from './observation-subcount-environment-service'; +import { ObservationEnvironmentService } from './observation-environment-service'; const defaultLog = getLogger('services/code-queries'); @@ -44,7 +47,7 @@ export class CodeService extends DBService { survey_progress, method_response_metrics, attractants, - observation_subcount_signs, + observation_signs, telemetry_device_makes, frequency_units, alert_types, @@ -69,7 +72,7 @@ export class CodeService extends DBService { await this.codeRepository.getSurveyProgress(), await this.codeRepository.getMethodResponseMetrics(), await this.codeRepository.getAttractants(), - await this.codeRepository.getObservationSubcountSigns(), + await this.codeRepository.getObservationSigns(), await this.codeRepository.getActiveTelemetryDeviceMakes(), await this.codeRepository.getFrequencyUnits(), await this.codeRepository.getAlertTypes(), @@ -96,7 +99,7 @@ export class CodeService extends DBService { survey_progress, method_response_metrics, attractants, - observation_subcount_signs, + observation_signs, telemetry_device_makes, frequency_units, alert_types, @@ -108,17 +111,23 @@ export class CodeService extends DBService { * Find qualitative and quantitative environments that match the given search terms. * * @param {string[]} searchTerms - * @return {*} {Promise} + * @return {*} {Promise<{ + * qualitative_environments: QualitativeEnvironmentTypeDefinition[]; + * quantitative_environments: QuantitativeEnvironmentTypeDefinition[]; + * }>} * @memberof CodeService */ - async findSubcountEnvironments(searchTerms: string[]): Promise { + async findEnvironmentReferenceData(searchTerms: string[]): Promise<{ + qualitative_environments: QualitativeEnvironmentTypeDefinition[]; + quantitative_environments: QuantitativeEnvironmentTypeDefinition[]; + }> { defaultLog.debug({ message: 'getEnvironments' }); - const observationSubCountEnvironmentService = new ObservationSubCountEnvironmentService(this.connection); + const observationEnvironmentService = new ObservationEnvironmentService(this.connection); const [qualitative_environments, quantitative_environments] = await Promise.all([ - await observationSubCountEnvironmentService.findQualitativeEnvironmentTypeDefinitions(searchTerms), - await observationSubCountEnvironmentService.findQuantitativeEnvironmentTypeDefinitions(searchTerms) + await observationEnvironmentService.findQualitativeEnvironmentTypeDefinitions(searchTerms), + await observationEnvironmentService.findQuantitativeEnvironmentTypeDefinitions(searchTerms) ]); return { diff --git a/api/src/services/observation-subcount-environment-service.ts b/api/src/services/observation-environment-service.ts similarity index 52% rename from api/src/services/observation-subcount-environment-service.ts rename to api/src/services/observation-environment-service.ts index 116571d296..780f01cc98 100644 --- a/api/src/services/observation-subcount-environment-service.ts +++ b/api/src/services/observation-environment-service.ts @@ -1,47 +1,47 @@ +import { ObservationEnvironmentQualitativeModel } from '../database-models/observation_environment_qualitative'; +import { ObservationEnvironmentQuantitativeModel } from '../database-models/observation_environment_quantitative'; import { IDBConnection } from '../database/db'; import { - InsertObservationSubCountQualitativeEnvironmentRecord, - InsertObservationSubCountQuantitativeEnvironmentRecord, - ObservationSubCountEnvironmentRepository, - ObservationSubCountQualitativeEnvironmentRecord, - ObservationSubCountQuantitativeEnvironmentRecord, + InsertObservationQualitativeEnvironmentRecord, + InsertObservationQuantitativeEnvironmentRecord, + ObservationEnvironmentRepository, QualitativeEnvironmentTypeDefinition, QuantitativeEnvironmentTypeDefinition -} from '../repositories/observation-subcount-environment-repository'; +} from '../repositories/observation-environment-repository'; import { DBService } from './db-service'; -export class ObservationSubCountEnvironmentService extends DBService { - observationSubCountEnvironmentRepository: ObservationSubCountEnvironmentRepository; +export class ObservationEnvironmentService extends DBService { + observationEnvironmentRepository: ObservationEnvironmentRepository; constructor(connection: IDBConnection) { super(connection); - this.observationSubCountEnvironmentRepository = new ObservationSubCountEnvironmentRepository(connection); + this.observationEnvironmentRepository = new ObservationEnvironmentRepository(connection); } /** * Insert qualitative environment records. * - * @param {InsertObservationSubCountQualitativeEnvironmentRecord[]} data - * @return {*} {Promise} - * @memberof ObservationSubCountEnvironmentService + * @param {InsertObservationQualitativeEnvironmentRecord[]} data + * @return {*} {Promise} + * @memberof ObservationEnvironmentService */ - async insertObservationSubCountQualitativeEnvironment( - data: InsertObservationSubCountQualitativeEnvironmentRecord[] - ): Promise { - return this.observationSubCountEnvironmentRepository.insertObservationQualitativeEnvironmentRecords(data); + async insertObservationQualitativeEnvironment( + data: InsertObservationQualitativeEnvironmentRecord[] + ): Promise { + return this.observationEnvironmentRepository.insertObservationQualitativeEnvironmentRecords(data); } /** * Insert quantitative environment records. * - * @param {InsertObservationSubCountQuantitativeEnvironmentRecord[]} data - * @return {*} {Promise} - * @memberof ObservationSubCountEnvironmentService + * @param {InsertObservationQuantitativeEnvironmentRecord[]} data + * @return {*} {Promise} + * @memberof ObservationEnvironmentService */ - async insertObservationSubCountQuantitativeEnvironment( - data: InsertObservationSubCountQuantitativeEnvironmentRecord[] - ): Promise { - return this.observationSubCountEnvironmentRepository.insertObservationQuantitativeEnvironmentRecords(data); + async insertObservationQuantitativeEnvironment( + data: InsertObservationQuantitativeEnvironmentRecord[] + ): Promise { + return this.observationEnvironmentRepository.insertObservationQuantitativeEnvironmentRecords(data); } /** @@ -49,10 +49,10 @@ export class ObservationSubCountEnvironmentService extends DBService { * * @param {number} surveyId * @param {number[]} surveyObservationId - * @memberof ObservationSubCountEnvironmentService + * @memberof ObservationEnvironmentService */ async deleteObservationEnvironments(surveyId: number, surveyObservationId: number[]) { - await this.observationSubCountEnvironmentRepository.deleteObservationEnvironments(surveyId, surveyObservationId); + await this.observationEnvironmentRepository.deleteObservationEnvironments(surveyId, surveyObservationId); } /** @@ -60,14 +60,12 @@ export class ObservationSubCountEnvironmentService extends DBService { * * @param {string[]} environmentQualitativeIds * @return {*} {Promise} - * @memberof ObservationSubCountEnvironmentService + * @memberof ObservationEnvironmentService */ async getQualitativeEnvironmentTypeDefinitions( environmentQualitativeIds: string[] ): Promise { - return this.observationSubCountEnvironmentRepository.getQualitativeEnvironmentTypeDefinitions( - environmentQualitativeIds - ); + return this.observationEnvironmentRepository.getQualitativeEnvironmentTypeDefinitions(environmentQualitativeIds); } /** @@ -75,14 +73,12 @@ export class ObservationSubCountEnvironmentService extends DBService { * * @param {string[]} searchTerms * @return {*} {Promise} - * @memberof ObservationSubCountEnvironmentService + * @memberof ObservationEnvironmentService */ async getQuantitativeEnvironmentTypeDefinitions( environmentQuantitativeIds: string[] ): Promise { - return this.observationSubCountEnvironmentRepository.getQuantitativeEnvironmentTypeDefinitions( - environmentQuantitativeIds - ); + return this.observationEnvironmentRepository.getQuantitativeEnvironmentTypeDefinitions(environmentQuantitativeIds); } /** @@ -90,12 +86,12 @@ export class ObservationSubCountEnvironmentService extends DBService { * * @param {number} surveyId * @return {*} {Promise} - * @memberof ObservationSubCountEnvironmentService + * @memberof ObservationEnvironmentService */ async getQualitativeEnvironmentTypeDefinitionsForSurvey( surveyId: number ): Promise { - return this.observationSubCountEnvironmentRepository.getQualitativeEnvironmentTypeDefinitionsForSurvey(surveyId); + return this.observationEnvironmentRepository.getQualitativeEnvironmentTypeDefinitionsForSurvey(surveyId); } /** @@ -103,12 +99,12 @@ export class ObservationSubCountEnvironmentService extends DBService { * * @param {number} surveyId * @return {*} {Promise} - * @memberof ObservationSubCountEnvironmentService + * @memberof ObservationEnvironmentService */ async getQuantitativeEnvironmentTypeDefinitionsForSurvey( surveyId: number ): Promise { - return this.observationSubCountEnvironmentRepository.getQuantitativeEnvironmentTypeDefinitionsForSurvey(surveyId); + return this.observationEnvironmentRepository.getQuantitativeEnvironmentTypeDefinitionsForSurvey(surveyId); } /** @@ -116,12 +112,12 @@ export class ObservationSubCountEnvironmentService extends DBService { * * @param {string[]} searchTerms * @return {*} {Promise} - * @memberof ObservationSubCountEnvironmentService + * @memberof ObservationEnvironmentService */ async findQualitativeEnvironmentTypeDefinitions( searchTerms: string[] ): Promise { - return this.observationSubCountEnvironmentRepository.findQualitativeEnvironmentTypeDefinitions(searchTerms); + return this.observationEnvironmentRepository.findQualitativeEnvironmentTypeDefinitions(searchTerms); } /** @@ -129,12 +125,12 @@ export class ObservationSubCountEnvironmentService extends DBService { * * @param {string[]} searchTerms * @return {*} {Promise} - * @memberof ObservationSubCountEnvironmentService + * @memberof ObservationEnvironmentService */ async findQuantitativeEnvironmentTypeDefinitions( searchTerms: string[] ): Promise { - return this.observationSubCountEnvironmentRepository.findQuantitativeEnvironmentTypeDefinitions(searchTerms); + return this.observationEnvironmentRepository.findQuantitativeEnvironmentTypeDefinitions(searchTerms); } /** @@ -146,7 +142,7 @@ export class ObservationSubCountEnvironmentService extends DBService { * environment_quantitative_id: string[]; * }} environmentIds * @return {*} {Promise} - * @memberof ObservationSubCountEnvironmentService + * @memberof ObservationEnvironmentService */ async deleteEnvironmentsForEnvironmentIds( surveyId: number, @@ -155,6 +151,6 @@ export class ObservationSubCountEnvironmentService extends DBService { environment_quantitative_id: string[]; } ): Promise { - return this.observationSubCountEnvironmentRepository.deleteEnvironmentsForEnvironmentIds(surveyId, environmentIds); + return this.observationEnvironmentRepository.deleteEnvironmentsForEnvironmentIds(surveyId, environmentIds); } } diff --git a/api/src/services/observation-services/observation-service.test.ts b/api/src/services/observation-services/observation-service.test.ts index b81bec3b5c..a609a36972 100644 --- a/api/src/services/observation-services/observation-service.test.ts +++ b/api/src/services/observation-services/observation-service.test.ts @@ -41,6 +41,9 @@ describe('ObservationService', () => { itis_scientific_name: 'itis_scientific_name', observation_date: '2023-01-01', observation_time: '12:00:00', + observation_sign_id: 1, + qualitative_environments: [], + quantitative_environments: [], survey_sample_site_id: 7, survey_sample_site_name: 'SITE_NAME', method_technique_id: 8, @@ -59,6 +62,9 @@ describe('ObservationService', () => { itis_scientific_name: 'itis_scientific_name', observation_date: '2023-02-02', observation_time: '13:00:00', + observation_sign_id: 1, + qualitative_environments: [], + quantitative_environments: [], survey_sample_site_id: 7, survey_sample_site_name: 'SITE_NAME', method_technique_id: 8, @@ -79,11 +85,11 @@ describe('ObservationService', () => { }; const getSurveyObservationsStub = sinon - .stub(ObservationRepository.prototype, 'getSurveyObservationsWithSamplingDataWithAttributesData') + .stub(ObservationRepository.prototype, 'getSurveyObservations') .resolves(mockObservations); const getSurveyObservationCountStub = sinon - .stub(ObservationRepository.prototype, 'getSurveyObservationCount') + .stub(ObservationRepository.prototype, 'getSurveyObservationsCount') .resolves(2); const getMeasurementTypeDefinitionsForSurveyStub = sinon @@ -91,7 +97,7 @@ describe('ObservationService', () => { .resolves({ qualitative_measurements: [], quantitative_measurements: [] }); const getEnvironmentTypeDefinitionsForSurveyStub = sinon - .stub(SubCountService.prototype, 'getEnvironmentTypeDefinitionsForSurvey') + .stub(ObservationService.prototype, 'getEnvironmentTypeDefinitionsForSurvey') .resolves({ qualitative_environments: [], quantitative_environments: [] }); const getSamplePeriodsForSurveyStub = sinon diff --git a/api/src/services/observation-services/observation-service.ts b/api/src/services/observation-services/observation-service.ts index e5ebea241d..57bc0e11b0 100644 --- a/api/src/services/observation-services/observation-service.ts +++ b/api/src/services/observation-services/observation-service.ts @@ -2,8 +2,14 @@ import { SurveyObservationRecord } from '../../database-models/survey_observatio import { IDBConnection } from '../../database/db'; import { ApiGeneralError } from '../../errors/api-error'; import { IObservationAdvancedFilters } from '../../models/observation-view'; -import { CodeRepository } from '../../repositories/code-repository'; import { + InsertObservationQualitativeEnvironmentRecord, + InsertObservationQuantitativeEnvironmentRecord, + QualitativeEnvironmentTypeDefinition, + QuantitativeEnvironmentTypeDefinition +} from '../../repositories/observation-environment-repository'; +import { + FlattenedObservationRecordWithSamplingAndSubcountData, InsertObservation, ObservationGeometryRecord, ObservationRecordWithSamplingAndSubcountData, @@ -12,64 +18,29 @@ import { ObservationSubmissionRecord, UpdateObservation } from '../../repositories/observation-repository/observation-repository'; -import { - InsertObservationSubCountQualitativeEnvironmentRecord, - InsertObservationSubCountQuantitativeEnvironmentRecord, - QualitativeEnvironmentTypeDefinition, - QuantitativeEnvironmentTypeDefinition -} from '../../repositories/observation-subcount-environment-repository'; import { InsertObservationSubCountQualitativeMeasurementRecord, InsertObservationSubCountQuantitativeMeasurementRecord } from '../../repositories/observation-subcount-measurement-repository'; import { SurveySamplePeriodDetails } from '../../repositories/sample-period-repository'; -import { generateS3FileKey, getFileFromS3 } from '../../utils/file-utils'; +import { generateS3FileKey } from '../../utils/file-utils'; import { getLogger } from '../../utils/logger'; -import { parseS3File } from '../../utils/media/media-utils'; -import { getCodeTypeDefinitions, validateCodes } from '../../utils/observation-xlsx-utils/code-column-utils'; -import { isQuantitativeValueValid } from '../../utils/observation-xlsx-utils/common-utils'; -import { - getEnvironmentColumnsTypeDefinitionMap, - getEnvironmentTypeDefinitionsFromColumnNames, - IEnvironmentDataToValidate, - validateEnvironments -} from '../../utils/observation-xlsx-utils/environment-column-utils'; -import { - getMeasurementColumnNames, - getTsnMeasurementTypeDefinitionMap, - IMeasurementDataToValidate, - validateMeasurements -} from '../../utils/observation-xlsx-utils/measurement-column-utils'; import { CSV_COLUMN_ALIASES } from '../../utils/xlsx-utils/column-aliases'; import { generateColumnCellGetterFromColumnValidator } from '../../utils/xlsx-utils/column-validator-utils'; -import { - constructXLSXWorkbook, - getDefaultWorksheet, - getNonStandardColumnNamesFromWorksheet, - getWorksheetRowObjects, - IXLSXCSVValidator, - validateCsvFile -} from '../../utils/xlsx-utils/worksheet-utils'; +import { IXLSXCSVValidator } from '../../utils/xlsx-utils/worksheet-utils'; import { ApiPaginationOptions } from '../../zod-schema/pagination'; import { CBQualitativeMeasurementTypeDefinition, - CBQuantitativeMeasurementTypeDefinition, - CritterbaseService + CBQuantitativeMeasurementTypeDefinition } from '../critterbase-service'; import { DBService } from '../db-service'; -import { ObservationSubCountEnvironmentService } from '../observation-subcount-environment-service'; +import { ObservationEnvironmentService } from '../observation-environment-service'; import { ObservationSubCountMeasurementService } from '../observation-subcount-measurement-service'; import { PlatformService } from '../platform-service'; import { SamplePeriodService } from '../sample-period-service'; import { SubCountService } from '../subcount-service'; -import { - pullEnvironmentsFromWorkSheetRowObject, - pullMeasurementsFromWorkSheetRowObject, - pullSamplingDataFromWorksheetRowObject -} from './utils'; export const defaultLog = getLogger('services/observation-services/observation-service'); -const defaultSubcountSign = 'direct sighting'; /** * An XLSX validation config for the standard columns of an Observation CSV. @@ -80,7 +51,7 @@ const defaultSubcountSign = 'direct sighting'; export const observationStandardColumnValidator = { ITIS_TSN: { type: 'number', aliases: CSV_COLUMN_ALIASES.ITIS_TSN }, COUNT: { type: 'number' }, - OBSERVATION_SUBCOUNT_SIGN: { type: 'code', aliases: CSV_COLUMN_ALIASES.OBSERVATION_SUBCOUNT_SIGN, optional: true }, + OBSERVATION_SIGN: { type: 'code', aliases: CSV_COLUMN_ALIASES.OBSERVATION_SIGN, optional: true }, DATE: { type: 'date', optional: true }, TIME: { type: 'string', optional: true }, LATITUDE: { type: 'number', aliases: CSV_COLUMN_ALIASES.LATITUDE, optional: true }, @@ -106,14 +77,6 @@ export interface InsertSubCount { measurement_id: string; measurement_value: number; }[]; - qualitative_environments: { - environment_qualitative_id: string; // uuid - environment_qualitative_option_id: string; - }[]; - quantitative_environments: { - environment_quantitative_id: string; // uuid - value: number; - }[]; } export type InsertUpdateObservations = { @@ -121,6 +84,40 @@ export type InsertUpdateObservations = { subcounts: InsertSubCount[]; }; +export type InsertObservations = { + standardColumns: { + itis_tsn: number; + itis_scientific_name: string | null; + survey_sample_period_id: string | null; + count: string | null; + latitude: string | null; + longitude: string | null; + observation_date: string | null; + observation_time: string | null; + observation_sign_id: number | null; + qualitative_environments: { + environment_qualitative_id: string; + environment_qualitative_option_id: string; + }[]; + quantitative_environments: { + environment_quantitative_id: string; + value: string; + }[]; + }; + subcounts: { + subcount: number; + comment: string | null; + qualitative_measurements: { + measurement_id: string; + measurement_option_id: string; + }[]; + quantitative_measurements: { + measurement_id: string; + measurement_value: number; + }[]; + }[]; +}; + export type ObservationCountSupplementaryData = { observationCount: number; }; @@ -169,7 +166,27 @@ export class ObservationService extends DBService { } /** - * Upserts the given observation records and their associated measurements. + * 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 findFlattenedObservations( + isUserAdmin: boolean, + systemUserId: number | null, + filterFields: IObservationAdvancedFilters, + pagination?: ApiPaginationOptions + ): Promise { + return this.observationRepository.findFlattenedObservations(isUserAdmin, systemUserId, filterFields, pagination); + } + + /** + * Upserts the given observation records and their associated subcounts. * * @param {number} surveyId * @param {InsertUpdateObservations[]} observations @@ -181,10 +198,12 @@ export class ObservationService extends DBService { observations: InsertUpdateObservations[] ): Promise { const subCountService = new SubCountService(this.connection); - const measurementService = new ObservationSubCountMeasurementService(this.connection); - const environmentService = new ObservationSubCountEnvironmentService(this.connection); + const observationSubCountMeasurementService = new ObservationSubCountMeasurementService(this.connection); + const observationEnvironmentService = new ObservationEnvironmentService(this.connection); for (const observation of observations) { + // -- Observation Data -------------------------------------------------------------- + // Upsert observation standard columns const upsertedObservationRecord = await this.observationRepository.insertUpdateSurveyObservations( surveyId, @@ -193,25 +212,50 @@ export class ObservationService extends DBService { const surveyObservationId = upsertedObservationRecord[0].survey_observation_id; - // TODO: Update process to fetch and find differences between incoming and existing data to only add, update or delete records as needed + // -- Observation Environment Data -------------------------------------------------------------- + + // TODO: Update 'delete environment' process to fetch and find differences between incoming and existing data to + // only add, update or delete records as needed + + // Delete old observation environment records + await observationEnvironmentService.deleteObservationEnvironments(surveyId, [surveyObservationId]); + + const qualitativeEnvironmentData: InsertObservationQualitativeEnvironmentRecord[] = + observation.standardColumns.qualitative_environments.map((item) => ({ + survey_observation_id: surveyObservationId, + environment_qualitative_id: item.environment_qualitative_id, + environment_qualitative_option_id: item.environment_qualitative_option_id + })); + await observationEnvironmentService.insertObservationQualitativeEnvironment(qualitativeEnvironmentData); + + const quantitativeEnvironmentData: InsertObservationQuantitativeEnvironmentRecord[] = + observation.standardColumns.quantitative_environments.map((item) => ({ + survey_observation_id: surveyObservationId, + environment_quantitative_id: item.environment_quantitative_id, + value: item.value + })); + await observationEnvironmentService.insertObservationQuantitativeEnvironment(quantitativeEnvironmentData); + + // -- Observation Subcount Data -------------------------------------------------------------- + + // TODO: Update 'delete subcount' process to fetch and find differences between incoming and existing data to + // only add, update or delete records as needed + // Delete old observation subcount records (critters, measurements and subcounts) await subCountService.deleteObservationSubCountRecords(surveyId, [surveyObservationId]); for (const subcount of observation.subcounts) { + // -- Subcount Data -------------------------------------------------------------- + // Insert observation subcount record for each subcount. const observationSubCountRecord = await subCountService.insertObservationSubCount({ survey_observation_id: surveyObservationId, - // NOTE: The UI currently only allows one subcount per observation, so the standardColumns count can be used - subcount: observation.subcounts.length === 1 ? observation.standardColumns.count : subcount.subcount, - observation_subcount_sign_id: subcount.observation_subcount_sign_id, + subcount: subcount.subcount, comment: subcount.comment }); - if (!observation.subcounts.length) { - return; - } + // -- Subcount Measurement Data -------------------------------------------------------------- - // 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) => ({ @@ -219,7 +263,7 @@ export class ObservationService extends DBService { critterbase_taxon_measurement_id: item.measurement_id, critterbase_measurement_qualitative_option_id: item.measurement_option_id })); - await measurementService.insertObservationSubCountQualitativeMeasurement(qualitativeData); + await observationSubCountMeasurementService.insertObservationSubCountQualitativeMeasurement(qualitativeData); } if (subcount.quantitative_measurements.length) { @@ -229,27 +273,9 @@ export class ObservationService extends DBService { 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); + await observationSubCountMeasurementService.insertObservationSubCountQuantitativeMeasurement( + quantitativeData + ); } } } @@ -318,12 +344,63 @@ export class ObservationService extends DBService { samplePeriods ] = await Promise.all([ // Fetch observations - this.observationRepository.getSurveyObservationsWithSamplingDataWithAttributesData(surveyId, pagination), + this.observationRepository.getSurveyObservations(surveyId, pagination), + // Fetch pagination count data + this.observationRepository.getSurveyObservationsCount(surveyId), + // Fetch supplementary data + subCountService.getMeasurementTypeDefinitionsForSurvey(surveyId), + this.getEnvironmentTypeDefinitionsForSurvey(surveyId), + samplePeriodService.getSamplePeriodsForSurvey(surveyId) + ]); + + return { + surveyObservations: surveyObservations, + supplementaryObservationData: { + observationCount, + qualitative_measurements: measurementTypeDefinitions.qualitative_measurements, + quantitative_measurements: measurementTypeDefinitions.quantitative_measurements, + qualitative_environments: environmentTypeDefinitions.qualitative_environments, + quantitative_environments: environmentTypeDefinitions.quantitative_environments, + sampling_data: samplePeriods + } + }; + } + + /** + * Retrieves all flattened observation records for the given survey along with supplementary data + * + * @param {number} surveyId + * @param {ApiPaginationOptions} [pagination] + * @return {*} {Promise<{ + * surveyObservations: FlattenedObservationRecordWithSamplingAndSubcountData[]; + * supplementaryObservationData: AllObservationSupplementaryData; + * }>} + * @memberof ObservationService + */ + async getSurveyFlattenedObservationsWithSupplementaryAndSamplingDataAndAttributeData( + surveyId: number, + pagination?: ApiPaginationOptions + ): Promise<{ + surveyObservations: FlattenedObservationRecordWithSamplingAndSubcountData[]; + supplementaryObservationData: AllObservationSupplementaryData; + }> { + const samplePeriodService = new SamplePeriodService(this.connection); + const subCountService = new SubCountService(this.connection); + + const [ + surveyObservations, + observationCount, + measurementTypeDefinitions, + environmentTypeDefinitions, + samplePeriods + ] = await Promise.all([ + // Fetch observations + this.observationRepository.getSurveyFlattenedObservations(surveyId, pagination), // Fetch pagination count data - this.observationRepository.getSurveyObservationCount(surveyId), + this.observationRepository.getSurveyFlattenedObservationsCount(surveyId), // Fetch supplementary data subCountService.getMeasurementTypeDefinitionsForSurvey(surveyId), - subCountService.getEnvironmentTypeDefinitionsForSurvey(surveyId), + this.getEnvironmentTypeDefinitionsForSurvey(surveyId), samplePeriodService.getSamplePeriodsForSurvey(surveyId) ]); @@ -358,7 +435,7 @@ export class ObservationService extends DBService { const surveyObservationsGeometry = await this.observationRepository.getSurveyObservationsGeometry(surveyId); // Get supplementary observation data - const observationCount = await this.observationRepository.getSurveyObservationCount(surveyId); + const observationCount = await this.observationRepository.getSurveyObservationsCount(surveyId); return { surveyObservationsGeometry, supplementaryObservationData: { observationCount } }; } @@ -370,8 +447,19 @@ export class ObservationService extends DBService { * @return {*} {Promise} * @memberof ObservationRepository */ - async getSurveyObservationCount(surveyId: number): Promise { - return this.observationRepository.getSurveyObservationCount(surveyId); + async getSurveyObservationsCount(surveyId: number): Promise { + return this.observationRepository.getSurveyObservationsCount(surveyId); + } + + /** + * Retrieves the count of flattened survey observations for the given survey + * + * @param {number} surveyId + * @return {*} {Promise} + * @memberof ObservationRepository + */ + async getSurveyFlattenedObservationsCount(surveyId: number): Promise { + return this.observationRepository.getSurveyFlattenedObservationsCount(surveyId); } /** @@ -391,6 +479,23 @@ export class ObservationService extends DBService { return this.observationRepository.findObservationsCount(isUserAdmin, systemUserId, filterFields); } + /** + * Retrieves the count of flattened survey observations for the given survey + * + * @param {boolean} isUserAdmin + * @param {(number | null)} systemUserId + * @param {IObservationAdvancedFilters} filterFields + * @return {*} {Promise} + * @memberof ObservationRepository + */ + async findFlattenedObservationsCount( + isUserAdmin: boolean, + systemUserId: number | null, + filterFields: IObservationAdvancedFilters + ): Promise { + return this.observationRepository.findFlattenedObservationsCount(isUserAdmin, systemUserId, filterFields); + } + /** * Inserts a survey observation submission record into the database and returns the key * @@ -472,245 +577,31 @@ export class ObservationService extends DBService { } /** - * 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. + * Returns a unique set of all environment type definitions for all environments of all observations in the given + * survey. * * @param {number} surveyId - * @param {number} submissionId - * @param {{ surveySamplePeriodId?: number }} [options] - * @return {*} {Promise} + * @return {*} {Promise<{ + * qualitative_environments: QualitativeEnvironmentTypeDefinition[]; + * quantitative_environments: QuantitativeEnvironmentTypeDefinition[]; + * }>} * @memberof ObservationService */ - async processObservationCsvSubmission( - surveyId: number, - submissionId: number, - options?: { surveySamplePeriodId?: number } - ): Promise { - defaultLog.debug({ label: 'processObservationCsvSubmission', submissionId }); - - // Get the observation submission record - const observationSubmissionRecord = await this.getObservationSubmissionById(surveyId, submissionId); - - // Get the S3 object containing the uploaded CSV file - const s3Object = await getFileFromS3(observationSubmissionRecord.key); - - // Get the csv file from the S3 object - const mediaFile = await parseS3File(s3Object); - - // Validate the CSV file mime type - if (mediaFile.mimetype !== 'text/csv') { - throw new Error('Failed to process file for importing observations. Invalid CSV file.'); - } - - // Construct the XLSX workbook - const xlsxWorkBook = constructXLSXWorkbook(mediaFile); - - // Get the default XLSX worksheet - const xlsxWorksheet = getDefaultWorksheet(xlsxWorkBook); - - // 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.'); - } - - // Filter out the standard columns from the worksheet - const nonStandardColumnNames = getNonStandardColumnNamesFromWorksheet( - xlsxWorksheet, - observationStandardColumnValidator - ); - - // Get the worksheet row objects - const worksheetRowObjects = getWorksheetRowObjects(xlsxWorksheet); - - // VALIDATE CODES ----------------------------------------------------------------------------------------- - - // TODO: This code column validation logic is specifically catered to the observation_subcount_signs code set, as - // it is the only code set currently being used in the observation CSVs, and is required. This logic will need to - // be updated to be more generic if other code sets are used in the future, or if they can be nullable. - - // Validate the Code columns in CSV file - const codeRepository = new CodeRepository(this.connection); - const codeTypeDefinitions = await getCodeTypeDefinitions(codeRepository); - - const codesToValidate = worksheetRowObjects.flatMap((row) => getColumnCellValue(row, 'OBSERVATION_SUBCOUNT_SIGN')); - - // Validate code column data - if (!validateCodes(codesToValidate, codeTypeDefinitions)) { - throw new Error('Failed to process file for importing observations. Code column validator failed.'); - } - - // VALIDATE MEASUREMENTS ----------------------------------------------------------------------------------------- - - // Validate the Measurement columns in CSV file - const critterBaseService = new CritterbaseService({ - keycloak_guid: this.connection.systemUserGUID(), - username: this.connection.systemUserIdentifier() - }); - - // Fetch all measurement type definitions from Critterbase for all unique TSNs - const tsns = worksheetRowObjects.map((row) => String(getColumnCellValue(row, 'ITIS_TSN').cell)); - - const tsnMeasurementTypeDefinitionMap = await getTsnMeasurementTypeDefinitionMap(tsns, critterBaseService); - - // 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(getColumnCellValue(row, 'ITIS_TSN').cell), - 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 environment type definitions from SIMS 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.'); - } - - // SAMPLING INFORMATION ----------------------------------------------------------------------------------------- - const samplePeriodService = new SamplePeriodService(this.connection); - - // Get sampling information for the survey to later validate - const samplingPeriods = await samplePeriodService.getSamplePeriodsForSurvey(surveyId); - - // -------------------------------------------------------------------------------------------------------------- - - // SamplePeriodHierarchyIds is only for when all records are being assigned to the same sampling period - let samplePeriodHierarchyIds: SurveySamplePeriodDetails; - - if (options?.surveySamplePeriodId) { - const samplePeriodService = new SamplePeriodService(this.connection); - samplePeriodHierarchyIds = await samplePeriodService.getSamplePeriodById(surveyId, options.surveySamplePeriodId); - } - - // Get subcount sign options and default option for when sign is null - const codeMap = new Map( - codeTypeDefinitions.OBSERVATION_SUBCOUNT_SIGN.map((option) => [option.name.toLowerCase(), option.id]) - ); - const defaultSubcountSignId = codeMap.get(defaultSubcountSign) || null; - - // Merge all the table rows into an array of InsertUpdateObservations[] - const newRowData: InsertUpdateObservations[] = worksheetRowObjects.map((row) => { - const observationSubcountSignId = this._getCodeIdFromCellValue( - getColumnCellValue(row, 'OBSERVATION_SUBCOUNT_SIGN').cell, - codeMap, - defaultSubcountSignId - ); - - const newSubcount: InsertSubCount = { - observation_subcount_id: null, - subcount: getColumnCellValue(row, 'COUNT').cell as number, - observation_subcount_sign_id: observationSubcountSignId ?? null, - comment: (getColumnCellValue(row, 'COMMENT').cell as string) ?? null, - qualitative_measurements: [], - quantitative_measurements: [], - qualitative_environments: [], - quantitative_environments: [] - }; - - const measurements = pullMeasurementsFromWorkSheetRowObject( - row, - measurementColumnNames, - tsnMeasurementTypeDefinitionMap - ); - newSubcount.qualitative_measurements = measurements.qualitative_measurements; - newSubcount.quantitative_measurements = measurements.quantitative_measurements; - - const environments = pullEnvironmentsFromWorkSheetRowObject( - row, - environmentColumnNames, - environmentColumnsTypeDefinitionMap - ); - newSubcount.qualitative_environments = environments.qualitative_environments; - newSubcount.quantitative_environments = environments.quantitative_environments; - - // If surveySamplePeriodId was included in the initial request, assign all rows to that sampling period - if (options?.surveySamplePeriodId) { - return { - standardColumns: { - survey_id: surveyId, - itis_tsn: getColumnCellValue(row, 'ITIS_TSN').cell as number, - itis_scientific_name: null, - survey_sample_site_id: samplePeriodHierarchyIds?.survey_sample_site_id ?? null, - method_technique_id: samplePeriodHierarchyIds?.method_technique_id ?? null, - survey_sample_period_id: samplePeriodHierarchyIds?.survey_sample_period_id ?? null, - latitude: getColumnCellValue(row, 'LATITUDE').cell as number, - longitude: getColumnCellValue(row, 'LONGITUDE').cell as number, - count: getColumnCellValue(row, 'COUNT').cell as number, - observation_time: getColumnCellValue(row, 'TIME').cell as string, - observation_date: getColumnCellValue(row, 'DATE').cell as string - }, - subcounts: [newSubcount] - }; - } - - // PROCESS AND VALIDATE SAMPLING INFORMATION ----------------------------------------------------------------------------------------- - const samplingData = pullSamplingDataFromWorksheetRowObject(row, samplingPeriods); - - if (!samplingData && getColumnCellValue(row, 'SAMPLING_SITE').cell) { - throw new Error('Failed to process file for importing observations. Sampling data validator failed.'); - } + async getEnvironmentTypeDefinitionsForSurvey(surveyId: number): Promise<{ + qualitative_environments: QualitativeEnvironmentTypeDefinition[]; + quantitative_environments: QuantitativeEnvironmentTypeDefinition[]; + }> { + const observationEnvironmentService = new ObservationEnvironmentService(this.connection); - return { - standardColumns: { - survey_id: surveyId, - itis_tsn: getColumnCellValue(row, 'ITIS_TSN').cell as number, - itis_scientific_name: null, - survey_sample_site_id: samplingData?.sampleSiteId ?? null, - method_technique_id: samplingData?.methodTechniqueId ?? null, - survey_sample_period_id: samplingData?.samplePeriodId ?? null, - latitude: getColumnCellValue(row, 'LATITUDE').cell as number, - longitude: getColumnCellValue(row, 'LONGITUDE').cell as number, - count: getColumnCellValue(row, 'COUNT').cell as number, - observation_time: getColumnCellValue(row, 'TIME').cell as string, - observation_date: getColumnCellValue(row, 'DATE').cell as string - }, - subcounts: [newSubcount] - }; - }); + const [qualitativeEnvironmentTypeDefinitions, quantitativeEnvironmentTypeDefinitions] = await Promise.all([ + observationEnvironmentService.getQualitativeEnvironmentTypeDefinitionsForSurvey(surveyId), + observationEnvironmentService.getQuantitativeEnvironmentTypeDefinitionsForSurvey(surveyId) + ]); - // Insert the parsed observation rows - await this.insertUpdateManualSurveyObservations(surveyId, newRowData); + return { + qualitative_environments: qualitativeEnvironmentTypeDefinitions, + quantitative_environments: quantitativeEnvironmentTypeDefinitions + }; } /** @@ -759,181 +650,19 @@ export class ObservationService extends DBService { * @memberof ObservationRepository */ async deleteObservationsByIds(surveyId: number, observationIds: number[]): Promise { - // Remove any existing child subcount records (observation_subcount, subcount_event, subcount_critter) before + // Remove any existing child subcount records (observation_subcount, subcount_critter) before // deleting survey_observation records const service = new SubCountService(this.connection); await service.deleteObservationSubCountRecords(surveyId, observationIds); + // Delete observation environments, if any + const observationEnvironmentService = new ObservationEnvironmentService(this.connection); + await observationEnvironmentService.deleteObservationEnvironments(surveyId, observationIds); + // Delete survey_observation records return this.observationRepository.deleteObservationsByIds(surveyId, observationIds); } - /** - * Processes manual observation data. - * - * This method: - * - Validates the given observations against environment definitions found in SIMS. - * - Validates the given observations against measurement definitions found in Critterbase. - * - Returns a boolean value indicating if the observations are valid. Returns as soon as an invalid observation is - * found. - * - * @param {InsertUpdateObservations[]} observationRows The observations to validate - * @param {CritterbaseService} critterBaseService Used to fetch measurement definitions to validate against - * @param {ObservationSubCountEnvironmentService} observationSubCountEnvironmentService Used to fetch environment - * definitions to validate against - * @return {*} {Promise} `true` if the observations are valid, `false` otherwise - * @memberof ObservationService - */ - async validateSurveyObservations( - observationRows: InsertUpdateObservations[], - critterBaseService: CritterbaseService, - observationSubCountEnvironmentService: ObservationSubCountEnvironmentService - ): Promise { - // VALIDATE ENVIRONMENTS ----------------------------------------------------------------------------------------- - - // Map incoming observation subcount data objects into IEnvironmentDataToValidate arrays - let qualitativeEnvironmentsToValidate: IEnvironmentDataToValidate[] = []; - let quantitativeEnvironmentsToValidate: IEnvironmentDataToValidate[] = []; - - for (const observationRow of observationRows) { - for (const subcount of observationRow.subcounts) { - qualitativeEnvironmentsToValidate = subcount.qualitative_environments.map((qualitative_environment) => { - return { - key: qualitative_environment.environment_qualitative_id, - value: qualitative_environment.environment_qualitative_option_id - }; - }); - - quantitativeEnvironmentsToValidate = subcount.quantitative_environments.map((quantitative_environment) => { - return { - key: quantitative_environment.environment_quantitative_id, - value: quantitative_environment.value - }; - }); - } - } - - // Fetch all environment type definitions from SIMS for all unique environment keys in the incoming data - const [qualitativeEnvironmentTypeDefinitions, quantitativeEnvironmentTypeDefinitions] = await Promise.all([ - observationSubCountEnvironmentService.getQualitativeEnvironmentTypeDefinitions( - qualitativeEnvironmentsToValidate.map((env) => env.key) - ), - observationSubCountEnvironmentService.getQuantitativeEnvironmentTypeDefinitions( - quantitativeEnvironmentsToValidate.map((env) => env.key) - ) - ]); - - // Validated incoming qualitative environments against fetched qualitative environment definitions - for (const qualitativeEnvironmentToValidate of qualitativeEnvironmentsToValidate) { - const foundEnvironment = qualitativeEnvironmentTypeDefinitions.find( - (env) => env.environment_qualitative_id === qualitativeEnvironmentToValidate.key - ); - - if (!foundEnvironment) { - defaultLog.debug({ - label: 'validateSurveyObservations', - message: 'Qualitative environments are invalid', - errors: ['Failed to find matching qualitative environment record'] - }); - // Return early if incoming environment column data is invalid - return false; - } - - const validOption = foundEnvironment?.options.find( - (option) => option.environment_qualitative_option_id === qualitativeEnvironmentToValidate.value - ); - - if (!validOption) { - defaultLog.debug({ - label: 'validateSurveyObservations', - message: 'Qualitative environments are invalid', - errors: ['Failed to find matching qualitative environment option record'] - }); - // Return early if incoming environment column data is invalid - return false; - } - } - - // Validated incoming quantitative environments against fetched quantitative environment definitions - for (const quantitativeEnvironmentToValidate of quantitativeEnvironmentsToValidate) { - const foundEnvironment = quantitativeEnvironmentTypeDefinitions.find( - (env) => env.environment_quantitative_id === quantitativeEnvironmentToValidate.key - ); - - if (!foundEnvironment) { - defaultLog.debug({ - label: 'validateSurveyObservations', - message: 'Quantitative environments are invalid', - errors: ['Failed to find matching quantitative environment record'] - }); - // Return early if incoming environment column data is invalid - return false; - } - - const validValue = isQuantitativeValueValid( - Number(quantitativeEnvironmentToValidate.value), - foundEnvironment.min, - foundEnvironment.max - ); - - if (!validValue) { - defaultLog.debug({ - label: 'validateSurveyObservations', - message: 'Quantitative environments are invalid', - errors: ['Quantitative environment value is out of range'] - }); - // Return early if incoming environment column data is invalid - return false; - } - } - - // VALIDATE MEASUREMENTS ----------------------------------------------------------------------------------------- - - // 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 objects into a IMeasurementDataToValidate array - const measurementsToValidate: IMeasurementDataToValidate[] = observationRows.flatMap( - (item: InsertUpdateObservations) => { - return item.subcounts.flatMap((subcount) => { - const qualitativeMeasurementsToValidate = subcount.qualitative_measurements.map((qualitative_measurement) => { - return { - tsn: String(item.standardColumns.itis_tsn), - key: qualitative_measurement.measurement_id, - value: qualitative_measurement.measurement_option_id - }; - }); - - 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 [...qualitativeMeasurementsToValidate, ...quantitativeMeasurementsToValidate]; - }); - } - ); - - // Validate measurement data against fetched measurement definition - const areMeasurementsValid = validateMeasurements(measurementsToValidate, tsnMeasurementTypeDefinitionMap); - - if (!areMeasurementsValid) { - defaultLog.debug({ label: 'validateSurveyObservations', message: 'Measurements are invalid' }); - // Return early if measurements are invalid - return false; - } - - // --------------------------------------------------------------------------------------------------------------- - - // Return true if both environments and measurements are valid - return true; - } - /** * Gets the code id value with a matching name from a pre-mapped set of options. If the function returns null, the * request should probably throw an error. diff --git a/api/src/services/observation-services/utils.ts b/api/src/services/observation-services/utils.ts index 10e992ef19..1031da2ce9 100644 --- a/api/src/services/observation-services/utils.ts +++ b/api/src/services/observation-services/utils.ts @@ -4,10 +4,6 @@ import isSameOrBefore from 'dayjs/plugin/isSameOrBefore'; import { DefaultDateFormat, DefaultTimeFormat, DefaultTimeFormatNoSeconds } from '../../constants/dates'; import { SurveySamplePeriodDetails } from '../../repositories/sample-period-repository'; import { isDateString, isDateTimeString, isTimeString } from '../../utils/date-time-utils'; -import { - EnvironmentNameTypeDefinitionMap, - isEnvironmentQualitativeTypeDefinition -} from '../../utils/observation-xlsx-utils/environment-column-utils'; import { getMeasurementFromTsnMeasurementTypeDefinitionMap, isMeasurementCBQualitativeTypeDefinition, @@ -434,66 +430,3 @@ export function pullMeasurementsFromWorkSheetRowObject( return foundMeasurements; } - -/** - * This function is a helper method for the `processObservationCsvSubmission` function. It will take row data from an - * uploaded CSV. - * - * @export - * @param {Record} row - * @param {string[]} environmentColumns - * @param {EnvironmentNameTypeDefinitionMap} environmentNameTypeDefinitionMap - * @return {*} {(Pick)} - */ -export function 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).toLowerCase()); - - 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; -} diff --git a/api/src/services/subcount-service.test.ts b/api/src/services/subcount-service.test.ts index e527439627..af0bcd8f11 100644 --- a/api/src/services/subcount-service.test.ts +++ b/api/src/services/subcount-service.test.ts @@ -2,15 +2,9 @@ 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 { ObservationSubcountModel } from '../database-models/observation_subcount'; import { ObservationSubCountMeasurementRepository } from '../repositories/observation-subcount-measurement-repository'; -import { - InsertObservationSubCount, - InsertSubCountEvent, - ObservationSubCountRecord, - SubCountEventRecord, - SubCountRepository -} from '../repositories/subcount-repository'; +import { InsertObservationSubCount, SubCountRepository } from '../repositories/subcount-repository'; import { getMockDBConnection } from '../__mocks__/db'; import { SubCountService } from './subcount-service'; @@ -26,32 +20,32 @@ describe('SubCountService', () => { const mockDbConnection = getMockDBConnection(); const subCountService = new SubCountService(mockDbConnection); + const mockObservationSubcountModel: ObservationSubcountModel = { + observation_subcount_id: 1, + survey_observation_id: 2, + subcount: 3, + comment: 'comment', + create_user: 1, + create_date: '2021-01-01', + update_user: null, + update_date: null, + revision_count: 0 + }; + + const mockInsertObservationSubCount: InsertObservationSubCount = { + survey_observation_id: 2, + subcount: 3, + comment: 'comment' + }; + const insertObservationSubCountStub = sinon .stub(SubCountRepository.prototype, 'insertObservationSubCount') - .resolves({ observation_subcount_id: 1 } as ObservationSubCountRecord); - - const response = await subCountService.insertObservationSubCount({ - survey_observation_id: 1 - } as InsertObservationSubCount); - - expect(insertObservationSubCountStub).to.be.calledOnceWith({ survey_observation_id: 1 }); - expect(response).to.eql({ observation_subcount_id: 1 }); - }); - }); - - describe('insertSubCountEvent', () => { - it('should insert subcount event', async () => { - const mockDbConnection = getMockDBConnection(); - const subCountService = new SubCountService(mockDbConnection); - - const insertSubCountEventStub = sinon - .stub(SubCountRepository.prototype, 'insertSubCountEvent') - .resolves({ observation_subcount_id: 1 } as SubCountEventRecord); + .resolves(mockObservationSubcountModel); - const response = await subCountService.insertSubCountEvent({ observation_subcount_id: 1 } as InsertSubCountEvent); + const response = await subCountService.insertObservationSubCount(mockInsertObservationSubCount); - expect(insertSubCountEventStub).to.be.calledOnceWith({ observation_subcount_id: 1 }); - expect(response).to.eql({ observation_subcount_id: 1 }); + expect(insertObservationSubCountStub).to.be.calledOnceWith(mockInsertObservationSubCount); + expect(response).to.eql(mockObservationSubcountModel); }); }); @@ -70,11 +64,6 @@ describe('SubCountService', () => { const deleteObservationMeasurementsStub = sinon .stub(ObservationSubCountMeasurementRepository.prototype, 'deleteObservationMeasurements') .resolves(); - - const deleteObservationEnvironmentsStub = sinon - .stub(ObservationSubCountEnvironmentRepository.prototype, 'deleteObservationEnvironments') - .resolves(); - const deleteObservationSubCountRecordsStub = sinon .stub(SubCountRepository.prototype, 'deleteObservationSubCountRecords') .resolves(); @@ -86,7 +75,6 @@ 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 9de252bbd0..49cf4898cf 100644 --- a/api/src/services/subcount-service.ts +++ b/api/src/services/subcount-service.ts @@ -1,19 +1,12 @@ +import { ObservationSubcountRecord } from '../database-models/observation_subcount'; import { IDBConnection } from '../database/db'; -import { EnvironmentType } from '../repositories/observation-subcount-environment-repository'; -import { - InsertObservationSubCount, - InsertSubCountEvent, - ObservationSubCountRecord, - SubCountEventRecord, - SubCountRepository -} from '../repositories/subcount-repository'; +import { InsertObservationSubCount, SubCountRepository } from '../repositories/subcount-repository'; import { CBQualitativeMeasurementTypeDefinition, CBQuantitativeMeasurementTypeDefinition, 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 { @@ -28,24 +21,13 @@ export class SubCountService extends DBService { * Inserts a new observation sub count * * @param {InsertObservationSubCount} record - * @returns {*} {Promise} + * @returns {*} {Promise} * @memberof SubCountService */ - async insertObservationSubCount(record: InsertObservationSubCount): Promise { + async insertObservationSubCount(record: InsertObservationSubCount): Promise { return this.subCountRepository.insertObservationSubCount(record); } - /** - * Inserts a new sub count event - * - * @param {InsertSubCountEvent} record - * @returns {*} {Promise} - * @memberof SubCountService - */ - async insertSubCountEvent(records: InsertSubCountEvent): Promise { - return this.subCountRepository.insertSubCountEvent(records); - } - /** * Delete observation_subcount records for the given set of survey observation ids. * @@ -57,16 +39,14 @@ export class SubCountService extends DBService { * @memberof SubCountService */ async deleteObservationSubCountRecords(surveyId: number, surveyObservationIds: number[]): Promise { - // Delete child subcount_critter records, if any - await this.subCountRepository.deleteSubCountCritterRecordsForObservationId(surveyId, surveyObservationIds); - - // Delete child observation measurements, if any 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); + await Promise.all([ + // Delete child subcount_critter records, if any + this.subCountRepository.deleteSubCountCritterRecordsForObservationId(surveyId, surveyObservationIds), + // Delete child observation measurements, if any + observationSubCountMeasurementService.deleteObservationMeasurements(surveyId, surveyObservationIds) + ]); // Delete observation_subcount records, if any return this.subCountRepository.deleteObservationSubCountRecords(surveyId, surveyObservationIds); @@ -108,26 +88,4 @@ 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.getQualitativeEnvironmentTypeDefinitionsForSurvey(surveyId), - observationSubCountEnvironmentService.getQuantitativeEnvironmentTypeDefinitionsForSurvey(surveyId) - ]); - - return { - qualitative_environments: qualitativeEnvironmentTypeDefinitions, - quantitative_environments: quantitativeEnvironmentTypeDefinitions - }; - } } diff --git a/api/src/utils/observation-xlsx-utils/code-column-utils.test.ts b/api/src/utils/observation-xlsx-utils/code-column-utils.test.ts index a20ea297e4..951548fde2 100644 --- a/api/src/utils/observation-xlsx-utils/code-column-utils.test.ts +++ b/api/src/utils/observation-xlsx-utils/code-column-utils.test.ts @@ -16,26 +16,24 @@ describe('environment-column-utils', () => { const codeRepository = new CodeRepository(dbConnectionObj); - const getObservationSubcountSignsStub = sinon - .stub(CodeRepository.prototype, 'getObservationSubcountSigns') - .resolves([ - { - id: 1, - name: 'Sign 1', - description: 'Sign 1 Desc' - }, - { - id: 1, - name: 'Sign 2', - description: 'Sign 2 Desc' - } - ]); + const getObservationSignsStub = sinon.stub(CodeRepository.prototype, 'getObservationSigns').resolves([ + { + id: 1, + name: 'Sign 1', + description: 'Sign 1 Desc' + }, + { + id: 1, + name: 'Sign 2', + description: 'Sign 2 Desc' + } + ]); const result = await getCodeTypeDefinitions(codeRepository); - expect(getObservationSubcountSignsStub).to.have.been.calledOnce; + expect(getObservationSignsStub).to.have.been.calledOnce; expect(result).to.eql({ - OBSERVATION_SUBCOUNT_SIGN: [ + OBSERVATION_SIGN: [ { id: 1, name: 'Sign 1', @@ -80,7 +78,7 @@ describe('environment-column-utils', () => { ]; const codeTypeDefinitions = { - OBSERVATION_SUBCOUNT_SIGN: [ + OBSERVATION_SIGN: [ { id: 1, name: 'Sign 1', @@ -132,7 +130,7 @@ describe('environment-column-utils', () => { ]; const codeTypeDefinitions = { - OBSERVATION_SUBCOUNT_SIGN: [ + OBSERVATION_SIGN: [ { id: 1, name: 'Sign 1', diff --git a/api/src/utils/observation-xlsx-utils/code-column-utils.ts b/api/src/utils/observation-xlsx-utils/code-column-utils.ts index 03d01e12da..5dbef5195b 100644 --- a/api/src/utils/observation-xlsx-utils/code-column-utils.ts +++ b/api/src/utils/observation-xlsx-utils/code-column-utils.ts @@ -1,32 +1,23 @@ import { CodeRepository, IAllCodeSets } from '../../repositories/code-repository'; import { CellObject } from '../xlsx-utils/column-validator-utils'; -// TODO: This code column validation logic is specifically catered to the observation_subcount_signs code set, as +// TODO: This code column validation logic is specifically catered to the observation_signs code set, as // it is the only code set currently being used in the observation CSVs, and is required. This logic will need to // be updated to be more generic if other code sets are used in the future, or if they can be nullable. -/** - * 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} - */ /** * Returns SIMS code sets for any observation code columns (columns where the value is a code). * * @export * @param {CodeRepository} codeRepository - * @return {*} {Promise<{ OBSERVATION_SUBCOUNT_SIGN: IAllCodeSets['observation_subcount_signs'] }>} + * @return {*} {Promise<{ OBSERVATION_SIGN: IAllCodeSets['observation_signs'] }>} */ export async function getCodeTypeDefinitions( codeRepository: CodeRepository -): Promise<{ OBSERVATION_SUBCOUNT_SIGN: IAllCodeSets['observation_subcount_signs'] }> { - const observation_subcount_signs = await codeRepository.getObservationSubcountSigns(); +): Promise<{ OBSERVATION_SIGN: IAllCodeSets['observation_signs'] }> { + const observation_signs = await codeRepository.getObservationSigns(); - return { OBSERVATION_SUBCOUNT_SIGN: observation_subcount_signs }; + return { OBSERVATION_SIGN: observation_signs }; } /** @@ -35,12 +26,12 @@ export async function getCodeTypeDefinitions( * * @export * @param {CellObject[]} codesToValidate - * @param {{ OBSERVATION_SUBCOUNT_SIGN: IAllCodeSets['observation_subcount_signs'] }} codeTypeDefinitions + * @param {{ OBSERVATION_SIGN: IAllCodeSets['observation_signs'] }} codeTypeDefinitions * @return {*} {boolean} */ export function validateCodes( codesToValidate: CellObject[], - codeTypeDefinitions: { OBSERVATION_SUBCOUNT_SIGN: IAllCodeSets['observation_subcount_signs'] } + codeTypeDefinitions: { OBSERVATION_SIGN: IAllCodeSets['observation_signs'] } ): boolean { return codesToValidate.every((codeToValidate) => { if (!codeToValidate.cell) { @@ -48,7 +39,7 @@ export function validateCodes( return true; } - const codeTypeDefinition = codeTypeDefinitions.OBSERVATION_SUBCOUNT_SIGN; + const codeTypeDefinition = codeTypeDefinitions.OBSERVATION_SIGN; return isCodeValueValid( codeToValidate.cell.toLowerCase(), 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 index 9f2a49661a..b788735dc7 100644 --- a/api/src/utils/observation-xlsx-utils/environment-column-utils.test.ts +++ b/api/src/utils/observation-xlsx-utils/environment-column-utils.test.ts @@ -2,11 +2,10 @@ 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'; +} from '../../repositories/observation-environment-repository'; +import { ObservationEnvironmentService } from '../../services/observation-environment-service'; import { getMockDBConnection } from '../../__mocks__/db'; import * as environment_column_utils from './environment-column-utils'; import { EnvironmentNameTypeDefinitionMap, IEnvironmentDataToValidate } from './environment-column-utils'; @@ -20,7 +19,7 @@ describe('environment-column-utils', () => { const dbConnectionObj = getMockDBConnection(); const findQualitativeEnvironmentTypeDefinitionsStub = sinon - .stub(ObservationSubCountEnvironmentService.prototype, 'findQualitativeEnvironmentTypeDefinitions') + .stub(ObservationEnvironmentService.prototype, 'findQualitativeEnvironmentTypeDefinitions') .resolves([ { environment_qualitative_id: '11-123-456', @@ -51,7 +50,7 @@ describe('environment-column-utils', () => { ]); const findQuantitativeEnvironmentTypeDefinitionsStub = sinon - .stub(ObservationSubCountEnvironmentService.prototype, 'findQuantitativeEnvironmentTypeDefinitions') + .stub(ObservationEnvironmentService.prototype, 'findQuantitativeEnvironmentTypeDefinitions') .resolves([ { environment_quantitative_id: '66-123-456', @@ -64,12 +63,11 @@ describe('environment-column-utils', () => { ]); const columnNames: string[] = ['Wind Speed', 'Weight', 'Col With No Match', 'Wind Direction', 'Height']; - const observationSubCountEnvironmentService: ObservationSubCountEnvironmentService = - new ObservationSubCountEnvironmentService(dbConnectionObj); + const observationEnvironmentService = new ObservationEnvironmentService(dbConnectionObj); const result = await environment_column_utils.getEnvironmentTypeDefinitionsFromColumnNames( columnNames, - observationSubCountEnvironmentService + observationEnvironmentService ); expect(findQualitativeEnvironmentTypeDefinitionsStub).to.have.been.calledOnceWith(columnNames); @@ -120,7 +118,10 @@ describe('environment-column-utils', () => { 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 = { + const environmentTypeDefinitions: { + qualitative_environments: QualitativeEnvironmentTypeDefinition[]; + quantitative_environments: QuantitativeEnvironmentTypeDefinition[]; + } = { qualitative_environments: [ { environment_qualitative_id: '11-123-456', diff --git a/api/src/utils/observation-xlsx-utils/environment-column-utils.ts b/api/src/utils/observation-xlsx-utils/environment-column-utils.ts index e4e6b14c5f..cbdab5fc33 100644 --- a/api/src/utils/observation-xlsx-utils/environment-column-utils.ts +++ b/api/src/utils/observation-xlsx-utils/environment-column-utils.ts @@ -1,9 +1,8 @@ import { - EnvironmentType, QualitativeEnvironmentTypeDefinition, QuantitativeEnvironmentTypeDefinition -} from '../../repositories/observation-subcount-environment-repository'; -import { ObservationSubCountEnvironmentService } from '../../services/observation-subcount-environment-service'; +} from '../../repositories/observation-environment-repository'; +import { ObservationEnvironmentService } from '../../services/observation-environment-service'; import { isQualitativeValueValid, isQuantitativeValueValid } from './common-utils'; export type EnvironmentNameTypeDefinitionMap = Map< @@ -22,16 +21,22 @@ export interface IEnvironmentDataToValidate { * * @export * @param {string[]} columnNames - * @param {ObservationSubCountEnvironmentService} observationSubCountEnvironmentService - * @return {*} {Promise} + * @param {ObservationEnvironmentService} observationEnvironmentService + * @return {*} {Promise<{ + * qualitative_environments: QualitativeEnvironmentTypeDefinition[]; + * quantitative_environments: QuantitativeEnvironmentTypeDefinition[]; + * }>} */ export async function getEnvironmentTypeDefinitionsFromColumnNames( columnNames: string[], - observationSubCountEnvironmentService: ObservationSubCountEnvironmentService -): Promise { + observationEnvironmentService: ObservationEnvironmentService +): Promise<{ + qualitative_environments: QualitativeEnvironmentTypeDefinition[]; + quantitative_environments: QuantitativeEnvironmentTypeDefinition[]; +}> { const [qualitative_environments, quantitative_environments] = await Promise.all([ - observationSubCountEnvironmentService.findQualitativeEnvironmentTypeDefinitions(columnNames), - observationSubCountEnvironmentService.findQuantitativeEnvironmentTypeDefinitions(columnNames) + observationEnvironmentService.findQualitativeEnvironmentTypeDefinitions(columnNames), + observationEnvironmentService.findQuantitativeEnvironmentTypeDefinitions(columnNames) ]); return { qualitative_environments, quantitative_environments }; @@ -43,12 +48,18 @@ export async function getEnvironmentTypeDefinitionsFromColumnNames( * * @export * @param {string[]} columnNames - * @param {EnvironmentType} environmentTypeDefinitions + * @param {{ + * qualitative_environments: QualitativeEnvironmentTypeDefinition[]; + * quantitative_environments: QuantitativeEnvironmentTypeDefinition[]; + * }} environmentTypeDefinitions * @return {*} {EnvironmentNameTypeDefinitionMap} */ export function getEnvironmentColumnsTypeDefinitionMap( columnNames: string[], - environmentTypeDefinitions: EnvironmentType + environmentTypeDefinitions: { + qualitative_environments: QualitativeEnvironmentTypeDefinition[]; + quantitative_environments: QuantitativeEnvironmentTypeDefinition[]; + } ): EnvironmentNameTypeDefinitionMap { const columnNameDefinitionMap = new Map< string, diff --git a/api/src/utils/xlsx-utils/column-aliases.ts b/api/src/utils/xlsx-utils/column-aliases.ts index a9838a024e..e1b53b1224 100644 --- a/api/src/utils/xlsx-utils/column-aliases.ts +++ b/api/src/utils/xlsx-utils/column-aliases.ts @@ -5,7 +5,7 @@ export const CSV_COLUMN_ALIASES: Record, Uppercase[]> DESCRIPTION: ['COMMENT', 'COMMENTS', 'NOTES'], ALIAS: ['NICKNAME', 'ANIMAL'], MARKING_TYPE: ['TYPE'], - OBSERVATION_SUBCOUNT_SIGN: ['SIGN'], + OBSERVATION_SIGN: ['SIGN'], SAMPLING_SITE: ['SITE', 'SITE ID', 'LOCATION', 'SAMPLING SITE', 'STATION'], METHOD_TECHNIQUE: ['METHOD', 'TECHNIQUE'], SAMPLING_PERIOD: ['PERIOD', 'TIME PERIOD', 'SESSION'], diff --git a/app/src/components/buttons/HelpButtonStack.tsx b/app/src/components/buttons/HelpButtonStack.tsx index 9e7bd7f895..a19e727f17 100644 --- a/app/src/components/buttons/HelpButtonStack.tsx +++ b/app/src/components/buttons/HelpButtonStack.tsx @@ -3,7 +3,7 @@ import HelpButtonTooltip from 'components/buttons/HelpButtonTooltip'; import { PropsWithChildren } from 'react'; interface IHelpButtonStackProps extends StackProps { - helpText: string; + helpText?: string; } const HelpButtonStack = (props: PropsWithChildren) => { @@ -11,7 +11,7 @@ const HelpButtonStack = (props: PropsWithChildren) => { return ( {children} - + {helpText ? : null} ); }; diff --git a/app/src/components/data-grid/GenericGridColumnDefinitions.tsx b/app/src/components/data-grid/GenericGridColumnDefinitions.tsx index 81317f0279..6582bda944 100644 --- a/app/src/components/data-grid/GenericGridColumnDefinitions.tsx +++ b/app/src/components/data-grid/GenericGridColumnDefinitions.tsx @@ -17,15 +17,16 @@ export const GenericDateColDef = (props: { field: string; headerName: string; description?: string; + editable?: boolean; hasError: (params: GridCellParams) => boolean; }): GridColDef => { - const { field, headerName, hasError, description } = props; + const { field, headerName, hasError, description, editable } = props; return { field, headerName, description: description, - editable: true, + editable: editable ?? true, hideable: true, type: 'date', minWidth: 150, @@ -68,14 +69,15 @@ export const GenericTimeColDef = (props: { field: string; headerName: string; description?: string; + editable?: boolean; hasError: (params: GridCellParams) => boolean; }): GridColDef => { - const { hasError, field, headerName, description } = props; + const { hasError, field, headerName, description, editable } = props; return { field, headerName, - editable: true, + editable: editable ?? true, hideable: true, description: description, type: 'string', @@ -132,15 +134,16 @@ export const GenericLatitudeColDef = (props: { field: string; headerName: string; description?: string; + editable?: boolean; hasError: (params: GridCellParams) => boolean; }): GridColDef => { - const { hasError, field, headerName, description } = props; + const { hasError, field, headerName, description, editable } = props; return { field, headerName, description: description, - editable: true, + editable: editable ?? true, hideable: true, width: 120, disableColumnMenu: true, @@ -193,15 +196,16 @@ export const GenericLongitudeColDef = (props: { field: string; headerName: string; description?: string; + editable?: boolean; hasError: (params: GridCellParams) => boolean; }): GridColDef => { - const { hasError, field, headerName, description } = props; + const { hasError, field, headerName, description, editable } = props; return { field, headerName, description: description, - editable: true, + editable: editable ?? true, hideable: true, width: 120, disableColumnMenu: true, @@ -254,11 +258,12 @@ export const GenericCommentColDef = (props: { field: string; headerName: string; description?: string; + editable?: boolean; hasError: (params: GridCellParams) => boolean; handleClose: () => void; handleOpen: (params: GridRenderEditCellParams) => void; }): GridColDef => { - const { field, headerName, description } = props; + const { field, headerName, description, editable } = props; return { field, @@ -266,7 +271,7 @@ export const GenericCommentColDef = (props: { description: description, width: 75, disableColumnMenu: true, - editable: true, + editable: editable ?? true, align: 'center', renderEditCell: (params) => { return ( @@ -297,7 +302,11 @@ export const GenericCommentColDef = (props: { return ( - + { + props.handleOpen(params); + }}> {params.value ? ( ) : ( diff --git a/app/src/components/fields/AutocompleteField.tsx b/app/src/components/fields/AutocompleteField.tsx index 34878669e6..9e0d4c426b 100644 --- a/app/src/components/fields/AutocompleteField.tsx +++ b/app/src/components/fields/AutocompleteField.tsx @@ -7,60 +7,143 @@ import Typography from '@mui/material/Typography'; import HelpButtonTooltip from 'components/buttons/HelpButtonTooltip'; import { useFormikContext } from 'formik'; import get from 'lodash-es/get'; -import { SyntheticEvent } from 'react'; +import { SyntheticEvent, useMemo } from 'react'; -export interface IAutocompleteFieldOption { - value: T; +export interface IAutocompleteFieldOption { + value: OptionValueType; label: string; description?: string | null; } -export interface IAutocompleteField { +export interface IAutocompleteField< + OptionValueType extends string | number, + OptionType extends IAutocompleteFieldOption +> { + /** + * The id of the field. + */ id: string; + /** + * The label of the field. This is displayed in the UI. + */ label: string; + /** + * The name of the field. This is used to link the field to the formik state. + */ name: string; - options: IAutocompleteFieldOption[]; /** - * Selected options are filtered from the options list, so they cannot be selected again + * An array of autocomplete options. + */ + options: OptionType[]; + /** + * An array of autocomplete option values. + * + * Options with these values will not be displayed in the autocomplete. + */ + selectedOptions?: OptionValueType[]; + /** + * If `true`, the field will be disabled. */ - selectedOptions?: T[]; disabled?: boolean; + /** + * If `true`, the field will be in a loading state. + */ loading?: boolean; + /** + * Additional styles to apply to the field. + */ sx?: TextFieldProps['sx']; //https://github.com/TypeStrong/fork-ts-checker-webpack-plugin/issues/271#issuecomment-1561891271 + /** + * If `true`, the field will be marked required. + */ required?: boolean; + /** + * The maximum number of options to display in the autocomplete. + */ filterLimit?: number; + /** + * If `true`, the value will be displayed in the field. + */ showValue?: boolean; + /** + * If `true`, the clear button will be disabled. + */ disableClearable?: boolean; - optionFilter?: 'value' | 'label'; // used to filter existing/ set data for the AutocompleteField, defaults to value in getExistingValue function + /** + * The help text to display. + */ helpText?: string; - getOptionDisabled?: (option: IAutocompleteFieldOption) => boolean; - onChange?: (event: SyntheticEvent, option: IAutocompleteFieldOption | null) => void; - renderOption?: (params: React.HTMLAttributes, option: IAutocompleteFieldOption) => React.ReactNode; + /** + * Function that receives an option, and returns a boolean indicating if that option should be disabled or not. + */ + getOptionDisabled?: (option: OptionType) => boolean; + /** + * Callback fired when the autocomplete onChange event is triggered. + */ + onChange?: (event: SyntheticEvent, option: OptionType | null) => void; + /** + * Function that returns a custom render component for the option. + */ + renderOption?: (params: React.HTMLAttributes, option: OptionType) => React.ReactNode; + /** + * Callback fired when the autcomplete input value changes. + */ onInputChange?: (event: React.SyntheticEvent, value: string, reason: string) => void; } // To be used when you want an autocomplete field with no freesolo allowed but only one option can be selected -const AutocompleteField = (props: IAutocompleteField) => { - const { touched, errors, setFieldValue, values } = useFormikContext>(); +const AutocompleteField = < + OptionValueType extends string | number, + OptionType extends IAutocompleteFieldOption = IAutocompleteFieldOption +>( + props: IAutocompleteField +) => { + const { + id, + label, + name, + options, + selectedOptions, + disabled, + loading, + sx, + required, + filterLimit, + showValue, + disableClearable, + helpText, + getOptionDisabled, + onChange, + renderOption + } = props; + + const { touched, errors, setFieldValue, values } = useFormikContext(); - const getExistingValue = (existingValue: T): IAutocompleteFieldOption => { - const result = props.options.find((option) => existingValue === option[props.optionFilter ?? 'value']); + const getExistingValue = (existingValue: OptionValueType): OptionType => { + const result = options.find((option) => existingValue === option.value); if (!result) { - return null as unknown as IAutocompleteFieldOption; + return null as unknown as OptionType; } return result; }; - // Filter out selected options from the available options - const filteredOptions = props.options.filter((option) => - props.selectedOptions && props.selectedOptions.includes(option.value) ? false : true + // If selected options is provided, filter out selected options from the available options + const filteredOptions = useMemo( + () => + selectedOptions + ? options.filter((option) => + // If the option is in the selected options, return false to filter it out + selectedOptions.some((selectedOption) => selectedOption === option.value) ? false : true + ) + : options, + [options, selectedOptions] ); const handleGetOptionSelected = ( - option: IAutocompleteFieldOption, - value: IAutocompleteFieldOption + option: IAutocompleteFieldOption, + value: IAutocompleteFieldOption ): boolean => { if (!option?.value || !value?.value) { return false; @@ -74,42 +157,42 @@ const AutocompleteField = (props: IAutocompleteField< clearOnBlur blurOnSelect handleHomeEndKeys - id={props.id} + id={id} fullWidth - data-testid={props.id} - value={getExistingValue(get(values, props.name))} + data-testid={id} + value={getExistingValue(get(values, name))} options={filteredOptions} getOptionLabel={(option) => option.label} - disableClearable={props.disableClearable} + disableClearable={disableClearable} isOptionEqualToValue={handleGetOptionSelected} - getOptionDisabled={props.getOptionDisabled} - filterOptions={createFilterOptions({ limit: props.filterLimit })} - disabled={props?.disabled || false} - sx={{ flex: '1 1 auto', ...props.sx }} - loading={props.loading} + getOptionDisabled={getOptionDisabled} + filterOptions={createFilterOptions({ limit: filterLimit })} + disabled={disabled || false} + sx={{ flex: '1 1 auto', ...sx }} + loading={loading} onInputChange={(_event, _value, reason) => { if (reason === 'reset') { return; } if (reason === 'clear') { - setFieldValue(props.name, null); + setFieldValue(name, null); return; } }} onChange={(event, option) => { - if (props.onChange) { - props.onChange(event, option); + if (onChange) { + onChange(event, option); return; } if (option?.value) { - setFieldValue(props.name, option?.value); + setFieldValue(name, option?.value); } }} renderOption={(params, option) => { - if (props.renderOption) { - return props.renderOption(params, option); + if (renderOption) { + return renderOption(params, option); } return ( @@ -137,19 +220,19 @@ const AutocompleteField = (props: IAutocompleteField< return ( - {props.loading ? : null} - {props.helpText && } + {loading ? : null} + {helpText && } {params.InputProps.endAdornment} ) diff --git a/app/src/components/fields/DualAutocompleteField.tsx b/app/src/components/fields/DualAutocompleteField.tsx deleted file mode 100644 index e664532b9c..0000000000 --- a/app/src/components/fields/DualAutocompleteField.tsx +++ /dev/null @@ -1,138 +0,0 @@ -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 { useFormikContext } from 'formik'; -import { get } from 'lodash'; -import { useMemo, useState } from 'react'; - -/** - * A generic component for rendering two autocomplete fields that are interdependent (category -> unit). - * @param props The component props. - */ -interface IDualAutocompleteFieldProps { - /** - * Label to display for the first autocomplete - */ - label: string; - - /** - * The categories data to display in the FIRST autocomplete field. - */ - categoryOptions: { value: TCategory; label: string }[]; - - /** - * The units data to display in the SECOND autocomplete field, based on the selected category. - */ - getUnitOptions: (categoryId: TCategory) => { value: TUnit; label: string }[]; - - /** - * The units data to display in the SECOND autocomplete field, based on the selected category. - */ - getUnitAutocompleteLabel?: (categoryId: TCategory) => string; - - /** - * The field name for the category in Formik. - */ - formikCategoryFieldName: string; - - /** - * The field name for the unit in Formik. - */ - formikUnitFieldName: string; - - /** - * Callback for when the delete button is clicked. - */ - onDelete: () => void; -} - -/** - * Returns two autocomplete fields where the values for the second dropdown depend on the value of the first dropdown. - * In this component, CATEGORY refers to the first dropdown and UNIT refers to the second dropdown. - * - * @param {IDualAutocompleteFieldProps}props - * @returns - */ -export const DualAutocompleteField = ( - props: IDualAutocompleteFieldProps -) => { - const { - categoryOptions, - getUnitOptions, - getUnitAutocompleteLabel, - label, - formikCategoryFieldName, - formikUnitFieldName, - onDelete - } = props; - const formik = useFormikContext(); - - // The label of the second dropdown, which defaults to "Value" unless set with the getUnitAutocompleteLabel prop - const [unitLabel, setUnitLabel] = useState('Value'); - - const categoryId: TCategory | null = get(formik.values, formikCategoryFieldName); - - // Filter units based on the selected category and exclude already selected units (if any) - const filteredUnits = useMemo(() => { - if (!categoryId) return []; - - const availableUnits = getUnitOptions(categoryId); - - // Update the label of the second dropdown if a custom label is provided - if (getUnitAutocompleteLabel) { - const label = getUnitAutocompleteLabel(categoryId); - setUnitLabel(label); - } - - return availableUnits; - }, [categoryId, getUnitAutocompleteLabel, getUnitOptions]); - - return ( - - { - if (!option) { - formik.setFieldValue(formikUnitFieldName, undefined); - setUnitLabel('Select a unit'); - return; - } - formik.setFieldValue(formikCategoryFieldName, option.value); - }} - required - sx={{ flex: 0.5 }} - /> - - { - formik.setFieldValue(formikUnitFieldName, option?.value ?? undefined); - }} - required - sx={{ flex: 0.5 }} - /> - - - - - - ); -}; diff --git a/app/src/components/fields/dual-autocomplete-field/DualAutocompleteField.tsx b/app/src/components/fields/dual-autocomplete-field/DualAutocompleteField.tsx new file mode 100644 index 0000000000..09e64b715e --- /dev/null +++ b/app/src/components/fields/dual-autocomplete-field/DualAutocompleteField.tsx @@ -0,0 +1,187 @@ +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, { IAutocompleteFieldOption } from 'components/fields/AutocompleteField'; +import CustomTextField from 'components/fields/CustomTextField'; +import { useFormikContext } from 'formik'; +import { get } from 'lodash'; +import { useMemo, useState } from 'react'; + +/** + * A generic component for rendering two autocomplete fields that are interdependent (category -> unit). + * + * @export + * @interface IDualAutocompleteFieldProps + * @template TCategory + * @template TUnit + * @template CategoryValueType + * @template UnitValueType + */ +export interface IDualAutocompleteFieldProps< + CategoryOptionsType extends IAutocompleteFieldOption, + UnitOptionsType extends IAutocompleteFieldOption, + CategoryValueType extends string | number, + UnitValueType extends string | number +> { + /** + * The formik field name for the category field. + */ + categoryFormikFieldName: string; + /** + * Label to display for the category field. + */ + categoryFieldLabel: string; + /** + * The options to display in the category field. + */ + categoryOptions: CategoryOptionsType[]; + /** + * Callback to get the data type of the category field. + */ + getCategoryDataType: (categoryValue: CategoryValueType) => 'quantitative' | 'qualitative'; + /** + * Get the formik field name for the unit field. + */ + getUnitFormikFieldName: (categoryValue: CategoryValueType) => string; + /** + * Callback to get the options to display in the unit field, based on the selected category. Only called when a + * category is selected and the category data type is qualitative. + */ + getUnitOptions: (categoryValue: CategoryValueType) => UnitOptionsType[]; + /** + * The options to display in the unit field, based on the selected category. + * + * If not provided, the default label for the unit field will be "Value". + */ + getUnitFieldLabel?: (categoryValue: CategoryValueType) => string; + /** + * Callback fired when the delete button is clicked. + */ + onDelete: () => void; +} + +/** + * Returns two autocomplete fields where the values for the second dropdown depend on the value of the first dropdown. + * In this component, CATEGORY refers to the first dropdown and UNIT refers to the second dropdown. + * + * @template TCategory + * @template TUnit + * @template CategoryValueType + * @template UnitValueType + * @param {IDualAutocompleteFieldProps} props + * @return {*} + */ +export const DualAutocompleteField = < + TCategory extends IAutocompleteFieldOption, + TUnit extends IAutocompleteFieldOption, + CategoryValueType extends string | number, + UnitValueType extends string | number +>( + props: IDualAutocompleteFieldProps +) => { + const { + categoryFormikFieldName, + categoryFieldLabel, + categoryOptions, + getCategoryDataType, + getUnitFormikFieldName, + getUnitOptions, + getUnitFieldLabel, + onDelete + } = props; + const { values, setFieldValue } = useFormikContext(); + + const categoryValue: CategoryValueType | undefined = get(values, categoryFormikFieldName); + + // The category data type (quantitative or qualitative) + const categoryDataType = categoryValue ? getCategoryDataType(categoryValue) : 'quantitative'; + + // The label units field, which defaults to "Value" unless set with the getUnitAutocompleteLabel prop + const [unitLabel, setUnitLabel] = useState('Value'); + + // The array of options for the unit field, if the category measurement type is qualitative. + const unitOptions = useMemo(() => { + if (!categoryValue) { + // No category selected, so no units to display + return []; + } + + const availableUnits = getUnitOptions(categoryValue); + + // Update the label of the second dropdown if a custom label is provided + if (getUnitFieldLabel) { + const label = getUnitFieldLabel(categoryValue); + setUnitLabel(label); + } + + return availableUnits; + }, [categoryValue, getUnitFieldLabel, getUnitOptions]); + + // The formik field name for the unit field + const unitFormikFieldName = categoryValue ? getUnitFormikFieldName(categoryValue) : 'value'; + + return ( + + { + if (!option) { + // If the category value is cleared, clear the dependent unit value as well + setFieldValue(unitFormikFieldName, undefined); + setUnitLabel('Select a unit'); + return; + } + + // Set the category value + setFieldValue(categoryFormikFieldName, option.value); + }} + sx={{ flex: 0.5 }} + /> + + {categoryDataType === 'qualitative' ? ( + { + // Set the unit value + setFieldValue(unitFormikFieldName, option?.value ?? undefined); + }} + required + sx={{ flex: 0.5 }} + /> + ) : ( + + )} + + + + + + ); +}; diff --git a/app/src/components/fields/dual-autocomplete-field/components/DualAutocompleteUnitField.tsx b/app/src/components/fields/dual-autocomplete-field/components/DualAutocompleteUnitField.tsx new file mode 100644 index 0000000000..df29ad3cb9 --- /dev/null +++ b/app/src/components/fields/dual-autocomplete-field/components/DualAutocompleteUnitField.tsx @@ -0,0 +1,68 @@ +import AutocompleteField from 'components/fields/AutocompleteField'; +import CustomTextField from 'components/fields/CustomTextField'; // Import CustomTextField +import { useFormikContext } from 'formik'; + +export interface IDualAutocompleteUnitFieldProps { + /** + * Label to display for the unit field. + */ + unitLabel: string; + /** + * The options to display in the unit field. + */ + unitOptions: { label: string; value: TUnit }[]; + /** + * The data type of the category field. + */ + categoryDataType: 'quantitative' | 'qualitative'; + /** + * The formik field name for the unit field. + */ + unitFormikFieldName: string; +} + +/** + * Returns an AutocompleteField or CustomTextField if the categoryDataType is qualitative or quantitative + * respectively. + * + * @param {IDualAutocompleteUnitFieldProps} props + * @return {*} + */ +export const DualAutocompleteUnitField = ( + props: IDualAutocompleteUnitFieldProps +) => { + const { unitLabel, unitOptions, categoryDataType, unitFormikFieldName } = props; + + const formik = useFormikContext(); + + if (categoryDataType === 'qualitative') { + return ( + { + // Set the unit value + formik.setFieldValue(unitFormikFieldName, option?.value ?? undefined); + }} + required + sx={{ flex: 0.5 }} + /> + ); + } + + return ( + + ); +}; diff --git a/app/src/components/species/components/SpeciesCard.tsx b/app/src/components/species/components/SpeciesCard.tsx index 685eb665ce..b1d4a58796 100644 --- a/app/src/components/species/components/SpeciesCard.tsx +++ b/app/src/components/species/components/SpeciesCard.tsx @@ -18,9 +18,9 @@ const SpeciesCard = (props: ISpeciesCardProps) => { const commonNames = taxon.commonNames.filter((item) => item !== null).join(`\u00A0\u00B7\u00A0`); return ( - + - + {taxon?.rank && ( { /> )} - - {commonNames} - + {commonNames.length > 0 && ( + + {commonNames} + + )} diff --git a/app/src/components/species/components/SpeciesSelectedCard.tsx b/app/src/components/species/components/SpeciesSelectedCard.tsx index ecb6909b79..008d80f28b 100644 --- a/app/src/components/species/components/SpeciesSelectedCard.tsx +++ b/app/src/components/species/components/SpeciesSelectedCard.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/grey'; import IconButton from '@mui/material/IconButton'; +import Paper, { PaperProps } from '@mui/material/Paper'; import { IPartialTaxonomy } from 'interfaces/useTaxonomyApi.interface'; import SpeciesCard from './SpeciesCard'; -interface ISpeciesSelectedCardProps { +interface ISpeciesSelectedCardProps extends PaperProps { /** * The species to display. * @@ -19,39 +21,44 @@ interface ISpeciesSelectedCardProps { * * @memberof ISpeciesSelectedCardProps */ - handleRemove?: (tsn: number) => void; + handleRemove?: (tsn?: number) => void; /** * The index of the component in the list. * * @type {number} * @memberof ISpeciesSelectedCardProps */ - index: number; + index?: number; } const SpeciesSelectedCard = (props: ISpeciesSelectedCardProps) => { - const { index, species, handleRemove } = props; + const { index, species, handleRemove, ...paperProps } = props; return ( - - - - + + {handleRemove && ( - + handleRemove(species.tsn)}> )} - + ); }; diff --git a/app/src/constants/i18n.ts b/app/src/constants/i18n.ts index 12aa06c144..c84f5a2188 100644 --- a/app/src/constants/i18n.ts +++ b/app/src/constants/i18n.ts @@ -534,6 +534,12 @@ export const AlertI18N = { deleteAlertDialogText: 'Are you sure you want to permanently delete this alert? This action cannot be undone.' }; +export const CreateObservationI18N = { + createErrorTitle: 'Error Creating Observations', + createErrorText: + 'An error has occurred while attempting to create observation data. Please try again. If the error persists, please contact your system administrator.' +}; + export const SamplePeriodI18N = { cancelTitle: 'Discard changes and exit?', cancelText: 'Any changes you have made will not be saved. Do you want to proceed?', diff --git a/app/src/contexts/observationsContext.tsx b/app/src/contexts/observationsContext.tsx index 2d469b74a2..0695d02ab6 100644 --- a/app/src/contexts/observationsContext.tsx +++ b/app/src/contexts/observationsContext.tsx @@ -1,6 +1,6 @@ import { useBiohubApi } from 'hooks/useBioHubApi'; import useDataLoader, { DataLoader } from 'hooks/useDataLoader'; -import { IGetSurveyObservationsResponse } from 'interfaces/useObservationApi.interface'; +import { IGetSurveyFlattenedObservationsResponse } from 'interfaces/useObservationApi.interface'; import { createContext, PropsWithChildren, useContext } from 'react'; import { ApiPaginationRequestOptions } from 'types/misc'; import { SurveyContext } from './surveyContext'; @@ -17,7 +17,7 @@ export type IObservationsContext = { */ observationsDataLoader: DataLoader< [pagination?: ApiPaginationRequestOptions], - IGetSurveyObservationsResponse, + IGetSurveyFlattenedObservationsResponse, unknown >; }; @@ -30,7 +30,7 @@ export const ObservationsContextProvider = (props: PropsWithChildren - biohubApi.observation.getObservationRecords(projectId, surveyId, pagination) + biohubApi.observation.getFlattenedObservationRecords(projectId, surveyId, pagination) ); const observationsContext: IObservationsContext = { diff --git a/app/src/contexts/observationsTableContext.tsx b/app/src/contexts/observationsTableContext.tsx index 25e2632da4..f8f81a7293 100644 --- a/app/src/contexts/observationsTableContext.tsx +++ b/app/src/contexts/observationsTableContext.tsx @@ -29,12 +29,18 @@ import { validateObservationTableRowMeasurements } from 'features/surveys/observations/observations-table/observation-row-validation/ObservationRowValidationUtils'; import { APIError } from 'hooks/api/useAxios'; -import { IObservationTableRowToSave, SubcountToSave } from 'hooks/api/useObservationApi'; 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 { + ICreateEditObservation, + IGetSurveyFlattenedObservationsResponse, + ObservationEnvironmentQualitativeObject, + ObservationEnvironmentQuantitativeObject, + ObservationRecord, + SubcountToSave +} from 'interfaces/useObservationApi.interface'; import { EnvironmentType, EnvironmentTypeIds } from 'interfaces/useReferenceApi.interface'; import { createContext, @@ -148,7 +154,7 @@ export type IObservationsTableContext = { /** * Refreshes the Observation Table with already existing records */ - refreshObservationRecords: () => Promise; + refreshObservationRecords: () => Promise; /** * Returns all of the observation table records that have been selected */ @@ -506,7 +512,7 @@ export const ObservationsTableContextProvider = (props: IObservationsTableContex // TODO: Either latitude/longitude OR sampling period is required, and either observation date OR sampling period is required const requiredStandardColumns: (keyof IObservationTableRow)[] = [ - 'observation_subcount_sign_id', + 'observation_sign_id', 'count', 'latitude', 'longitude', @@ -919,7 +925,7 @@ export const ObservationsTableContextProvider = (props: IObservationsTableContex survey_sample_site_id: null as unknown as number, method_technique_id: null as unknown as number, survey_sample_period_id: null, - observation_subcount_sign_id: null as unknown as number, + observation_sign_id: null as unknown as number, count: null as unknown as number, observation_date: '', observation_time: '', @@ -1019,8 +1025,17 @@ export const ObservationsTableContextProvider = (props: IObservationsTableContex // True if the taxonomy cache is still initializing or the observations data is still loading const isLoading: boolean = useMemo(() => { - return !taxonomyCacheStatus.isInitialized || isLoadingObservationsData || observationsPageContext.isLoading; - }, [isLoadingObservationsData, observationsPageContext.isLoading, taxonomyCacheStatus.isInitialized]); + return ( + !taxonomyCacheStatus.isInitialized || + (!observationsData && isLoadingObservationsData) || + observationsPageContext.isLoading + ); + }, [ + isLoadingObservationsData, + observationsData, + observationsPageContext.isLoading, + taxonomyCacheStatus.isInitialized + ]); // True if the save process has started const isSaving: boolean = _isSavingData.current || _isStoppingEdit.current; @@ -1029,7 +1044,7 @@ export const ObservationsTableContextProvider = (props: IObservationsTableContex * Send all observation rows to the backend. */ const _saveRecords = useCallback( - async (rowsToSave: IObservationTableRowToSave[]) => { + async (rowsToSave: ICreateEditObservation[]) => { try { await biohubApi.observation.insertUpdateObservationRecords(projectId, surveyId, rowsToSave); @@ -1120,8 +1135,8 @@ export const ObservationsTableContextProvider = (props: IObservationsTableContex */ const _getEnvironmentsToSave = useCallback( (row: ObservationRecord) => { - const qualitative: SubcountToSave['qualitative_environments'] = []; - const quantitative: SubcountToSave['quantitative_environments'] = []; + const qualitative: ObservationEnvironmentQualitativeObject[] = []; + const quantitative: ObservationEnvironmentQuantitativeObject[] = []; // For each qualitative environment column in the data grid for (const environmentDefinition of environmentColumns.qualitative_environments) { @@ -1161,7 +1176,6 @@ 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 { @@ -1169,29 +1183,28 @@ export const ObservationsTableContextProvider = (props: IObservationsTableContex // Map the observation `count` value to the `subcount` value, for now. // 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, + subcount: row.subcount, comment: row.comment, - observation_subcount_sign_id: row.observation_subcount_sign_id, qualitative_measurements: measurementsToSave.qualitative, - quantitative_measurements: measurementsToSave.quantitative, - qualitative_environments: environmentsToSave.qualitative, - quantitative_environments: environmentsToSave.quantitative + quantitative_measurements: measurementsToSave.quantitative }; }, - [_getEnvironmentsToSave, _getMeasurementsToSave] + [_getMeasurementsToSave] ); /** * Compiles the given row into the format expected by the SIMS API. * * @param {ObservationRecord} row - * @return {*} {IObservationTableRowToSave} + * @return {*} {ICreateEditObservation} */ const _getRowToSave = useCallback( - (row: ObservationRecord): IObservationTableRowToSave => { + (row: ObservationRecord): ICreateEditObservation => { // Get all subcount row data for the observation row const subcountsToSave = _getSubcountsToSave(row); + const environmentsToSave = _getEnvironmentsToSave(row); + // Return the observation row to save return { standardColumns: { @@ -1203,7 +1216,10 @@ export const ObservationsTableContextProvider = (props: IObservationsTableContex observation_date: row.observation_date, observation_time: row.observation_time, latitude: row.latitude, - longitude: row.longitude + longitude: row.longitude, + observation_sign_id: row.observation_sign_id, + qualitative_environments: environmentsToSave.qualitative, + quantitative_environments: environmentsToSave.quantitative }, // Set the subcount data for the observation row. // Why? Currently the UI only supports 1 subcount record per observation record. @@ -1211,22 +1227,22 @@ export const ObservationsTableContextProvider = (props: IObservationsTableContex subcounts: [subcountsToSave] }; }, - [_getSubcountsToSave] + [_getSubcountsToSave, _getEnvironmentsToSave] ); /** * Transforms the raw data grid rows into the format expected by the SIMS API. * - * @return {*} {IObservationTableRowToSave[]} + * @return {*} {ICreateEditObservation[]} */ - const _getRowsToSave = useCallback((): IObservationTableRowToSave[] => { + const _getRowsToSave = useCallback((): ICreateEditObservation[] => { // Get all rows that have been modified const modifiedRows: ObservationRecord[] = modifiedRowIds .map((rowId) => _muiDataGridApiRef.current.getRow(rowId)) .filter(Boolean); // Transform the modified rows into the format expected by the SIMS API - const rowsToSave: IObservationTableRowToSave[] = modifiedRows.map((modifiedRow) => _getRowToSave(modifiedRow)); + const rowsToSave: ICreateEditObservation[] = modifiedRows.map((modifiedRow) => _getRowToSave(modifiedRow)); return rowsToSave; }, [_getRowToSave, _muiDataGridApiRef, modifiedRowIds]); @@ -1237,62 +1253,59 @@ export const ObservationsTableContextProvider = (props: IObservationsTableContex * @param {IGetSurveyObservationsResponse} observationsData * @return {*} {IObservationTableRow[]} */ - const _getRowsToDisplay = useCallback((observationsData: IGetSurveyObservationsResponse): IObservationTableRow[] => { - // Spread all subcount rows into separate observation table rows, duplicating the parent observation standard row data - const rowsToDisplay: IObservationTableRow[] = observationsData.surveyObservations.flatMap((observationRow) => { - // Return a new row for each subcount, which contains the parent observation standard row data - return observationRow.subcounts.map((subcountRow) => { - // This code flattens out the array of subcount rows into a single array of observation rows, where each row - // contains a copy of the parent observation standard row data, and the unique subcount row data. - // Note: This code currently assumes that each observation record has exactly 1 subcount record. - // Why? Currently there is no UI support for handling multiple subcount records per observation record. - // See https://apps.nrs.gov.bc.ca/int/jira/browse/SIMSBIOHUB-534 + const _getRowsToDisplay = useCallback( + (observationsData: IGetSurveyFlattenedObservationsResponse): IObservationTableRow[] => { + // Spread all subcount rows into separate observation table rows, duplicating the parent observation standard row data + const rowsToDisplay: IObservationTableRow[] = observationsData.surveyObservations.flatMap((observationRow) => { + const { subcount, qualitative_environments, quantitative_environments, ...restObservation } = observationRow; + const { qualitative_measurements, quantitative_measurements, ...restSubcount } = subcount; return { + // Set the required datagrid row id + id: String(restSubcount.observation_subcount_id), + // Spread the standard observation row data into the row - id: String(observationRow.survey_observation_id), - ...observationRow, - - // Add the subcount id to the row - observation_subcount_id: subcountRow.observation_subcount_id, - // Add the subcount sign data into the row - observation_subcount_sign_id: subcountRow.observation_subcount_sign_id, - // // Add the subcount comment into the row - comment: subcountRow.comment, - - // Reduce the array of qualitative measurements into an object and spread into the row - ...subcountRow.qualitative_measurements.reduce((acc, cur) => { + ...restObservation, + + // Reduce the array of observation qualitative environments into an object and spread into the row + ...qualitative_environments.reduce((acc, cur) => { return { ...acc, - [cur.critterbase_taxon_measurement_id]: cur.critterbase_measurement_qualitative_option_id + [cur.environment_qualitative_id]: cur.environment_qualitative_option_id }; }, {}), - // Reduce the array of quantitative measurements into an object and spread into the row - ...subcountRow.quantitative_measurements.reduce((acc, cur) => { + // Reduce the array of observation quantitative environments into an object and spread into the row + ...quantitative_environments.reduce((acc, cur) => { return { ...acc, - [cur.critterbase_taxon_measurement_id]: cur.value + [cur.environment_quantitative_id]: cur.value }; }, {}), - // Reduce the array of qualitative environments into an object and spread into the row - ...subcountRow.qualitative_environments.reduce((acc, cur) => { + + // Spread the standard subcount data into the row + ...restSubcount, + + // Reduce the array of subcount qualitative measurements into an object and spread into the row + ...qualitative_measurements.reduce((acc, cur) => { return { ...acc, - [cur.environment_qualitative_id]: cur.environment_qualitative_option_id + [cur.critterbase_taxon_measurement_id]: cur.critterbase_measurement_qualitative_option_id }; }, {}), - // Reduce the array of quantitative environments into an object and spread into the row - ...subcountRow.quantitative_environments.reduce((acc, cur) => { + + // Reduce the array of subcount quantitative measurements into an object and spread into the row + ...quantitative_measurements.reduce((acc, cur) => { return { ...acc, - [cur.environment_quantitative_id]: cur.value + [cur.critterbase_taxon_measurement_id]: cur.value }; }, {}) }; }); - }); - return rowsToDisplay; - }, []); + return rowsToDisplay; + }, + [] + ); /** * Fetch new rows based on sort/ pagination model changes diff --git a/app/src/features/standards/view/components/AccordionStandardCard.tsx b/app/src/features/standards/view/components/AccordionStandardCard.tsx index 4966a82f4b..2dbf97775a 100644 --- a/app/src/features/standards/view/components/AccordionStandardCard.tsx +++ b/app/src/features/standards/view/components/AccordionStandardCard.tsx @@ -34,7 +34,7 @@ export const AccordionStandardCard = (props: PropsWithChildren + { const observationsDataLoader = useDataLoader( (pagination: ApiPaginationRequestOptions, filter?: IObservationsAdvancedFilters) => { - return biohubApi.observation.findObservations(pagination, filter); + return biohubApi.observation.findFlattenedObservations(pagination, filter); } ); @@ -117,46 +117,51 @@ const ObservationsListContainer = (props: IObservationsListContainerProps) => { }, [advancedFiltersModel, paginationSort]); const getRowsFromObservations = useCallback( - (observationsData: IGetSurveyObservationsResponse): IObservationTableRow[] => + (observationsData: IGetSurveyFlattenedObservationsResponse): 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, + const { subcount, qualitative_environments, quantitative_environments, ...restObservation } = observationRow; + const { qualitative_measurements, quantitative_measurements, ...restSubcount } = subcount; + return { + // Set the required datagrid row id + id: String(restSubcount.observation_subcount_id), - // 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 - }; - }, {}) - }; - }); + // Spread the standard observation row data into the row + ...restObservation, + + // Reduce the array of observation qualitative environments into an object and spread into the row + ...qualitative_environments.reduce((acc, cur) => { + return { + ...acc, + [cur.environment_qualitative_id]: cur.environment_qualitative_option_id + }; + }, {}), + // Reduce the array of observation quantitative environments into an object and spread into the row + ...quantitative_environments.reduce((acc, cur) => { + return { + ...acc, + [cur.environment_quantitative_id]: cur.value + }; + }, {}), + + // Spread the standard subcount data into the row + ...restSubcount, + + // Reduce the array of subcount qualitative measurements into an object and spread into the row + ...qualitative_measurements.reduce((acc, cur) => { + return { + ...acc, + [cur.critterbase_taxon_measurement_id]: cur.critterbase_measurement_qualitative_option_id + }; + }, {}), + + // Reduce the array of subcount quantitative measurements into an object and spread into the row + ...quantitative_measurements.reduce((acc, cur) => { + return { + ...acc, + [cur.critterbase_taxon_measurement_id]: cur.value + }; + }, {}) + }; }), [] ); @@ -197,7 +202,7 @@ const ObservationsListContainer = (props: IObservationsListContainerProps) => { ) }, { - field: 'count', + field: 'subcount', headerName: 'Count', flex: 1 }, diff --git a/app/src/features/surveys/SurveyRouter.tsx b/app/src/features/surveys/SurveyRouter.tsx index ef2e9716e3..0cfe4b591c 100644 --- a/app/src/features/surveys/SurveyRouter.tsx +++ b/app/src/features/surveys/SurveyRouter.tsx @@ -12,6 +12,7 @@ import React from 'react'; import { Redirect, Switch } from 'react-router'; import RouteWithTitle from 'utils/RouteWithTitle'; import { getTitle } from 'utils/Utils'; +import CreateObservationPage from './observations/create/CreateObservationPage'; import { TelemetryRouter } from './telemetry/TelemetryRouter'; /** @@ -91,6 +92,17 @@ const SurveyRouter: React.FC = () => { + + + + + + {/* Sampling routes */} {values.species && ( - + { return ( - + { + if (handleRemoveSpecies) { + handleRemoveSpecies(species.tsn); + } + }} + /> ); diff --git a/app/src/features/surveys/components/funding/SurveyFundingSourceForm.tsx b/app/src/features/surveys/components/funding/SurveyFundingSourceForm.tsx index 3885c9b2a1..ad18d91e2e 100644 --- a/app/src/features/surveys/components/funding/SurveyFundingSourceForm.tsx +++ b/app/src/features/surveys/components/funding/SurveyFundingSourceForm.tsx @@ -81,13 +81,25 @@ export const SurveyFundingSourceForm = () => { useFormikContext(); const biohubApi = useBiohubApi(); + const fundingSourcesDataLoader = useDataLoader(() => biohubApi.funding.getAllFundingSources()); - fundingSourcesDataLoader.load(); + + useEffect(() => { + fundingSourcesDataLoader.load(); + }, [fundingSourcesDataLoader]); const fundingSourceOptions = useMemo( () => fundingSourcesDataLoader.data?.map((option) => ({ value: option.funding_source_id, label: option.name })) ?? [], - [fundingSourcesDataLoader] + [fundingSourcesDataLoader.data] + ); + + const existingFunctionSources = useMemo( + () => + fundingSourceOptions.filter((option) => + values.funding_sources.map((source) => source.funding_source_id).includes(option.value) + ), + [fundingSourceOptions, values.funding_sources] ); // Update `funding_used` based on the existence of `funding_sources` @@ -109,10 +121,6 @@ export const SurveyFundingSourceForm = () => { return null; }; - const sources = fundingSourceOptions.filter((option) => - values.funding_sources.map((source) => source.funding_source_id).includes(option.value) - ); - return (
{ {/* Transition Group for displaying funding sources */} - {sources.map((fundingSource, index) => ( + {existingFunctionSources.map((fundingSource, index) => ( { + const history = useHistory(); + const biohubApi = useBiohubApi(); + const formikRef = useRef>(null); + + // Ability to bypass showing the 'Are you sure you want to cancel' dialog + const [enableCancelCheck, setEnableCancelCheck] = useState(true); + const [isSaving, setIsSaving] = useState(false); + + const { locationChangeInterceptor } = useUnsavedChangesDialog(); + + const dialogContext = useContext(DialogContext); + const codesContext = useContext(CodesContext); + + // Project and survey details for breadcrumbs + const projectContext = useProjectContext(); + const surveyContext = useSurveyContext(); + + const projectName = projectContext.projectDataLoader.data?.projectData.project.project_name; + const surveyName = surveyContext.surveyDataLoader.data?.surveyData.survey_details.survey_name; + const { projectId, surveyId } = surveyContext; + + useEffect(() => { + codesContext.codesDataLoader.load(); + }, [codesContext.codesDataLoader]); + + const defaultErrorDialogProps = { + onClose: () => { + dialogContext.setErrorDialog({ open: false }); + }, + onOk: () => { + dialogContext.setErrorDialog({ open: false }); + } + }; + + const showCreateErrorDialog = (textDialogProps?: Partial) => { + dialogContext.setErrorDialog({ + dialogTitle: CreateObservationI18N.createErrorTitle, + dialogText: CreateObservationI18N.createErrorText, + ...defaultErrorDialogProps, + ...textDialogProps, + open: true + }); + }; + + const handleCancel = () => { + history.push(`/admin/projects/${projectId}/surveys/${surveyId}/observations`); + }; + + /** + * Creates a new observation + * + * @param {ICreateObservation} observationPostObject + * @return {*} + */ + const createObservation = async (formData: ObservationFormData) => { + setIsSaving(true); + try { + const { + itis_scientific_name, + itis_tsn, + observation_date, + observation_time, + survey_sample_period_id, + longitude, + latitude, + environments + } = formData.standardColumns; + + const quantitative_environments: ObservationEnvironmentQuantitativeObject[] = []; + const qualitative_environments: ObservationEnvironmentQualitativeObject[] = []; + + for (const environment of environments) { + if (environment._type === 'quantitative') { + if (!environment.environment_quantitative_id || !environment.value) { + continue; + } + + quantitative_environments.push({ + environment_quantitative_id: environment.environment_quantitative_id, + value: environment.value + }); + } else if (environment._type === 'qualitative') { + if (!environment.environment_qualitative_id || !environment.environment_qualitative_option_id) { + continue; + } + + qualitative_environments.push({ + environment_qualitative_id: environment.environment_qualitative_id, + environment_qualitative_option_id: environment.environment_qualitative_option_id + }); + } + } + + const standardColumns: ICreateObservation['standardColumns'] = { + itis_scientific_name, + itis_tsn, + observation_date, + observation_time, + survey_sample_period_id, + latitude, + longitude, + count: formData.subcounts.reduce((sum, subcount) => sum + (subcount.subcount ?? 0), 0), + observation_sign_id: formData.standardColumns.observation_sign_id, + qualitative_environments, + quantitative_environments + }; + + const subcounts: ICreateObservation['subcounts'] = formData.subcounts.map((subcount) => { + const { measurements, ...subcountProps } = subcount; + + const quantitative_measurements: SubcountQuantitativeMeasurement[] = []; + const qualitative_measurements: SubcountQualitativeMeasurement[] = []; + + for (const measurement of measurements) { + if (isSubcountQuantitativeMeasurement(measurement)) { + if (!measurement.measurement_value) { + // No value was entered for the quantitative measurement, skip it + continue; + } + + quantitative_measurements.push({ + measurement_id: measurement.measurement_id, + measurement_value: measurement.measurement_value + }); + } else if (isSubcountQualitativeMeasurement(measurement)) { + if (!measurement.measurement_option_id) { + // No value was selected for the qualitative measurement, skip it + continue; + } + + qualitative_measurements.push({ + measurement_id: measurement.measurement_id, + measurement_option_id: measurement.measurement_option_id + }); + } + } + + return { + subcount: subcountProps.subcount, + comment: subcountProps.comment, + quantitative_measurements, + qualitative_measurements + }; + }); + + const createObservationPayload: ICreateObservation = { + standardColumns, + subcounts + }; + + await biohubApi.observation.createObservation(projectId, surveyId, createObservationPayload); + + setEnableCancelCheck(false); + history.push(`/admin/projects/${projectId}/surveys/${surveyId}/observations`, SKIP_CONFIRMATION_DIALOG); + } catch (error) { + const apiError = error as APIError; + showCreateErrorDialog({ + dialogTitle: CreateObservationI18N.createErrorTitle, + dialogText: CreateObservationI18N.createErrorText, + dialogError: apiError.message, + dialogErrorDetails: apiError.errors + }); + } finally { + setIsSaving(false); + } + }; + + if (!codesContext.codesDataLoader.data) { + return ; + } + + return ( + <> + + '}> + + {projectName} + + + {surveyName} + + + Observations + + + Create Observation + + + } + buttonJSX={ + <> + formikRef.current?.submitForm()} + data-testid="submit-observation-button"> + Save and Exit + + + + } + /> + + + + + { + createObservation(formData); + }} + formikRef={formikRef} + /> + + + formikRef.current?.submitForm()} + data-testid="submit-observation-button"> + Save and Exit + + + + + + + ); +}; + +export default CreateObservationPage; diff --git a/app/src/features/surveys/observations/form/ObservationForm.interface.tsx b/app/src/features/surveys/observations/form/ObservationForm.interface.tsx new file mode 100644 index 0000000000..ca6d798e59 --- /dev/null +++ b/app/src/features/surveys/observations/form/ObservationForm.interface.tsx @@ -0,0 +1,28 @@ +import { EnvironmentsFormData } from 'features/surveys/observations/form/components/environments/ObservationEnvironmentsForm'; +import { SubcountsFormData } from 'features/surveys/observations/form/components/subcounts/SubcountsForm'; +import { StandardObservationColumns } from 'interfaces/useObservationApi.interface'; + +/** + * Defines the form data structure for the ObservationForm component. + */ +export type ObservationFormData = { + /** + * The standard columns for the observation record. + */ + standardColumns: Pick< + StandardObservationColumns, + | 'itis_tsn' + | 'itis_scientific_name' + | 'survey_sample_period_id' + | 'count' + | 'observation_date' + | 'observation_time' + | 'latitude' + | 'longitude' + | 'observation_sign_id' + > & { + survey_observation_id: number | null; + survey_sample_site_id: number | undefined; + method_technique_id: number | undefined; + } & EnvironmentsFormData; +} & SubcountsFormData; diff --git a/app/src/features/surveys/observations/form/ObservationForm.tsx b/app/src/features/surveys/observations/form/ObservationForm.tsx new file mode 100644 index 0000000000..2383038bac --- /dev/null +++ b/app/src/features/surveys/observations/form/ObservationForm.tsx @@ -0,0 +1,207 @@ +import Divider from '@mui/material/Divider'; +import Stack from '@mui/material/Stack'; +import FormikErrorSnackbar from 'components/alert/FormikErrorSnackbar'; +import HorizontalSplitFormComponent from 'components/fields/HorizontalSplitFormComponent'; +import { ObservationDateTimeForm } from 'features/surveys/observations/form/components/date/ObservationDateTimeForm'; +import { ObservationEnvironmentsForm } from 'features/surveys/observations/form/components/environments/ObservationEnvironmentsForm'; +import { ObservationLocationForm } from 'features/surveys/observations/form/components/location/ObservationLocationForm'; +import { ObservationSamplingForm } from 'features/surveys/observations/form/components/sampling/ObservationSamplingForm'; +import { ObservationSpeciesForm } from 'features/surveys/observations/form/components/species/ObservationSpeciesForm'; +import { SubcountsForm } from 'features/surveys/observations/form/components/subcounts/SubcountsForm'; +import { ObservationFormData } from 'features/surveys/observations/form/ObservationForm.interface'; +import { Formik, FormikProps } from 'formik'; +import React, { useState } from 'react'; +import yup from 'utils/YupSchema'; + +// Define the full validation schema for the observation +export const ObservationYupSchema = yup.object({ + standardColumns: yup + .object({ + observation_subcount_id: yup.number().nullable(), + itis_tsn: yup.number().nullable().required('Species is required.'), + itis_scientific_name: yup.string().nullable(), + survey_sample_site_id: yup.number().nullable(), + method_technique_id: yup.number().nullable(), + survey_sample_period_id: yup.number().nullable(), + count: yup.number().nullable().optional(), + observation_date: yup.date().nullable(), + observation_time: yup.string().nullable(), + latitude: yup + .number() + .nullable() + .min(-90, 'Latitude must be between -90 and 90') + .max(90, 'Latitude must be between -90 and 90'), + longitude: yup + .number() + .nullable() + .min(-180, 'Longitude must be between -180 and 180') + .max(180, 'Longitude must be between -180 and 180'), + environments: yup.array().of( + yup + .object({ + environment_qualitative_id: yup.string().nullable(), + environment_qualitative_option_id: yup.string().nullable(), + environment_quantitative_id: yup.string().nullable(), + value: yup.number().nullable(), + _type: yup.string().oneOf(['qualitative', 'quantitative']) + }) + .test('conditional-validation', 'Invalid fields based on _type', function (_value) { + if (_value._type === 'qualitative') { + if (!_value.environment_qualitative_id) { + return this.createError({ + path: `${this.path}.environment_qualitative_id`, + message: 'A value is required' + }); + } + if (!_value.environment_qualitative_option_id) { + return this.createError({ + path: `${this.path}.environment_qualitative_option_id`, + message: 'A value is required' + }); + } + } else if (_value._type === 'quantitative') { + if (!_value.environment_quantitative_id) { + return this.createError({ + path: `${this.path}.environment_quantitative_id`, + message: 'A value is required' + }); + } + if (_value.value === null || _value.value === undefined) { + return this.createError({ + path: `${this.path}.value`, + message: 'A value is required' + }); + } + } + return true; + }) + ) + }) + .test('conditional-validation', 'Invalid fields based on survey_sample_period_id', function (_value) { + if (!_value.survey_sample_period_id) { + if (!_value.observation_date) { + return this.createError({ + path: `${this.path}.observation_date`, + message: 'Observation date or a sampling period must be provided' + }); + } + } + return true; + }) + .test('conditional-validation', 'Invalid fields based on survey_sample_period_id', function (_value) { + if (!_value.survey_sample_period_id) { + if (!_value.latitude) { + return this.createError({ + path: `${this.path}.latitude`, + message: 'Latitude or a sampling period must be provided' + }); + } + } + return true; + }) + .test('conditional-validation', 'Invalid fields based on survey_sample_period_id', function (_value) { + if (!_value.survey_sample_period_id) { + if (!_value.longitude) { + return this.createError({ + path: `${this.path}.longitude`, + message: 'Longitude or a sampling period must be provided' + }); + } + } + return true; + }), + subcounts: yup + .array() + .of( + yup.object({ + subcount: yup.number().nullable().required('A count is required'), + comment: yup.string().nullable(), + measurements: yup.array().of( + yup.object({ + measurement_id: yup.string().nullable().required('A measurement ID is required'), + measurement_option_id: yup.string().nullable(), + measurement_value: yup.number().nullable() + }) + ) + }) + ) + .min(1, 'At least one subcount is required.') + .required('At least one subcount is required.') +}); + +interface IObservationFormProps { + initialFormData: ObservationFormData; + onSubmit: (formikData: ObservationFormData) => void; + formikRef: React.RefObject>; +} + +const ObservationForm = (props: IObservationFormProps) => { + const { initialFormData, onSubmit, formikRef } = props; + + const [showSamplingInformation, setShowSamplingInformation] = useState(false); + + return ( + + + + + {/* Species Form */} + + + + + + + {/* Sampling Information Form */} + + + + + + + {/* Location */} + + + + + + + {/* Datetime Form */} + + + + + + + {/*Environments Form */} + + + + + + + {/* Subcounts Form */} + + + + + + + + ); +}; + +export default ObservationForm; diff --git a/app/src/features/surveys/observations/form/components/date/ObservationDateTimeForm.tsx b/app/src/features/surveys/observations/form/components/date/ObservationDateTimeForm.tsx new file mode 100644 index 0000000000..94cdc02bc0 --- /dev/null +++ b/app/src/features/surveys/observations/form/components/date/ObservationDateTimeForm.tsx @@ -0,0 +1,18 @@ +import Stack from '@mui/material/Stack'; +import SingleDateField from 'components/fields/SingleDateField'; +import { TimeField } from 'components/fields/TimeField'; + +/** + * Form component for the observation date and time. + * + + * @return {*} + */ +export const ObservationDateTimeForm = () => { + return ( + + + + + ); +}; diff --git a/app/src/features/surveys/observations/form/components/environments/ObservationEnvironmentsForm.tsx b/app/src/features/surveys/observations/form/components/environments/ObservationEnvironmentsForm.tsx new file mode 100644 index 0000000000..d7a5608a59 --- /dev/null +++ b/app/src/features/surveys/observations/form/components/environments/ObservationEnvironmentsForm.tsx @@ -0,0 +1,95 @@ +import { mdiPlus } from '@mdi/js'; +import { Icon } from '@mdi/react'; +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; +import Stack from '@mui/material/Stack'; +import { + EnvironmentField, + EnvironmentFormData, + initialEnvironmentFormData +} from 'features/surveys/observations/form/components/environments/environment/EnvironmentField'; +import { ObservationFormData } from 'features/surveys/observations/form/ObservationForm.interface'; +import { FieldArray, FieldArrayRenderProps, useFormikContext } from 'formik'; +import { useBiohubApi } from 'hooks/useBioHubApi'; +import useDataLoader from 'hooks/useDataLoader'; +import get from 'lodash-es/get'; +import { useEffect } from 'react'; +import { v4 } from 'uuid'; + +export type EnvironmentsFormData = { + environments: EnvironmentFormData[]; +}; + +export const initialEnvironmentsFormData = { + environments: [] +}; + +/** + * Returns form controls for adding environments to the observation subcount. + * + * @return {*} + */ +export const ObservationEnvironmentsForm = () => { + const { values } = useFormikContext(); + + const biohubApi = useBiohubApi(); + + const environmentsDataLoader = useDataLoader(() => biohubApi.reference.findEnvironmentReferenceData('')); + + useEffect(() => { + environmentsDataLoader.load(); + }, [environmentsDataLoader]); + + const environmentTypeDefinitions = environmentsDataLoader.data ?? { + quantitative_environments: [], + qualitative_environments: [] + }; + + const environmentsFormData: EnvironmentFormData[] | undefined = get(values, 'standardColumns.environments'); + + return ( + { + return ( + <> + {environmentsFormData.length > 0 && ( + + {environmentsFormData?.map((environmentFormData, index) => { + const environmentsArrayFieldName = `standardColumns.environments[${index}]`; + + return ( + arrayHelpers.remove(index)} + key={environmentFormData._id} + /> + ); + })} + + )} + + + + + + ); + }} + /> + ); +}; diff --git a/app/src/features/surveys/observations/form/components/environments/environment/EnvironmentField.tsx b/app/src/features/surveys/observations/form/components/environments/environment/EnvironmentField.tsx new file mode 100644 index 0000000000..ca38afcc0a --- /dev/null +++ b/app/src/features/surveys/observations/form/components/environments/environment/EnvironmentField.tsx @@ -0,0 +1,253 @@ +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, { IAutocompleteFieldOption } from 'components/fields/AutocompleteField'; +import CustomTextField from 'components/fields/CustomTextField'; +import { ObservationFormData } from 'features/surveys/observations/form/ObservationForm.interface'; +import { useFormikContext } from 'formik'; +import { + EnvironmentQualitativeTypeDefinition, + EnvironmentQuantitativeTypeDefinition, + EnvironmentType +} from 'interfaces/useReferenceApi.interface'; +import get from 'lodash-es/get'; +import { useMemo } from 'react'; + +export type EnvironmentFormData = { + // UI helper value to satisfy react keys + _id?: string; + // UI helper value to indicate the type of environment field. + _type?: 'qualitative' | 'quantitative'; + // Qualitative values + environment_qualitative_id: string | null; + environment_qualitative_option_id: string | null; + // Quantitative values + environment_quantitative_id: string | null; + value: number | null; +}; + +export const initialEnvironmentFormData: EnvironmentFormData = { + environment_qualitative_id: null, + environment_qualitative_option_id: null, + environment_quantitative_id: null, + value: null +}; + +export interface IEnvironmentFormProps { + /** + * The formik field name. + */ + formikFieldName: string; + /** + * The environment type definitions. + */ + environmentTypeDefinitions: EnvironmentType; + /** + * Callback fired when the delete button is clicked. + */ + onDelete: () => void; +} + +type EnvironmentQuantitativeCategoryOption = EnvironmentQuantitativeTypeDefinition & { + _type: 'quantitative'; +} & IAutocompleteFieldOption; + +type EnvironmentQualitativeCategoryOption = EnvironmentQualitativeTypeDefinition & { + _type: 'qualitative'; +} & IAutocompleteFieldOption; + +type EnvironmentCategoryOption = EnvironmentQuantitativeCategoryOption | EnvironmentQualitativeCategoryOption; + +/** + * Subcount Measurement Field component. + * + * @param {IEnvironmentFormProps} props + * @return {*} + */ +export const EnvironmentField = (props: IEnvironmentFormProps) => { + const { formikFieldName, environmentTypeDefinitions, onDelete } = props; + + const { values, setFieldValue } = useFormikContext(); + + // UI helper field to indicate the type of the environment field. + const environmentUnitTypeFieldName = `${formikFieldName}._type`; + + // Quantitative field names + const environmentQuantitativeCategoryFieldName = `${formikFieldName}.environment_quantitative_id`; + const environmentQuantitativeUnitFieldName = `${formikFieldName}.value`; + + // Qualitative field names + const environmentQualitativeCategoryFieldName = `${formikFieldName}.environment_qualitative_id`; + const environmentQualitativeUnitFieldName = `${formikFieldName}.environment_qualitative_option_id`; + + const environmentCategoryValue: EnvironmentFormData | undefined = useMemo( + () => get(values, formikFieldName), + [formikFieldName, values] + ); + + const environmentCategoryFieldName = useMemo(() => { + if (!environmentCategoryValue) { + return ''; + } + + if (environmentCategoryValue._type === 'quantitative') { + return environmentQuantitativeCategoryFieldName; + } + + return environmentQualitativeCategoryFieldName; + }, [environmentCategoryValue, environmentQualitativeCategoryFieldName, environmentQuantitativeCategoryFieldName]); + + const environmentCategoryOptions = useMemo( + (): EnvironmentCategoryOption[] => [ + ...(environmentTypeDefinitions.qualitative_environments.map((item) => { + return { + ...item, + label: item.name, + value: item.environment_qualitative_id, + _type: 'qualitative' as const + }; + }) ?? []), + ...(environmentTypeDefinitions.quantitative_environments.map((item) => { + return { + ...item, + label: item.unit ? `${item.name} (${item.unit})` : item.name, + value: item.environment_quantitative_id, + _type: 'quantitative' as const + }; + }) ?? []) + ], + [environmentTypeDefinitions] + ); + + const selectedEnvironmentTypeDefinition = useMemo(() => { + return environmentCategoryOptions.find( + (item) => + item.value === environmentCategoryValue?.environment_qualitative_id || + item.value === environmentCategoryValue?.environment_quantitative_id + ); + }, [environmentCategoryValue, environmentCategoryOptions]); + + const categoryDataType = environmentCategoryValue?._type ?? 'quantitative'; + + const environmentUnitFieldLabel = useMemo(() => { + if (!selectedEnvironmentTypeDefinition) { + return 'Value'; + } + + if (selectedEnvironmentTypeDefinition._type === 'quantitative') { + return `${selectedEnvironmentTypeDefinition.name} ${ + selectedEnvironmentTypeDefinition.unit ? `(${selectedEnvironmentTypeDefinition.unit})` : '' + }`; + } + + return selectedEnvironmentTypeDefinition.name; + }, [selectedEnvironmentTypeDefinition]); + + const environmentQualitativeUnitOptions = useMemo(() => { + if (!environmentCategoryValue) { + return []; + } + + const qualitativeTypeDefinitions = (environmentTypeDefinitions.qualitative_environments ?? []).find( + (item) => item.environment_qualitative_id === environmentCategoryValue.environment_qualitative_id + ); + + return ( + qualitativeTypeDefinitions?.options.map((option) => ({ + value: option.environment_qualitative_option_id, + label: option.name + })) ?? [] + ); + }, [environmentCategoryValue, environmentTypeDefinitions.qualitative_environments]); + + /** + * Clear the environment field values to their default initial state. + */ + const resetField = () => { + setFieldValue(environmentQuantitativeCategoryFieldName, null); + setFieldValue(environmentQualitativeCategoryFieldName, null); + + setFieldValue(environmentQuantitativeUnitFieldName, null); + setFieldValue(environmentQualitativeUnitFieldName, null); + + setFieldValue(environmentUnitTypeFieldName, 'quantitative'); + }; + + /** + * Set the formik field values when a quantitative category option is selected. + */ + const onSelectQuantitativeCategoryOption = (option: EnvironmentQuantitativeCategoryOption) => { + setFieldValue(environmentQuantitativeCategoryFieldName, option?.value ?? null); + setFieldValue(environmentQuantitativeUnitFieldName, null); + + setFieldValue(environmentQualitativeCategoryFieldName, null); + setFieldValue(environmentQualitativeUnitFieldName, null); + + setFieldValue(environmentUnitTypeFieldName, 'quantitative'); + }; + + /** + * Set the formik field values when a qualitative category option is selected. + */ + const onSelectQualitativeCategoryOption = (option: EnvironmentQualitativeCategoryOption) => { + setFieldValue(environmentQualitativeCategoryFieldName, option?.value ?? null); + setFieldValue(environmentQualitativeUnitFieldName, null); + + setFieldValue(environmentQuantitativeCategoryFieldName, null); + setFieldValue(environmentQuantitativeUnitFieldName, null); + + setFieldValue(environmentUnitTypeFieldName, 'qualitative'); + }; + + return ( + + { + if (!option) { + resetField(); + return; + } + + if (option._type === 'quantitative') { + onSelectQuantitativeCategoryOption(option); + } else if (option._type === 'qualitative') { + onSelectQualitativeCategoryOption(option); + } + }} + /> + {categoryDataType === 'quantitative' ? ( + + ) : ( + + )} + + + + + + ); +}; diff --git a/app/src/features/surveys/observations/form/components/environments/environment/QualitativeEnvironmentField.tsx b/app/src/features/surveys/observations/form/components/environments/environment/QualitativeEnvironmentField.tsx new file mode 100644 index 0000000000..1745e02a19 --- /dev/null +++ b/app/src/features/surveys/observations/form/components/environments/environment/QualitativeEnvironmentField.tsx @@ -0,0 +1,31 @@ +import AutocompleteField from 'components/fields/AutocompleteField'; +import { EnvironmentQualitativeTypeDefinition } from 'interfaces/useReferenceApi.interface'; + +export interface IQualitativeEnvironmentFieldProps { + formikFieldName: string; + environmentTypeDefinition: EnvironmentQualitativeTypeDefinition; +} + +/** + * Subcount Qualitative Measurement Field component. + * + * @param {IQualitativeEnvironmentFieldProps} props + * @return {*} + */ +export const QualitativeEnvironmentField = (props: IQualitativeEnvironmentFieldProps) => { + const { formikFieldName, environmentTypeDefinition } = props; + + const QualitativeEnvironmentFieldName = `${formikFieldName}.measurement_option_id`; + + return ( + ({ + label: option.name, + value: option.environment_qualitative_option_id + }))} + /> + ); +}; diff --git a/app/src/features/surveys/observations/form/components/environments/environment/QuantitativeEnvironmentField.tsx b/app/src/features/surveys/observations/form/components/environments/environment/QuantitativeEnvironmentField.tsx new file mode 100644 index 0000000000..533e3c517c --- /dev/null +++ b/app/src/features/surveys/observations/form/components/environments/environment/QuantitativeEnvironmentField.tsx @@ -0,0 +1,29 @@ +import CustomTextField from 'components/fields/CustomTextField'; +import { EnvironmentQuantitativeTypeDefinition } from 'interfaces/useReferenceApi.interface'; + +export interface IQuantitativeEnvironmentFieldProps { + formikFieldName: string; + environmentTypeDefinition: EnvironmentQuantitativeTypeDefinition; +} + +/** + * Subcount Quantitative Measurement Field component. + * + * @param {IQuantitativeEnvironmentFieldProps} props + * @return {*} + */ +export const QuantitativeEnvironmentField = (props: IQuantitativeEnvironmentFieldProps) => { + const { formikFieldName, environmentTypeDefinition } = props; + + const QuantitativeEnvironmentFieldName = `${formikFieldName}.measurement_value`; + + return ( + + ); +}; diff --git a/app/src/features/surveys/observations/form/components/location/ObservationLocationForm.tsx b/app/src/features/surveys/observations/form/components/location/ObservationLocationForm.tsx new file mode 100644 index 0000000000..55cf43b31b --- /dev/null +++ b/app/src/features/surveys/observations/form/components/location/ObservationLocationForm.tsx @@ -0,0 +1,16 @@ +import Stack from '@mui/material/Stack'; +import CustomTextField from 'components/fields/CustomTextField'; + +/** + * Form component for the observation location. + * + * @return {*} + */ +export const ObservationLocationForm = () => { + return ( + + + + + ); +}; diff --git a/app/src/features/surveys/observations/form/components/sampling/ObservationSamplingForm.tsx b/app/src/features/surveys/observations/form/components/sampling/ObservationSamplingForm.tsx new file mode 100644 index 0000000000..93b487e573 --- /dev/null +++ b/app/src/features/surveys/observations/form/components/sampling/ObservationSamplingForm.tsx @@ -0,0 +1,51 @@ +import { mdiPlus } from '@mdi/js'; +import Icon from '@mdi/react'; +import Button from '@mui/material/Button'; +import Stack from '@mui/material/Stack'; +import { MethodTechniqueField } from 'features/surveys/observations/form/components/sampling/components/MethodTechniqueField'; +import { SamplingPeriodField } from 'features/surveys/observations/form/components/sampling/components/SamplingPeriodField'; +import { SamplingSiteField } from 'features/surveys/observations/form/components/sampling/components/SamplingSiteField'; +import { useSamplingInformationCache } from 'features/surveys/observations/form/components/sampling/hooks/useSamplingInformationCache'; +import React from 'react'; + +interface IObservationSamplingFormProps { + showSamplingInformation: boolean; + setShowSamplingInformation: React.Dispatch>; +} + +/** + * Form component for the observation sampling information. + * + * @param {IObservationSamplingFormProps} props + * @return {*} + */ +export const ObservationSamplingForm = (props: IObservationSamplingFormProps) => { + const { showSamplingInformation, setShowSamplingInformation } = props; + + const samplingInformationCache = useSamplingInformationCache(); + + // Initialize the cached sampling information. + // Optional when creating new records. Necessary when editing existing records. + samplingInformationCache.initCachedSamplingInformationRef({ periods: [] }); + + return ( + <> + {showSamplingInformation ? ( + + + + + + ) : ( + + )} + + ); +}; diff --git a/app/src/features/surveys/observations/form/components/sampling/components/MethodTechniqueField.tsx b/app/src/features/surveys/observations/form/components/sampling/components/MethodTechniqueField.tsx new file mode 100644 index 0000000000..062383a0e8 --- /dev/null +++ b/app/src/features/surveys/observations/form/components/sampling/components/MethodTechniqueField.tsx @@ -0,0 +1,218 @@ +import Autocomplete from '@mui/material/Autocomplete'; +import Box from '@mui/material/Box'; +import CircularProgress from '@mui/material/CircularProgress'; +import { grey } from '@mui/material/colors'; +import Paper from '@mui/material/Paper'; +import TextField from '@mui/material/TextField'; +import Typography from '@mui/material/Typography'; +import { + SamplingInformationCache, + SamplingInformationCachedTechnique +} from 'features/surveys/observations/form/components/sampling/hooks/useSamplingInformationCache'; +import { ObservationFormData } from 'features/surveys/observations/form/ObservationForm.interface'; +import { useFormikContext } from 'formik'; +import { useBiohubApi } from 'hooks/useBioHubApi'; +import { useSurveyContext } from 'hooks/useContext'; +import useIsMounted from 'hooks/useIsMounted'; +import { get } from 'lodash-es'; +import debounce from 'lodash-es/debounce'; +import { useEffect, useMemo, useState } from 'react'; + +export interface IMethodTechniqueFieldProps { + samplingInformationCache: SamplingInformationCache; +} + +/** + * Method technique formik field. + * + * @param {IMethodTechniqueFieldProps} props + * @return {*} + */ +export const MethodTechniqueField = (props: IMethodTechniqueFieldProps) => { + const { samplingInformationCache } = props; + + const { values, errors, touched, setFieldValue } = useFormikContext(); + + const biohubApi = useBiohubApi(); + const surveyContext = useSurveyContext(); + + const isMounted = useIsMounted(); + + // The currently selected option + const [currentOption, setCurrentOption] = useState( + values.standardColumns.method_technique_id + ? samplingInformationCache.getCurrentTechnique(values.standardColumns.method_technique_id) + : null + ); + const [options, setOptions] = useState( + samplingInformationCache.getTechniquesForRow(values.standardColumns.survey_sample_site_id ?? null) + ); + // Is control loading (search in progress) + const [isLoading, setIsLoading] = useState(false); + + /** + * Debounced function to get the options for the autocomplete, based on the search term. + * Includes the cached method techniques in the resulting options array. + */ + const getOptions = useMemo( + () => + debounce(async (searchTerm: string) => { + const keyword = searchTerm?.trim(); + + const surveySampleSiteId = values.standardColumns.survey_sample_site_id; + + if (!surveySampleSiteId) { + // Currently the control requires that a site be selected first, before techniques can be searched/selected + setIsLoading(false); + return; + } + + const response = await biohubApi.technique.findTechniques({ + survey_id: surveyContext.surveyId, + sample_site_id: surveySampleSiteId, + keyword + }); + + if (!isMounted()) { + return; + } + + const options: SamplingInformationCachedTechnique[] = response.techniques.map((item) => ({ + method_technique_id: item.method_technique_id, + survey_sample_site_id: surveySampleSiteId, + method_response_metric_id: item.method_response_metric_id, + label: item.name, + value: item.method_technique_id + })); + + // Update the cached method techniques + samplingInformationCache.updateCachedMethodTechniques(options); + + // Get the latest valid options for the current row + const validOptions = samplingInformationCache.getTechniquesForRow( + values.standardColumns.survey_sample_site_id ?? null + ); + + // Set the options for the autocomplete + setOptions(validOptions); + + setIsLoading(false); + }, 500), + [ + biohubApi.technique, + values.standardColumns.survey_sample_site_id, + isMounted, + samplingInformationCache, + surveyContext.surveyId + ] + ); + + useEffect(() => { + if (!values.standardColumns.survey_sample_site_id) { + // If the site not selected, then unset any selected technique, as its value is dependent + // on the site. + setCurrentOption(null); + return; + } + + if (currentOption?.survey_sample_site_id !== values.standardColumns.survey_sample_site_id) { + // If the site has changed, then unset any selected technique, and update the options to reflect the + // valid techniques for the new site. + setCurrentOption(null); + // Set the options to any previously cached techniques for the new site + setOptions(samplingInformationCache.getTechniquesForRow(values.standardColumns.survey_sample_site_id)); + // Trigger a search to get all of the techniques for the new site + setIsLoading(true); + getOptions(''); + } + }, [ + currentOption?.survey_sample_site_id, + getOptions, + samplingInformationCache, + values.standardColumns.survey_sample_site_id + ]); + + return ( + {children}} + getOptionLabel={(option) => option.label} + isOptionEqualToValue={(option, value) => { + if (!option?.value || !value?.value) { + return false; + } + return option.value === value.value; + }} + filterOptions={(item) => item} + onChange={(_, selectedOption) => { + // Set the autocomplete value to the selected option + setCurrentOption(selectedOption); + + // If the method technique is changed, clear sampling period as it is dependent on the method technique + setFieldValue('standardColumns.survey_sample_period_id', null); + // Set the data grid cell value for the selected method technique option + setFieldValue('standardColumns.method_technique_id', selectedOption?.value); + + setIsLoading(false); + }} + onInputChange={(_, newInputValue, reason) => { + if (reason === 'input' && newInputValue !== '') { + // The user has updated the input field, and it is not empty, trigger the search. + // The other options ('clear', 'reset') should not trigger a search. + setIsLoading(true); + getOptions(newInputValue); + } + }} + renderInput={(params) => ( + + {isLoading ? : null} + {params.InputProps.endAdornment} + + ) + }} + /> + )} + renderOption={(renderProps, renderOption) => { + return ( + + + {renderOption.label} + + + ); + }} + data-testid={'method_technique-field'} + /> + ); +}; diff --git a/app/src/features/surveys/observations/form/components/sampling/components/SamplingPeriodField.tsx b/app/src/features/surveys/observations/form/components/sampling/components/SamplingPeriodField.tsx new file mode 100644 index 0000000000..cace15c89e --- /dev/null +++ b/app/src/features/surveys/observations/form/components/sampling/components/SamplingPeriodField.tsx @@ -0,0 +1,223 @@ +import Autocomplete from '@mui/material/Autocomplete'; +import Box from '@mui/material/Box'; +import CircularProgress from '@mui/material/CircularProgress'; +import { grey } from '@mui/material/colors'; +import Paper from '@mui/material/Paper'; +import TextField from '@mui/material/TextField'; +import Typography from '@mui/material/Typography'; +import { + SamplingInformationCache, + SamplingInformationCachedPeriod +} from 'features/surveys/observations/form/components/sampling/hooks/useSamplingInformationCache'; +import { ObservationFormData } from 'features/surveys/observations/form/ObservationForm.interface'; +import { useFormikContext } from 'formik'; +import { useBiohubApi } from 'hooks/useBioHubApi'; +import { useSurveyContext } from 'hooks/useContext'; +import useIsMounted from 'hooks/useIsMounted'; +import { get } from 'lodash-es'; +import debounce from 'lodash-es/debounce'; +import { useEffect, useMemo, useState } from 'react'; +import { getDateTimeLabel } from 'utils/datetime'; + +export interface ISamplingPeriodFieldProps { + samplingInformationCache: SamplingInformationCache; +} + +/** + * Survey sample period formik field. + * + * @param {ISamplingPeriodFieldProps} prop + * @return {*} + */ +export const SamplingPeriodField = (props: ISamplingPeriodFieldProps) => { + const { samplingInformationCache } = props; + + const { values, errors, touched, setFieldValue } = useFormikContext(); + + const biohubApi = useBiohubApi(); + const surveyContext = useSurveyContext(); + + const isMounted = useIsMounted(); + + // The currently selected option + const [currentOption, setCurrentOption] = useState( + values.standardColumns.survey_sample_period_id + ? samplingInformationCache.getCurrentPeriod(values.standardColumns.survey_sample_period_id) + : null + ); + const [options, setOptions] = useState( + samplingInformationCache.getPeriodsForRow(values.standardColumns.method_technique_id ?? null) + ); + // Is control loading (search in progress) + const [isLoading, setIsLoading] = useState(false); + + /** + * Debounced function to get the options for the autocomplete, based on the search term. + * Includes the cached sample periods in the resulting options array. + */ + const getOptions = useMemo( + () => + debounce(async (_searchTerm: string) => { + const surveySampleSiteId = values.standardColumns.survey_sample_site_id; + const methodTechniqueId = values.standardColumns.method_technique_id; + + if (!surveySampleSiteId || !methodTechniqueId) { + // Currently the control requires that a site and technique be selected first, before periods can be + // searched/selected + setIsLoading(false); + return; + } + + const response = await biohubApi.samplingPeriod.findSamplePeriods({ + survey_id: surveyContext.surveyId, + sample_site_id: [surveySampleSiteId], + method_technique_id: [methodTechniqueId] + }); + + if (!isMounted()) { + return; + } + + const options = response.periods + .map((item) => ({ + ...item, + label: getDateTimeLabel(item.start_date, item.start_time, item.end_date, item.end_time), + value: item.survey_sample_period_id + })) // Filter out any periods that do not have a start and end date (and should not be selectable in the UI) + .filter((item) => item.start_date && item.end_date); + + samplingInformationCache.updateCachedSamplingPeriods(options); + + const validOptions = samplingInformationCache.getPeriodsForRow( + values.standardColumns.method_technique_id ?? null + ); + + // Set the options for the autocomplete + setOptions(validOptions); + + setIsLoading(false); + }, 500), + [ + biohubApi.samplingPeriod, + values.standardColumns.survey_sample_site_id, + values.standardColumns.method_technique_id, + isMounted, + samplingInformationCache, + surveyContext.surveyId + ] + ); + + useEffect(() => { + if (!values.standardColumns.survey_sample_site_id || !values.standardColumns.method_technique_id) { + // If either the site or technique is not selected, then unset any selected period, as its value is dependent + // on the site and technique. + setCurrentOption(null); + return; + } + + if ( + currentOption?.survey_sample_site_id !== values.standardColumns.survey_sample_site_id || + currentOption?.method_technique_id !== values.standardColumns.method_technique_id + ) { + // If the site or technique has changed, then unset any selected period, and update the options to reflect the + // valid periods for the new site and technique. + setCurrentOption(null); + // Set the options to any previously cached periods for the new site + technique + setOptions(samplingInformationCache.getPeriodsForRow(values.standardColumns.method_technique_id)); + // Trigger a search to get all of the periods for the new site + technique + setIsLoading(true); + getOptions(''); + } + }, [ + currentOption?.method_technique_id, + currentOption?.survey_sample_site_id, + getOptions, + samplingInformationCache, + values.standardColumns.method_technique_id, + values.standardColumns.survey_sample_site_id + ]); + + return ( + {children}} + getOptionLabel={(option) => option.label} + isOptionEqualToValue={(option, value) => { + if (!option?.value || !value?.value) { + return false; + } + return option.value === value.value; + }} + filterOptions={(item) => item} + onChange={(_, selectedOption) => { + // Set the autocomplete value to the selected option + setCurrentOption(selectedOption); + + // Set the data grid cell value for the selected sampling period option + setFieldValue('standardColumns.survey_sample_period_id', selectedOption?.value); + + setIsLoading(false); + }} + onInputChange={(_, newInputValue, reason) => { + if (reason === 'input' && newInputValue !== '') { + // The user has updated the input field, and it is not empty, trigger the search. + // The other options ('clear', 'reset') should not trigger a search. + setIsLoading(true); + getOptions(newInputValue); + } + }} + renderInput={(params) => ( + + {isLoading ? : null} + {params.InputProps.endAdornment} + + ) + }} + /> + )} + renderOption={(renderProps, renderOption) => { + return ( + + + {renderOption.label} + + + ); + }} + data-testid={'survey_sample_period-field'} + /> + ); +}; diff --git a/app/src/features/surveys/observations/form/components/sampling/components/SamplingSiteField.tsx b/app/src/features/surveys/observations/form/components/sampling/components/SamplingSiteField.tsx new file mode 100644 index 0000000000..f8ae810ea1 --- /dev/null +++ b/app/src/features/surveys/observations/form/components/sampling/components/SamplingSiteField.tsx @@ -0,0 +1,181 @@ +import Autocomplete from '@mui/material/Autocomplete'; +import Box from '@mui/material/Box'; +import CircularProgress from '@mui/material/CircularProgress'; +import { grey } from '@mui/material/colors'; +import Paper from '@mui/material/Paper'; +import TextField from '@mui/material/TextField'; +import Typography from '@mui/material/Typography'; +import { + SamplingInformationCache, + SamplingInformationCachedSite +} from 'features/surveys/observations/form/components/sampling/hooks/useSamplingInformationCache'; +import { ObservationFormData } from 'features/surveys/observations/form/ObservationForm.interface'; +import { useFormikContext } from 'formik'; +import { useBiohubApi } from 'hooks/useBioHubApi'; +import { useSurveyContext } from 'hooks/useContext'; +import useIsMounted from 'hooks/useIsMounted'; +import { get } from 'lodash-es'; +import debounce from 'lodash-es/debounce'; +import { useEffect, useMemo, useState } from 'react'; + +export interface ISamplingSiteFieldProps { + samplingInformationCache: SamplingInformationCache; +} + +/** + * Survey sample site formik field. + * + * @param {ISamplingSiteFieldProps} props + * @return {*} + */ +export const SamplingSiteField = (props: ISamplingSiteFieldProps) => { + const { samplingInformationCache } = props; + + const { values, errors, touched, setFieldValue } = useFormikContext(); + + const biohubApi = useBiohubApi(); + const surveyContext = useSurveyContext(); + + const isMounted = useIsMounted(); + + // The currently selected option + const [currentOption, setCurrentOption] = useState( + values.standardColumns.survey_sample_site_id + ? samplingInformationCache.getCurrentSite(values.standardColumns.survey_sample_site_id) + : null + ); + const [options, setOptions] = useState( + Array.from(samplingInformationCache.cachedSamplingInformationRef.current?.sites.values() ?? []) + ); + // Is control loading (search in progress) + const [isLoading, setIsLoading] = useState(false); + + /** + * Debounced function to get the options for the autocomplete, based on the search term. + * Includes the cached sample sites in the resulting options array. + */ + const getOptions = useMemo( + () => + debounce(async (searchTerm: string) => { + const keyword = searchTerm?.trim(); + + const response = await biohubApi.samplingSite.findSampleSites({ survey_id: surveyContext.surveyId, keyword }); + + if (!isMounted()) { + return; + } + + const options = response.sites.map((item) => ({ + ...item, + label: item.name, + value: item.survey_sample_site_id + })); + + // Update the cached sampling sites + samplingInformationCache.updateCachedSamplingSites(options); + + // Set the options for the autocomplete + setOptions(Array.from(samplingInformationCache.cachedSamplingInformationRef.current?.sites.values() ?? [])); + + setIsLoading(false); + }, 500), + [biohubApi.samplingSite, isMounted, samplingInformationCache, surveyContext.surveyId] + ); + + useEffect(() => { + if (options.length || isLoading) { + return; + } + + // Preload the options on initial load + setIsLoading(true); + getOptions(''); + }, [getOptions, isLoading, options.length]); + + return ( + {children}} + getOptionLabel={(option) => option.label} + isOptionEqualToValue={(option, value) => { + if (!option?.value || !value?.value) { + return false; + } + return option.value === value.value; + }} + filterOptions={(item) => item} + onChange={(_, selectedOption) => { + // Set the autocomplete value to the selected option + setCurrentOption(selectedOption); + + // If the sampling site is changed, clear the method technique and sampling period as they are dependent on + // the site + setFieldValue('standardColumns.method_technique_id', null); + setFieldValue('standardColumns.survey_sample_period_id', null); + + // Set the data grid cell value for the selected sampling site option + setFieldValue('standardColumns.survey_sample_site_id', selectedOption?.value); + + setIsLoading(false); + }} + onInputChange={(_, newInputValue, reason) => { + if (reason === 'input' && newInputValue !== '') { + // The user has updated the input field, and it is not empty, trigger the search. + // The other options ('clear', 'reset') should not trigger a search. + getOptions(newInputValue); + } + }} + renderInput={(params) => ( + + {isLoading ? : null} + {params.InputProps.endAdornment} + + ) + }} + /> + )} + renderOption={(renderProps, renderOption) => { + return ( + + + {renderOption.label} + + + ); + }} + data-testid={'survey_sample_site-field'} + /> + ); +}; diff --git a/app/src/features/surveys/observations/form/components/sampling/hooks/useSamplingInformationCache.tsx b/app/src/features/surveys/observations/form/components/sampling/hooks/useSamplingInformationCache.tsx new file mode 100644 index 0000000000..89382a392e --- /dev/null +++ b/app/src/features/surveys/observations/form/components/sampling/hooks/useSamplingInformationCache.tsx @@ -0,0 +1,341 @@ +import { IAutocompleteFieldOption } from 'components/fields/AutocompleteField'; +import { GetSamplingPeriod } from 'interfaces/useSamplingPeriodApi.interface'; +import { MutableRefObject, useRef } from 'react'; +import { getDateTimeLabel } from 'utils/datetime'; + +export type SamplingInformationCachedSite = IAutocompleteFieldOption & { + survey_sample_site_id: number; +}; + +export type SamplingInformationCachedTechnique = IAutocompleteFieldOption & { + method_technique_id: number; + survey_sample_site_id: number | null; + method_response_metric_id: number; +}; + +export type SamplingInformationCachedPeriod = IAutocompleteFieldOption & { + survey_sample_period_id: number; + survey_sample_site_id: number | null; + method_technique_id: number | null; +}; + +export type SamplingInformationCacheRef = { + // A unique list of sample sites + sites: Map; + // A unique list of techniques + techniques: Map; + // A unique list of sampling periods + periods: Map; +}; + +export type SamplingInformationCache = { + cachedSamplingInformationRef: MutableRefObject; + initCachedSamplingInformationRef: (params: { periods?: GetSamplingPeriod[] }) => void; + updateCachedSamplingSites: (sites: SamplingInformationCachedSite[]) => void; + updateCachedMethodTechniques: (techniques: SamplingInformationCachedTechnique[]) => void; + updateCachedSamplingPeriods: (periods: SamplingInformationCachedPeriod[]) => void; + getCurrentSite: (surveySampleSiteId: number) => SamplingInformationCachedSite | null; + getCurrentTechnique: (methodTechniqueId: number) => SamplingInformationCachedTechnique | null; + getCurrentPeriod: (surveySamplePeriodId: number) => SamplingInformationCachedPeriod | null; + getTechniquesForRow: (survey_sample_site_id: number | null) => SamplingInformationCachedTechnique[]; + getPeriodsForRow: (method_technique_id: number | null) => SamplingInformationCachedPeriod[]; +}; + +/** + * A hook to manage a cache of sampling information. + * + * Provides methods to initialize the cache, update the cache with new data, and retrieve data from the cache. + * + * @return {*} {SamplingInformationCache} + */ +export const useSamplingInformationCache = (): SamplingInformationCache => { + const cachedSamplingInformationRef = useRef(); + + /** + * Initialize the cache with the provided sampling periods. + * + * @param {{ periods?: GetSamplingPeriod[] }} params + * @return {*} + */ + const initCachedSamplingInformationRef = (params: { periods?: GetSamplingPeriod[] }) => { + if (!params.periods?.length) { + cachedSamplingInformationRef.current = { + sites: new Map(), + techniques: new Map(), + periods: new Map() + }; + + return; + } + + const sitesMap: Map = new Map(); + const techniquesMap: Map = new Map(); + const periodsMap: Map = new Map(); + + params.periods.forEach((period) => { + if (_isValidSamplingSite(period) && !sitesMap.has(period.survey_sample_site_id)) { + sitesMap.set(period.survey_sample_site_id, { + survey_sample_site_id: period.survey_sample_site_id, + // Satisfy the IAutocompleteDataGridOption interface + value: period.survey_sample_site_id, + label: period.survey_sample_site.name + }); + } + + if (_isValidMethodTechnique(period) && !techniquesMap.has(period.method_technique.method_technique_id)) { + techniquesMap.set(period.method_technique.method_technique_id, { + method_technique_id: period.method_technique.method_technique_id, + survey_sample_site_id: period.survey_sample_site_id, + method_response_metric_id: period.method_technique.method_response_metric_id, + // Satisfy the IAutocompleteDataGridOption interface + value: period.method_technique.method_technique_id, + label: period.method_technique.name + }); + } + + if (_isValidSamplingPeriod(period) && !periodsMap.has(period.survey_sample_period_id)) { + periodsMap.set(period.survey_sample_period_id, { + survey_sample_period_id: period.survey_sample_period_id, + survey_sample_site_id: period.survey_sample_site_id, + method_technique_id: period.method_technique_id, + // Satisfy the IAutocompleteDataGridOption interface + value: period.survey_sample_period_id, + label: getDateTimeLabel(period.start_date, period.start_time, period.end_date, period.end_time) + }); + } + }); + + cachedSamplingInformationRef.current = { + sites: sitesMap, + techniques: techniquesMap, + periods: periodsMap + }; + }; + + /** + * Update the cache with new sampling sites. Will ignore sites that are already in the cache. + * + * @param {SamplingInformationCachedSite[]} sites + * @return {*} + */ + const updateCachedSamplingSites = (sites: SamplingInformationCachedSite[]) => { + if (!cachedSamplingInformationRef.current) { + return; + } + + const newSitesMap = cachedSamplingInformationRef.current.sites; + + for (const site of sites) { + if (!newSitesMap.has(site.survey_sample_site_id)) { + newSitesMap.set(site.survey_sample_site_id, site); + } + } + + // Update the cache + cachedSamplingInformationRef.current = { + sites: newSitesMap, + techniques: cachedSamplingInformationRef.current.techniques, + periods: cachedSamplingInformationRef.current.periods + }; + }; + + /** + * Update the cache with new method techniques. Will ignore techniques that are already in the cache. + * + * @param {SamplingInformationCachedTechnique[]} techniques + * @return {*} + */ + const updateCachedMethodTechniques = (techniques: SamplingInformationCachedTechnique[]) => { + if (!cachedSamplingInformationRef.current) { + return; + } + + const newTechniquesMap = cachedSamplingInformationRef.current.techniques; + + for (const technique of techniques) { + if (!newTechniquesMap.has(technique.method_technique_id)) { + newTechniquesMap.set(technique.method_technique_id, technique); + } + } + + // Update the cache + cachedSamplingInformationRef.current = { + sites: cachedSamplingInformationRef.current.sites, + techniques: newTechniquesMap, + periods: cachedSamplingInformationRef.current.periods + }; + }; + + /** + * Update the cache with new sampling periods. Will ignore periods that are already in the cache. + * + * @param {SamplingInformationCachedPeriod[]} periods + * @return {*} + */ + const updateCachedSamplingPeriods = (periods: SamplingInformationCachedPeriod[]) => { + if (!cachedSamplingInformationRef.current) { + return; + } + + const newPeriodsMap = cachedSamplingInformationRef.current.periods; + + for (const period of periods) { + if (!newPeriodsMap.has(period.survey_sample_period_id)) { + newPeriodsMap.set(period.survey_sample_period_id, period); + } + } + + // Update the cache + cachedSamplingInformationRef.current = { + sites: cachedSamplingInformationRef.current.sites, + techniques: cachedSamplingInformationRef.current.techniques, + periods: newPeriodsMap + }; + }; + + /** + * Return the site object for the provided site id. + * + * @param {(number | null)} [siteId] + * @return {*} {(SamplingInformationCachedSite | null)} + */ + const findSite = (siteId?: number | null): SamplingInformationCachedSite | null => { + if (!siteId) { + return null; + } + + return cachedSamplingInformationRef.current?.sites.get(siteId) ?? null; + }; + + /** + * Return the technique object for the provided technique id. + * + * @param {(number | null)} [techniqueId] + * @return {*} {(SamplingInformationCachedTechnique | null)} + */ + const findTechnique = (techniqueId?: number | null): SamplingInformationCachedTechnique | null => { + if (!techniqueId) { + return null; + } + + return cachedSamplingInformationRef.current?.techniques.get(techniqueId) ?? null; + }; + + /** + * Return the period object for the provided period id. + * + * @param {(number | null)} [periodId] + * @return {*} {(SamplingInformationCachedPeriod | null)} + */ + const findPeriod = (periodId?: number | null): SamplingInformationCachedPeriod | null => { + if (!periodId) { + return null; + } + + return cachedSamplingInformationRef.current?.periods.get(periodId) ?? null; + }; + + /** + * Get the currently selected site for the row. + * + * @template DataGridType + * @param {GridRenderCellParams} dataGridProps + * @param {(MutableRefObject)} cachedSamplingInformationRef + * @return {*} {(SamplingInformationCachedSite | null)} + */ + const getCurrentSite = (surveySampleSiteId: number): SamplingInformationCachedSite | null => { + return findSite(surveySampleSiteId) ?? null; + }; + + /** + * Get the currently selected method technique for the row. + * + * @param {number} methodTechniqueId + * @return {*} {(SamplingInformationCachedTechnique | null)} + */ + const getCurrentTechnique = (methodTechniqueId: number): SamplingInformationCachedTechnique | null => { + return findTechnique(methodTechniqueId) ?? null; + }; + + /** + * Get the currently selected period for the row. + * + * @param {number} surveySamplePeriodId + * @return {*} {(SamplingInformationCachedPeriod | null)} + */ + const getCurrentPeriod = (surveySamplePeriodId: number): SamplingInformationCachedPeriod | null => { + return findPeriod(surveySamplePeriodId) ?? null; + }; + + /** + * Get all valid techniques for the currently selected site. + * + * @param {(number | null)} [surveySampleSiteId] + * @return {*} {SamplingInformationCachedTechnique[]} + */ + const getTechniquesForRow = (surveySampleSiteId?: number | null): SamplingInformationCachedTechnique[] => { + if (!surveySampleSiteId) { + return []; + } + + return Array.from(cachedSamplingInformationRef.current?.techniques.values() ?? []).filter((technique) => { + return technique.survey_sample_site_id === surveySampleSiteId; + }); + }; + + /** + * Get all valid periods for the currently selected site and technique. + * + * @param {(number | null)} [methodTechniqueId] + * @return {*} {SamplingInformationCachedPeriod[]} + */ + const getPeriodsForRow = (methodTechniqueId?: number | null): SamplingInformationCachedPeriod[] => { + if (!methodTechniqueId) { + return []; + } + + return Array.from(cachedSamplingInformationRef.current?.periods.values() ?? []).filter((period) => { + return period.method_technique_id === methodTechniqueId; + }); + }; + + const _isValidSamplingSite = ( + period: GetSamplingPeriod + ): period is GetSamplingPeriod & { + survey_sample_site_id: NonNullable; + survey_sample_site: NonNullable; + } => { + return period.survey_sample_site_id !== null && period.survey_sample_site !== null; + }; + + const _isValidMethodTechnique = ( + period: GetSamplingPeriod + ): period is GetSamplingPeriod & { + method_technique_id: NonNullable; + method_technique: NonNullable; + } => { + return period.method_technique_id !== null && period.method_technique !== null; + }; + + const _isValidSamplingPeriod = ( + period: GetSamplingPeriod + ): period is GetSamplingPeriod & { + start_date: NonNullable; + end_date: NonNullable; + } => { + return period.start_date !== null && period.end_date !== null; + }; + + return { + cachedSamplingInformationRef, + initCachedSamplingInformationRef, + updateCachedSamplingSites, + updateCachedMethodTechniques, + updateCachedSamplingPeriods, + getCurrentSite, + getCurrentTechnique, + getCurrentPeriod, + getTechniquesForRow, + getPeriodsForRow + }; +}; diff --git a/app/src/features/surveys/observations/form/components/species/ObservationSpeciesForm.tsx b/app/src/features/surveys/observations/form/components/species/ObservationSpeciesForm.tsx new file mode 100644 index 0000000000..c2cb7e0958 --- /dev/null +++ b/app/src/features/surveys/observations/form/components/species/ObservationSpeciesForm.tsx @@ -0,0 +1,74 @@ +import Collapse from '@mui/material/Collapse'; +import grey from '@mui/material/colors/grey'; +import Stack from '@mui/material/Stack'; +import AutocompleteField from 'components/fields/AutocompleteField'; +import SpeciesAutocompleteField from 'components/species/components/SpeciesAutocompleteField'; +import SpeciesSelectedCard from 'components/species/components/SpeciesSelectedCard'; +import { ObservationFormData } from 'features/surveys/observations/form/ObservationForm.interface'; +import { useFormikContext } from 'formik'; +import { useCodesContext } from 'hooks/useContext'; +import { get } from 'lodash-es'; +import { useEffect } from 'react'; +import { TransitionGroup } from 'react-transition-group'; + +/** + * Form component for the observation species. + * + * @return {*} + */ +export const ObservationSpeciesForm = () => { + const codesContext = useCodesContext(); + + useEffect(() => { + codesContext.codesDataLoader.load(); + }, [codesContext.codesDataLoader]); + + const { setFieldValue, values, errors } = useFormikContext(); + + return ( + + { + if (species.tsn) { + setFieldValue('standardColumns.itis_tsn', species.tsn); + setFieldValue('standardColumns.itis_scientific_name', species.scientificName); + } + }} + clearOnSelect={true} + error={get(errors, 'standardColumns.itis_tsn')} + /> + + {values.standardColumns.itis_tsn && values.standardColumns.itis_scientific_name && ( + + { + setFieldValue('standardColumns.itis_tsn', null); + }} + /> + + )} + + ({ + label: sign.name, + value: sign.id + })) ?? [] + } + helpText="Select the type of evidence that was observed." + /> + + ); +}; diff --git a/app/src/features/surveys/observations/form/components/subcounts/SubcountsForm.tsx b/app/src/features/surveys/observations/form/components/subcounts/SubcountsForm.tsx new file mode 100644 index 0000000000..e19add234b --- /dev/null +++ b/app/src/features/surveys/observations/form/components/subcounts/SubcountsForm.tsx @@ -0,0 +1,182 @@ +import { mdiMinusCircle, mdiPlus } from '@mdi/js'; +import { Icon } from '@mdi/react'; +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; +import IconButton from '@mui/material/IconButton'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import HelpButtonStack from 'components/buttons/HelpButtonStack'; +import { + initialSubcountFormData, + SubcountForm, + SubcountFormData +} from 'features/surveys/observations/form/components/subcounts/subcount/SubcountForm'; +import { ObservationFormData } from 'features/surveys/observations/form/ObservationForm.interface'; +import { FieldArray, useFormikContext } from 'formik'; +import { useFocalOrObservedSpeciesTsns } from 'hooks/useFocalOrObservedTsns'; +import { CBMeasurementType } from 'interfaces/useCritterApi.interface'; +import get from 'lodash-es/get'; +import { useMemo, useState } from 'react'; +import { v4 } from 'uuid'; +import { MeasurementsSearch } from '../../../observations-table/configure-columns/components/measurements/search/MeasurementsSearch'; + +export type SubcountsFormData = { + subcounts: SubcountFormData[]; +}; + +export const initialSubcountsFormData: SubcountsFormData = { + subcounts: [ + { + _id: v4(), + ...initialSubcountFormData + } + ] +}; + +/** + * Form component for observation subcounts. + * + * @return {*} + */ +export const SubcountsForm = () => { + const { values, setFieldValue } = useFormikContext(); + + const [, allSpeciesWithParentsTsns] = useFocalOrObservedSpeciesTsns(); + + // Keep selected measurements in state to get measurement names + const [selectedMeasurementTypeDefinitions, setSelectedMeasurementTypeDefinitions] = useState([]); + + // Performance: pre-parse the selected measurements into the structure expected by the subcount form. + const selectedMeasurementsFormData = useMemo(() => { + return selectedMeasurementTypeDefinitions.map((measurement) => ({ + measurement_option_id: null as unknown as string, + measurement_id: measurement.taxon_measurement_id + })); + }, [selectedMeasurementTypeDefinitions]); + + // Adds a new measurement column to the data grid + const handleAddMeasurement = (measurement: CBMeasurementType) => { + // Add the measurement to selectedMeasurements state + setSelectedMeasurementTypeDefinitions((prev) => [...prev, measurement]); + + // Update subcounts with the new measurement + const subcounts: SubcountFormData[] | undefined = get(values, 'subcounts')?.map((subcount) => ({ + ...subcount, + measurements: [ + ...subcount.measurements, + { + measurement_option_id: null as unknown as string, + measurement_id: measurement.taxon_measurement_id + } + ] + })); + + setFieldValue('subcounts', subcounts); + }; + + const handleRemoveMeasurement = (taxonMeasurementId: string) => { + // Remove the measurement from all subcounts + const updatedSubcounts = values.subcounts.map((subcount) => ({ + ...subcount, + measurements: subcount.measurements.filter((measurement) => measurement.measurement_id !== taxonMeasurementId) + })); + + // Remove the measurement from the selected measurements state + setSelectedMeasurementTypeDefinitions((prev) => + prev.filter((measurement) => measurement.taxon_measurement_id !== taxonMeasurementId) + ); + + // Update the formik state with the updated subcounts + setFieldValue('subcounts', updatedSubcounts); + }; + + return ( + <> + + + Select Attributes + + + + + + + { + const subcountsFormData: SubcountFormData[] | undefined = get(values, 'subcounts'); + + return ( + <> + + {subcountsFormData?.map((subcount, index) => { + const subcountsArrayFieldName = `subcounts[${index}]`; + + const enableHeaders = index === 0; + const disableRemoveSubcount = values.subcounts.length <= 1; + + return ( + + + + + { + // Remove the subcount from the subcounts array + arrayHelpers.remove(index); + }}> + + + + + ); + })} + + + + ); + }} + /> + + + ); +}; diff --git a/app/src/features/surveys/observations/form/components/subcounts/subcount/SubcountForm.tsx b/app/src/features/surveys/observations/form/components/subcounts/subcount/SubcountForm.tsx new file mode 100644 index 0000000000..b999d7916d --- /dev/null +++ b/app/src/features/surveys/observations/form/components/subcounts/subcount/SubcountForm.tsx @@ -0,0 +1,96 @@ +import Box from '@mui/material/Box'; +import grey from '@mui/material/colors/grey'; +import Paper from '@mui/material/Paper'; +import Stack from '@mui/material/Stack'; +import { SubcountCountField } from 'features/surveys/observations/form/components/subcounts/subcount/count/SubcountCountField'; +import { + initialSubcountMeasurementsFormData, + SubcountMeasurementsForm +} from 'features/surveys/observations/form/components/subcounts/subcount/measurements/SubcountMeasurementsForm'; +import { CBMeasurementType } from 'interfaces/useCritterApi.interface'; +import { SubcountCommentForm } from './comment/SubcountCommentForm'; + +export type SubcountFormData = { + /** + * Unique id for react keys. + */ + _id?: string; + /** + * The subcount record id. + * + * Will be null when creating a new subcount record, and will be non-null when editing an existing subcount record. + */ + observation_subcount_id: number | null; + /** + * The count value for the subcount record. + * + * Ex: How many of the species were observed. + */ + subcount: number | null; + /** + * The comment for the subcount record. + */ + comment: string | null; + /** + * The markings for the subcount record. + * + * // TODO - future enhancement + */ + markings?: never[]; +} & SubcountMeasurementsForm; + +export const initialSubcountFormData: SubcountFormData = { + observation_subcount_id: null, + subcount: null, + comment: null, + ...initialSubcountMeasurementsFormData +}; + +export interface ISubcountFormProps { + formikFieldName: string; + measurementTypeDefinitions: CBMeasurementType[]; + onDeleteMeasurement: (taxonMeasurementId: string) => void; + enableHeaders?: boolean; +} + +/** + * Form component for a single observation subcount. + * + * @param {ISubcountFormProps} props + * @return {*} + */ +export const SubcountForm = (props: ISubcountFormProps) => { + const { formikFieldName, measurementTypeDefinitions, onDeleteMeasurement, enableHeaders } = props; + + return ( + + + {/* Render the subcount count field */} + + + + + {/* Render the subcount measurement fields */} + + + + + {/* Render the subcount comment field */} + + + + + + ); +}; diff --git a/app/src/features/surveys/observations/form/components/subcounts/subcount/comment/SubcountCommentForm.tsx b/app/src/features/surveys/observations/form/components/subcounts/subcount/comment/SubcountCommentForm.tsx new file mode 100644 index 0000000000..a8093a1e79 --- /dev/null +++ b/app/src/features/surveys/observations/form/components/subcounts/subcount/comment/SubcountCommentForm.tsx @@ -0,0 +1,30 @@ +import Typography from '@mui/material/Typography'; +import CustomTextField from 'components/fields/CustomTextField'; + +export interface ISubcountCommentFormProps { + formikFieldName: string; + displayHeader?: boolean; +} + +/** + * Form component for the observation subcount comment. + * + * @param {ISubcountCommentFormProps} props + * @return {*} + */ +export const SubcountCommentForm = (props: ISubcountCommentFormProps) => { + const { displayHeader, formikFieldName } = props; + + const subcountCommentFieldName = formikFieldName ? `${formikFieldName}.comment` : 'comment'; + + return ( + <> + {displayHeader === true && ( + + Comment + + )} + + + ); +}; diff --git a/app/src/features/surveys/observations/form/components/subcounts/subcount/count/SubcountCountField.tsx b/app/src/features/surveys/observations/form/components/subcounts/subcount/count/SubcountCountField.tsx new file mode 100644 index 0000000000..0e8a5279a4 --- /dev/null +++ b/app/src/features/surveys/observations/form/components/subcounts/subcount/count/SubcountCountField.tsx @@ -0,0 +1,37 @@ +import Typography from '@mui/material/Typography'; +import HelpButtonStack from 'components/buttons/HelpButtonStack'; +import CustomTextField from 'components/fields/CustomTextField'; + +export interface ISubcountCountFieldProps { + formikFieldName: string; + displayHeader?: boolean; +} + +/** + * Subcount Count Field component. + * + * @param {ISubcountCountFieldProps} props + * @return {*} + */ +export const SubcountCountField = (props: ISubcountCountFieldProps) => { + const { formikFieldName, displayHeader } = props; + + const subcountCountFieldName = `${formikFieldName}.subcount`; + + return ( + <> + {displayHeader === true && ( + + + Count + + + )} + + + ); +}; diff --git a/app/src/features/surveys/observations/form/components/subcounts/subcount/measurements/SubcountMeasurementField.tsx b/app/src/features/surveys/observations/form/components/subcounts/subcount/measurements/SubcountMeasurementField.tsx new file mode 100644 index 0000000000..e01aa2e56b --- /dev/null +++ b/app/src/features/surveys/observations/form/components/subcounts/subcount/measurements/SubcountMeasurementField.tsx @@ -0,0 +1,74 @@ +import { mdiMinusCircle } from '@mdi/js'; +import { Icon } from '@mdi/react'; +import Box from '@mui/material/Box'; +import IconButton from '@mui/material/IconButton'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import { SubcountQualitativeMeasurementField } from 'features/surveys/observations/form/components/subcounts/subcount/measurements/SubcountQualitativeMeasurementField'; +import { SubcountQuantitativeMeasurementField } from 'features/surveys/observations/form/components/subcounts/subcount/measurements/SubcountQuantitativeMeasurementField'; +import { isCBQualitativeMeasurementTypeDefinition } from 'features/surveys/observations/utils/type-guard-utils'; +import { CBMeasurementType } from 'interfaces/useCritterApi.interface'; + +export interface ISubcountMeasurementFieldProps { + /** + * The formik path prefix for the field. May be empty. + */ + formikFieldName: string; + /** + * The measurement type definition for the measurement field. + */ + measurementTypeDefinition: CBMeasurementType; + /** + * Whether to display a header with the measurement name and a delete button. + */ + displayHeader?: boolean; + /** + * Callback fired when the delete button is clicked. + * + * The delete button is only displayed if displayHeader is true. + */ + onDelete?: () => void; +} + +/** + * Subcount Measurement Field component. + * + * @param {ISubcountMeasurementFieldProps} props + * @return {*} + */ +export const SubcountMeasurementField = (props: ISubcountMeasurementFieldProps) => { + const { formikFieldName, measurementTypeDefinition, onDelete, displayHeader } = props; + + const isQualitativeMeasurement = isCBQualitativeMeasurementTypeDefinition(measurementTypeDefinition); + + return ( + + {displayHeader === true && ( + + + {measurementTypeDefinition.measurement_name} + + + + + + )} + + {isQualitativeMeasurement ? ( + + ) : ( + + )} + + ); +}; diff --git a/app/src/features/surveys/observations/form/components/subcounts/subcount/measurements/SubcountMeasurementsForm.tsx b/app/src/features/surveys/observations/form/components/subcounts/subcount/measurements/SubcountMeasurementsForm.tsx new file mode 100644 index 0000000000..521e1309a4 --- /dev/null +++ b/app/src/features/surveys/observations/form/components/subcounts/subcount/measurements/SubcountMeasurementsForm.tsx @@ -0,0 +1,96 @@ +import { SubcountMeasurementField } from 'features/surveys/observations/form/components/subcounts/subcount/measurements/SubcountMeasurementField'; +import { ObservationFormData } from 'features/surveys/observations/form/ObservationForm.interface'; +import { FieldArray, useFormikContext } from 'formik'; +import { CBMeasurementType } from 'interfaces/useCritterApi.interface'; +import { + SubcountQualitativeMeasurement, + SubcountQuantitativeMeasurement +} from 'interfaces/useObservationApi.interface'; +import get from 'lodash-es/get'; + +export interface SubcountMeasurementsForm { + /** + * The subcount measurements. + * + * @type {((SubcountQualitativeMeasurement | SubcountQuantitativeMeasurement)[])} + * @memberof SubcountMeasurementsForm + */ + measurements: (SubcountQualitativeMeasurement | SubcountQuantitativeMeasurement)[]; +} + +export const initialSubcountMeasurementsFormData: SubcountMeasurementsForm = { + measurements: [] +}; + +export interface ISubcountMeasurementsFormProps { + /** + * The formik field name for the array field. + */ + formikFieldName: string; + /** + * The measurement type definitions. + */ + measurementTypeDefinitions: CBMeasurementType[]; + /** + * Callback fired when the delete button is clicked. + */ + onDeleteMeasurement: (taxonMeasurementId: string) => void; + /** + * Whether to display headers for each measurement field. + */ + enableHeaders?: boolean; +} + +/** + * Subcount Measurements form component that renders an array of measurement fields. + * + * @param {ISubcountMeasurementsFormProps} props + * @return {*} + */ +export const SubcountMeasurementsForm = (props: ISubcountMeasurementsFormProps) => { + const { formikFieldName, measurementTypeDefinitions, onDeleteMeasurement, enableHeaders } = props; + + const { values } = useFormikContext(); + + const measurementsFieldName = `${formikFieldName}.measurements`; + + const measurements: (SubcountQualitativeMeasurement | SubcountQuantitativeMeasurement)[] | undefined = get( + values, + measurementsFieldName + ); + + if (!measurements?.length) { + // No measurement fields to render + return null; + } + + return ( + { + return measurements.map((measurement, index) => { + const measurementTypeDefinition = measurementTypeDefinitions.find( + (measurementTypeDefinition) => measurementTypeDefinition.taxon_measurement_id === measurement.measurement_id + ); + + if (!measurementTypeDefinition) { + // Failed to find the corresponding measurement type definition for the measurement form field + return null; + } + + const measurementsArrayFieldName = `${measurementsFieldName}[${index}]`; + + return ( + onDeleteMeasurement(measurementTypeDefinition.taxon_measurement_id)} + displayHeader={enableHeaders} + /> + ); + }); + }} + /> + ); +}; diff --git a/app/src/features/surveys/observations/form/components/subcounts/subcount/measurements/SubcountQualitativeMeasurementField.tsx b/app/src/features/surveys/observations/form/components/subcounts/subcount/measurements/SubcountQualitativeMeasurementField.tsx new file mode 100644 index 0000000000..52c4c94081 --- /dev/null +++ b/app/src/features/surveys/observations/form/components/subcounts/subcount/measurements/SubcountQualitativeMeasurementField.tsx @@ -0,0 +1,31 @@ +import AutocompleteField from 'components/fields/AutocompleteField'; +import { CBQualitativeMeasurementTypeDefinition } from 'interfaces/useCritterApi.interface'; + +export interface ISubcountQualitativeMeasurementFieldProps { + formikFieldName: string; + measurementTypeDefinition: CBQualitativeMeasurementTypeDefinition; +} + +/** + * Subcount Qualitative Measurement Field component. + * + * @param {ISubcountQualitativeMeasurementFieldProps} props + * @return {*} + */ +export const SubcountQualitativeMeasurementField = (props: ISubcountQualitativeMeasurementFieldProps) => { + const { formikFieldName, measurementTypeDefinition } = props; + + const subcountQualitativeMeasurementFieldName = `${formikFieldName}.measurement_option_id`; + + return ( + ({ + label: option.option_label, + value: option.qualitative_option_id + }))} + /> + ); +}; diff --git a/app/src/features/surveys/observations/form/components/subcounts/subcount/measurements/SubcountQuantitativeMeasurementField.tsx b/app/src/features/surveys/observations/form/components/subcounts/subcount/measurements/SubcountQuantitativeMeasurementField.tsx new file mode 100644 index 0000000000..1d553aaa19 --- /dev/null +++ b/app/src/features/surveys/observations/form/components/subcounts/subcount/measurements/SubcountQuantitativeMeasurementField.tsx @@ -0,0 +1,29 @@ +import CustomTextField from 'components/fields/CustomTextField'; +import { CBQuantitativeMeasurementTypeDefinition } from 'interfaces/useCritterApi.interface'; + +export interface ISubcountQuantitativeMeasurementFieldProps { + formikFieldName: string; + measurementTypeDefinition: CBQuantitativeMeasurementTypeDefinition; +} + +/** + * Subcount Quantitative Measurement Field component. + * + * @param {ISubcountQuantitativeMeasurementFieldProps} props + * @return {*} + */ +export const SubcountQuantitativeMeasurementField = (props: ISubcountQuantitativeMeasurementFieldProps) => { + const { formikFieldName, measurementTypeDefinition } = props; + + const subcountQuantitativeMeasurementFieldName = `${formikFieldName}.measurement_value`; + + return ( + + ); +}; diff --git a/app/src/features/surveys/observations/observations-table/ObservationsTable.tsx b/app/src/features/surveys/observations/observations-table/ObservationsTable.tsx index 7c3afd2cbe..d047b97651 100644 --- a/app/src/features/surveys/observations/observations-table/ObservationsTable.tsx +++ b/app/src/features/surveys/observations/observations-table/ObservationsTable.tsx @@ -51,7 +51,9 @@ const ObservationsTable = (props: ISpeciesObservationTableProps) => { sortModel={observationsTableContext.sortModel} onSortModelChange={observationsTableContext.setSortModel} // Row editing - onRowEditStart={(params) => observationsTableContext.onRowEditStart(params.id)} + onRowEditStart={(params) => { + observationsTableContext.onRowEditStart(params.id); + }} onRowEditStop={(_params, event) => { event.defaultMuiPrevented = true; }} diff --git a/app/src/features/surveys/observations/observations-table/ObservationsTableContainer.tsx b/app/src/features/surveys/observations/observations-table/ObservationsTableContainer.tsx index 454f318f38..b8a9bbc9da 100644 --- a/app/src/features/surveys/observations/observations-table/ObservationsTableContainer.tsx +++ b/app/src/features/surveys/observations/observations-table/ObservationsTableContainer.tsx @@ -24,8 +24,8 @@ import { BulkActionsButton } from 'features/surveys/observations/observations-ta import { DiscardChangesButton } from 'features/surveys/observations/observations-table/discard-changes/DiscardChangesButton'; import { MethodTechniqueColDef, - ObservationCountColDef, - ObservationSubcountSignColDef, + ObservationSignColDef, + ObservationSubcountColDef, SamplePeriodColDef, SampleSiteColDef, TaxonomyColDef @@ -37,10 +37,12 @@ import { useCodesContext, useObservationsContext, useObservationsPageContext, - useObservationsTableContext + useObservationsTableContext, + useSurveyContext } from 'hooks/useContext'; import { MarkdownTypeNameEnum } from 'interfaces/useMarkdownApi.interface'; import { useEffect, useMemo } from 'react'; +import { useHistory } from 'react-router'; import { ConfigureColumnsButton } from './configure-columns/ConfigureColumnsButton'; import ExportHeadersButton from './export-button/ExportHeadersButton'; import { ObservationSubcountCommentDialog } from './grid-column-definitions/comment/ObservationSubcountCommentDialog'; @@ -56,17 +58,20 @@ const ObservationsTableContainer = () => { const observationsTableContext = useObservationsTableContext(); const observationsContext = useObservationsContext(); + const { projectId, surveyId } = useSurveyContext(); + const history = useHistory(); + useEffect(() => { codesContext.codesDataLoader.load(); }, [codesContext.codesDataLoader]); - const observationSubcountSignOptions = useMemo( + const observationSignOptions = useMemo( () => - codesContext.codesDataLoader.data?.observation_subcount_signs.map((option) => ({ - observation_subcount_sign_id: option.id, + codesContext.codesDataLoader.data?.observation_signs.map((option) => ({ + observation_sign_id: option.id, name: option.name })) ?? [], - [codesContext.codesDataLoader.data?.observation_subcount_signs] + [codesContext.codesDataLoader.data?.observation_signs] ); const samplingInformationCache = useSamplingInformationCache(); @@ -102,8 +107,8 @@ const ObservationsTableContainer = () => { samplingInformationCache: samplingInformationCache, hasError: observationsTableContext.hasError }), - ObservationSubcountSignColDef({ observationSubcountSignOptions, hasError: observationsTableContext.hasError }), - ObservationCountColDef({ + ObservationSignColDef({ observationSignOptions, hasError: observationsTableContext.hasError }), + ObservationSubcountColDef({ samplingInformationCache: samplingInformationCache, hasError: observationsTableContext.hasError }), @@ -111,25 +116,29 @@ const ObservationsTableContainer = () => { field: 'observation_date', headerName: 'Date', hasError: observationsTableContext.hasError, - description: 'The date when the observation was made' + description: 'The date when the observation was made', + editable: false }), GenericTimeColDef({ field: 'observation_time', headerName: 'Time', hasError: observationsTableContext.hasError, - description: 'The time of day when the observation was made' + description: 'The time of day when the observation was made', + editable: false }), GenericLatitudeColDef({ field: 'latitude', headerName: 'Latitude', hasError: observationsTableContext.hasError, - description: 'The latitude where the observation was made' + description: 'The latitude where the observation was made', + editable: false }), GenericLongitudeColDef({ field: 'longitude', headerName: 'Longitude', hasError: observationsTableContext.hasError, - description: 'The longitude where the observation was made' + description: 'The longitude where the observation was made', + editable: false }), // Add measurement columns to the table ...getMeasurementColumnDefinitions( @@ -144,6 +153,7 @@ const ObservationsTableContainer = () => { GenericCommentColDef({ field: 'comment', headerName: '', + editable: false, hasError: observationsTableContext.hasError, handleOpen: (params: GridRenderEditCellParams) => observationsTableContext.setCommentDialogParams(params), handleClose: () => observationsTableContext.setCommentDialogParams(null) @@ -153,7 +163,7 @@ const ObservationsTableContainer = () => { // observationsTableContext is listed as a missing dependency // eslint-disable-next-line react-hooks/exhaustive-deps [ - observationSubcountSignOptions, + observationSignOptions, observationsTableContext.environmentColumns, observationsTableContext.hasError, observationsTableContext.measurementColumns, @@ -193,10 +203,11 @@ const ObservationsTableContainer = () => { variant="contained" color="primary" startIcon={} - onClick={() => observationsTableContext.addObservationRecord()} + onClick={() => history.push(`/admin/projects/${projectId}/surveys/${surveyId}/observations/create`)} disabled={observationsTableContext.isSaving || observationsTableContext.isDisabled}> - Add + Add Row + { open={Boolean(observationsTableContext.commentDialogParams)} initialValue={observationsTableContext.commentDialogParams?.value} handleClose={() => observationsTableContext.setCommentDialogParams(null)} - handleSave={(value) => { - if (!observationsTableContext.commentDialogParams) { - return; - } - - observationsTableContext.commentDialogParams.api.setEditCellValue({ - value, - id: observationsTableContext.commentDialogParams.id, - field: observationsTableContext.commentDialogParams.field - }); - }} /> 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 index 8194dd930d..2d3c810420 100644 --- 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 @@ -31,7 +31,7 @@ export const EnvironmentsSearch = (props: IEnvironmentsSearchProps) => { const biohubApi = useBiohubApi(); const environmentsDataLoader = useDataLoader(async (searchTerm: string) => - biohubApi.reference.findSubcountEnvironments(searchTerm) + biohubApi.reference.findEnvironmentReferenceData(searchTerm) ); // Need to process them into 1 array? With a common label? 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 index fa49a29ad0..54502ebb65 100644 --- 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 @@ -6,6 +6,14 @@ 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 { + isQualitativeEnvironmentTypeDefinition, + isQuantitativeEnvironmentTypeDefinition +} from 'features/surveys/observations/observations-table/grid-column-definitions/GridColumnDefinitionsUtils'; +import { + isEnvironmentQualitativeTypeDefinition, + isEnvironmentQuantitativeTypeDefinition +} from 'features/surveys/observations/utils/type-guard-utils'; import { EnvironmentQualitativeTypeDefinition, EnvironmentQuantitativeTypeDefinition, @@ -81,9 +89,9 @@ export const EnvironmentsSearchAutocomplete = (props: IEnvironmentsSearchAutocom clearOnBlur={true} getOptionLabel={(option) => option.name} isOptionEqualToValue={(option, value) => { - if ('environment_qualitative_id' in option && 'environment_qualitative_id' in value) { + if (isEnvironmentQualitativeTypeDefinition(option) && isEnvironmentQualitativeTypeDefinition(value)) { return option.environment_qualitative_id === value.environment_qualitative_id; - } else if ('environment_quantitative_id' in option && 'environment_quantitative_id' in value) { + } else if (isEnvironmentQuantitativeTypeDefinition(option) && isEnvironmentQuantitativeTypeDefinition(value)) { return option.environment_quantitative_id === value.environment_quantitative_id; } @@ -95,11 +103,11 @@ export const EnvironmentsSearchAutocomplete = (props: IEnvironmentsSearchAutocom } const unselectedOptions = options.filter((option) => { - if ('environment_qualitative_id' in option) { + if (isEnvironmentQualitativeTypeDefinition(option)) { return !selectedOptions.qualitative_environments.some( (item) => item.environment_qualitative_id === option.environment_qualitative_id ); - } else if ('environment_quantitative_id' in option) { + } else if (isEnvironmentQuantitativeTypeDefinition(option)) { return !selectedOptions.quantitative_environments.some( (item) => item.environment_quantitative_id === option.environment_quantitative_id ); @@ -135,8 +143,8 @@ export const EnvironmentsSearchAutocomplete = (props: IEnvironmentsSearchAutocom } onAddEnvironmentColumn({ - qualitative_environments: 'environment_qualitative_id' in value ? [value] : [], - quantitative_environments: 'environment_quantitative_id' in value ? [value] : [] + qualitative_environments: isQualitativeEnvironmentTypeDefinition(value) ? [value] : [], + quantitative_environments: isQuantitativeEnvironmentTypeDefinition(value) ? [value] : [] }); setInputValue(''); setOptions([]); @@ -152,7 +160,7 @@ export const EnvironmentsSearchAutocomplete = (props: IEnvironmentsSearchAutocom }} {...renderProps} key={`environment-item-${ - 'environment_qualitative_id' in renderOption + isQualitativeEnvironmentTypeDefinition(renderOption) ? renderOption.environment_qualitative_id : renderOption.environment_quantitative_id }`} 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 index 5a5a081d73..b7843055fa 100644 --- 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 @@ -12,6 +12,11 @@ import Typography from '@mui/material/Typography'; import ColouredRectangleChip from 'components/chips/ColouredRectangleChip'; import { NoDataOverlay } from 'components/overlay/NoDataOverlay'; import { AccordionStandardCard } from 'features/standards/view/components/AccordionStandardCard'; +import { + isQualitativeMeasurementTypeDefinition, + isQuantitativeMeasurementTypeDefinition +} from 'features/surveys/observations/observations-table/grid-column-definitions/GridColumnDefinitionsUtils'; +import { useFocalOrObservedSpeciesTsns } from 'hooks/useFocalOrObservedTsns'; import { CBMeasurementType } from 'interfaces/useCritterApi.interface'; import { useState } from 'react'; import { MeasurementsSearch } from './search/MeasurementsSearch'; @@ -44,12 +49,13 @@ export interface IConfigureMeasurementColumnsProps { * @param {IConfigureMeasurementColumnsProps} props * @return {*} */ - export const ConfigureMeasurementColumns = (props: IConfigureMeasurementColumnsProps) => { const { measurementColumns, onAddMeasurementColumns, onRemoveMeasurementColumns } = props; const [isFocalSpeciesMeasurementsOnly, setIsFocalSpeciesMeasurementsOnly] = useState(true); + const [observedAndFocalSpeciesTsns, allSpeciesWithParentsTsns] = useFocalOrObservedSpeciesTsns(); + return ( @@ -58,7 +64,8 @@ export const ConfigureMeasurementColumns = (props: IConfigureMeasurementColumnsP onAddMeasurementColumns([measurementColumn])} - focalOrObservedSpeciesOnly={isFocalSpeciesMeasurementsOnly} + tsns={isFocalSpeciesMeasurementsOnly ? observedAndFocalSpeciesTsns : []} + applicableTsns={allSpeciesWithParentsTsns} /> ) : undefined }> - {'options' in measurement && ( + {isQualitativeMeasurementTypeDefinition(measurement) && ( {measurement.options.map((option) => ( void; /** - * Whether to only show measurements that focal or observed species can have + * TSNs to retrieve measurements for, if the search needs to be more specified than all focal or observed species * * @memberof IMeasurementsSearchProps */ - focalOrObservedSpeciesOnly?: boolean; + tsns?: number[]; + /** + * TSNs to retrieve measurements for, if the search needs to be more specified than all focal or observed species + * + * @memberof IMeasurementsSearchProps + */ + applicableTsns?: number[]; } /** @@ -35,61 +39,26 @@ export interface IMeasurementsSearchProps { * @param {IMeasurementsSearchProps} props * @return {*} */ -import React, { useEffect } from 'react'; - -export const MeasurementsSearch: React.FC = (props) => { - const { selectedMeasurements, onAddMeasurementColumn, focalOrObservedSpeciesOnly } = props; +export const MeasurementsSearch = (props: IMeasurementsSearchProps) => { + const { selectedMeasurements, onAddMeasurementColumn, tsns = [], applicableTsns } = props; const critterbaseApi = useCritterbaseApi(); - const surveyContext = useSurveyContext(); - const biohubApi = useBiohubApi(); - - const observedSpeciesDataLoader = useDataLoader(() => - biohubApi.observation.getObservedSpecies(surveyContext.projectId, surveyContext.surveyId) - ); const measurementsDataLoader = useDataLoader((searchTerm: string, tsns?: number[]) => critterbaseApi.xref.getMeasurementTypeDefinitionsBySearchTerm(searchTerm, tsns) ); - const hierarchyDataLoader = useDataLoader((tsns: number[]) => biohubApi.taxonomy.getTaxonHierarchyByTSNs(tsns)); - - useEffect(() => { - if (!observedSpeciesDataLoader.data) { - observedSpeciesDataLoader.load(); - } - }, [observedSpeciesDataLoader]); - - const focalOrObservedSpecies: number[] = [ - ...(surveyContext.surveyDataLoader.data?.surveyData.species.focal_species.map((species) => species.tsn) ?? []), - ...(observedSpeciesDataLoader.data?.map((species) => species.tsn) ?? []) - ]; - - useEffect(() => { - if (focalOrObservedSpecies.length) { - hierarchyDataLoader.load(focalOrObservedSpecies); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [hierarchyDataLoader]); - + // No longer need to fetch observed species or focal species const getOptions = async (inputValue: string): Promise => { - const response = focalOrObservedSpeciesOnly - ? await measurementsDataLoader.refresh(inputValue, focalOrObservedSpecies) - : await measurementsDataLoader.refresh(inputValue); - + const response = await measurementsDataLoader.refresh(inputValue, tsns); return response ? [...response.qualitative, ...response.quantitative] : []; }; - const focalOrObservedSpeciesTsns = [ - ...focalOrObservedSpecies, - ...(hierarchyDataLoader.data?.flatMap((taxon) => taxon.hierarchy) ?? []) - ]; - return ( } - applicableTsns={focalOrObservedSpeciesTsns} + applicableTsns={applicableTsns} getOptions={getOptions} onAddMeasurementColumn={onAddMeasurementColumn} /> 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 77032e6ee5..cc289dee2a 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 @@ -21,7 +21,7 @@ import { } from 'interfaces/useReferenceApi.interface'; type IObservationSubcountSignOption = { - observation_subcount_sign_id: number; + observation_sign_id: number; name: string; }; @@ -34,7 +34,7 @@ export const TaxonomyColDef = (props: { field: 'itis_tsn', headerName: 'Species', description: 'The observed species, or if the species is unknown, a higher taxon', - editable: true, + editable: false, hideable: true, flex: 1, minWidth: 200, @@ -63,7 +63,7 @@ export const SampleSiteColDef = (props: { field: 'survey_sample_site_id', description: 'The sampling site where the observation was made', headerName: 'Site', - editable: true, + editable: false, hideable: true, flex: 1, minWidth: 180, @@ -101,7 +101,7 @@ export const MethodTechniqueColDef = (props: { field: 'method_technique_id', headerName: 'Technique', description: 'The technique with which the observation was made', - editable: true, + editable: false, hideable: true, flex: 1, minWidth: 180, @@ -139,7 +139,7 @@ export const SamplePeriodColDef = (props: { field: 'survey_sample_period_id', headerName: 'Period', description: 'The sampling period in which the observation was made', - editable: true, + editable: false, hideable: true, flex: 1, minWidth: 180, @@ -167,17 +167,17 @@ export const SamplePeriodColDef = (props: { }; }; -export const ObservationCountColDef = (props: { +export const ObservationSubcountColDef = (props: { samplingInformationCache: SamplingInformationCache; hasError: (params: GridCellParams) => boolean; }): GridColDef => { const { samplingInformationCache, hasError } = props; return { - field: 'count', + field: 'subcount', headerName: 'Count', description: 'The number of individuals observed', - editable: true, + editable: false, hideable: true, type: 'number', minWidth: 110, @@ -201,22 +201,22 @@ export const ObservationCountColDef = (props: { }; }; -export const ObservationSubcountSignColDef = (props: { - observationSubcountSignOptions: IObservationSubcountSignOption[]; +export const ObservationSignColDef = (props: { + observationSignOptions: IObservationSubcountSignOption[]; hasError: (params: GridCellParams) => boolean; }): GridColDef => { - const { observationSubcountSignOptions, hasError } = props; + const { observationSignOptions, hasError } = props; - const signOptions = observationSubcountSignOptions.map((item) => ({ + const signOptions = observationSignOptions.map((item) => ({ label: item.name, - value: item.observation_subcount_sign_id + value: item.observation_sign_id })); return { - field: 'observation_subcount_sign_id', + field: 'observation_sign_id', headerName: 'Sign', description: 'The sign of the observation', - editable: true, + editable: false, hideable: true, minWidth: 140, disableColumnMenu: true, @@ -240,7 +240,7 @@ export const ObservationQuantitativeMeasurementColDef = (props: { field: measurement.taxon_measurement_id, headerName: measurement.measurement_name, description: measurement.measurement_desc ?? '', - editable: true, + editable: false, hideable: true, sortable: false, type: 'number', @@ -296,7 +296,7 @@ export const ObservationQualitativeMeasurementColDef = (props: { field: measurement.taxon_measurement_id, headerName: measurement.measurement_name, description: measurement.measurement_desc ?? '', - editable: true, + editable: false, hideable: true, sortable: false, flex: 1, @@ -326,7 +326,7 @@ export const ObservationQuantitativeEnvironmentColDef = (props: { field: String(environment.environment_quantitative_id), headerName: environment.name, description: environment.description ?? '', - editable: true, + editable: false, hideable: true, sortable: false, type: 'number', @@ -381,7 +381,7 @@ export const ObservationQualitativeEnvironmentColDef = (props: { field: String(environment.environment_qualitative_id), headerName: environment.name, description: environment.description ?? '', - editable: true, + editable: false, hideable: true, sortable: false, flex: 1, diff --git a/app/src/features/surveys/observations/observations-table/grid-column-definitions/comment/ObservationSubcountCommentDialog.tsx b/app/src/features/surveys/observations/observations-table/grid-column-definitions/comment/ObservationSubcountCommentDialog.tsx index b259bf1aba..37bb257cfd 100644 --- a/app/src/features/surveys/observations/observations-table/grid-column-definitions/comment/ObservationSubcountCommentDialog.tsx +++ b/app/src/features/surveys/observations/observations-table/grid-column-definitions/comment/ObservationSubcountCommentDialog.tsx @@ -1,11 +1,8 @@ -import LoadingButton from '@mui/lab/LoadingButton'; 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 TextField from '@mui/material/TextField'; -import { useState } from 'react'; interface IObservationSubcountCommentDialogProps { /** @@ -15,74 +12,46 @@ interface IObservationSubcountCommentDialogProps { */ initialValue?: string; /** - * The open state of the dialog. + * Set to 'true' to open the dialog. * * @type {boolean} */ open: boolean; /** - * Callback to close the dialog. + * Callback fired when the close button is clicked. * - * @type {(value?: string) => void} + * @type {() => void} */ handleClose: () => void; - /** - * Callback to save the comment. - * - * @type {(value?: string) => void} - */ - handleSave: (value?: string) => void; } /** - * Dialog for adding comments to an observation. + * Dialog for displaying comments of an observation. * * @param {IObservationSubcountCommentDialogProps} props * @returns {*} {JSX.Element} */ export const ObservationSubcountCommentDialog = (props: IObservationSubcountCommentDialogProps) => { - // Hold the intial value in state so that we can reset the comment if the user cancels - const [comment, setComment] = useState(props.initialValue); + const { initialValue, open, handleClose } = props; return ( - Add Comment - - setComment(event.target.value)} - /> - + Comment + {initialValue} - { - // Close the dialog and save the comment - props.handleClose(); - props.handleSave(comment); - }} - color="primary" - variant="contained"> - Save & close - diff --git a/app/src/features/surveys/observations/observations-table/grid-column-definitions/sampling-information/useSamplingInformationCache.tsx b/app/src/features/surveys/observations/observations-table/grid-column-definitions/sampling-information/useSamplingInformationCache.tsx index 9962ffad72..7ecd32f251 100644 --- a/app/src/features/surveys/observations/observations-table/grid-column-definitions/sampling-information/useSamplingInformationCache.tsx +++ b/app/src/features/surveys/observations/observations-table/grid-column-definitions/sampling-information/useSamplingInformationCache.tsx @@ -4,6 +4,10 @@ import { GetSamplingPeriod } from 'interfaces/useSamplingPeriodApi.interface'; import { MutableRefObject, useRef } from 'react'; import { getDateTimeLabel } from 'utils/datetime'; +// TODO: Update to use the logic from `.../observations/form/components/sampling/hooks/useSamplingInformationCache.tsx` +// It has improved logic and is more up-to-date. Both caches could probably be merged, with the context specific +// functions being moved to separate utils file or something similar. + export type SamplingInformationCachedSite = IAutocompleteDataGridOption & { survey_sample_site_id: number; }; diff --git a/app/src/features/surveys/observations/utils/type-guard-utils.ts b/app/src/features/surveys/observations/utils/type-guard-utils.ts new file mode 100644 index 0000000000..654648bda5 --- /dev/null +++ b/app/src/features/surveys/observations/utils/type-guard-utils.ts @@ -0,0 +1,94 @@ +import { + CBQualitativeMeasurementTypeDefinition, + CBQuantitativeMeasurementTypeDefinition +} from 'interfaces/useCritterApi.interface'; +import { + SubcountQualitativeMeasurement, + SubcountQuantitativeMeasurement +} from 'interfaces/useObservationApi.interface'; +import { + EnvironmentQualitativeTypeDefinition, + EnvironmentQuantitativeTypeDefinition +} from 'interfaces/useReferenceApi.interface'; + +/** + * Type guard to check if a given item is a `SubcountQuantitativeMeasurement`. + * + * @export + * @param {*} item + * @return {*} {item is SubcountQuantitativeMeasurement} + */ +export function isSubcountQuantitativeMeasurement(item: any): item is SubcountQuantitativeMeasurement { + if (!item) { + return false; + } + + return 'measurement_value' in item && 'measurement_id' in item; +} + +/** + * Type guard to check if a given item is a `SubcountQualitativeMeasurement`. + * + * @export + * @param {*} item + * @return {*} {item is SubcountQualitativeMeasurement} + */ +export function isSubcountQualitativeMeasurement(item: any): item is SubcountQualitativeMeasurement { + if (!item) { + return false; + } + + return 'measurement_option_id' in item && 'measurement_id' in item; +} + +/** + * Type guard to check if a given item is a `CBQualitativeMeasurementTypeDefinition`. + * + * @export + * @param {(CBQuantitativeMeasurementTypeDefinition | CBQualitativeMeasurementTypeDefinition)} item + * @return {*} {item is CBQualitativeMeasurementTypeDefinition} + */ +export function isCBQualitativeMeasurementTypeDefinition( + item: CBQualitativeMeasurementTypeDefinition | CBQuantitativeMeasurementTypeDefinition +): item is CBQualitativeMeasurementTypeDefinition { + return 'options' in item && 'taxon_measurement_id' in item; +} + +/** + * Type guard to check if a given item is a `CBQuantitativeMeasurementTypeDefinition`. + * + * @export + * @param {(CBQuantitativeMeasurementTypeDefinition | CBQualitativeMeasurementTypeDefinition)} item + * @return {*} {item is CBQuantitativeMeasurementTypeDefinition} + */ +export function isCBQuantitativeMeasurementTypeDefinition( + item: CBQualitativeMeasurementTypeDefinition | CBQuantitativeMeasurementTypeDefinition +): item is CBQuantitativeMeasurementTypeDefinition { + return 'unit' in item && 'taxon_measurement_id' in item; +} + +/** + * Type guard to check if a given item is a `EnvironmentQualitativeTypeDefinition`. + * + * @export + * @param {(CBQuantitativeMeasurementTypeDefinition | CBQualitativeMeasurementTypeDefinition)} item + * @return {*} {item is EnvironmentQualitativeTypeDefinition} + */ +export function isEnvironmentQualitativeTypeDefinition( + item: EnvironmentQualitativeTypeDefinition | EnvironmentQuantitativeTypeDefinition +): item is EnvironmentQualitativeTypeDefinition { + return 'options' in item && 'environment_qualitative_id' in item; +} + +/** + * Type guard to check if a given item is a `EnvironmentQuantitativeTypeDefinition`. + * + * @export + * @param {(CBQuantitativeMeasurementTypeDefinition | CBQualitativeMeasurementTypeDefinition)} item + * @return {*} {item is EnvironmentQuantitativeTypeDefinition} + */ +export function isEnvironmentQuantitativeTypeDefinition( + item: EnvironmentQualitativeTypeDefinition | EnvironmentQuantitativeTypeDefinition +): item is EnvironmentQuantitativeTypeDefinition { + return 'unit' in item && 'environment_quantitative_id' in item; +} diff --git a/app/src/features/surveys/sampling-information/techniques/components/vantages/TechniqueVantagesForm.tsx b/app/src/features/surveys/sampling-information/techniques/components/vantages/TechniqueVantagesForm.tsx index 61b4b9c5c5..add00aa6a1 100644 --- a/app/src/features/surveys/sampling-information/techniques/components/vantages/TechniqueVantagesForm.tsx +++ b/app/src/features/surveys/sampling-information/techniques/components/vantages/TechniqueVantagesForm.tsx @@ -3,7 +3,7 @@ import { Icon } from '@mdi/react'; import Box from '@mui/material/Box'; import Button from '@mui/material/Button'; import Collapse from '@mui/material/Collapse'; -import { DualAutocompleteField } from 'components/fields/DualAutocompleteField'; +import { DualAutocompleteField } from 'components/fields/dual-autocomplete-field/DualAutocompleteField'; import { FieldArray, FieldArrayRenderProps, useFormikContext } from 'formik'; import { GetVantageReferenceRecord } from 'interfaces/useReferenceApi.interface'; import { TransitionGroup } from 'react-transition-group'; @@ -51,6 +51,12 @@ export const TechniqueVantageForm = ({ + ...record, + value: record.vantage_category_id, + label: record.name + })); + return ( ({ - value: record.vantage_category_id, - label: record.name - }))} - getUnitOptions={(categoryId: number) => getUnitOptions(vantage.vantage_method_id, categoryId)} - formikCategoryFieldName={`vantage_methods.[${index}].vantage_category_id`} - formikUnitFieldName={`vantage_methods.[${index}].vantage_method_id`} + categoryFormikFieldName={`vantage_methods.[${index}].vantage_category_id`} + categoryFieldLabel="Vantage" + categoryOptions={vantageCategoryOptions} + getCategoryDataType={() => 'qualitative'} + getUnitFormikFieldName={() => `vantage_methods.[${index}].vantage_method_id`} + getUnitFieldLabel={() => 'Value'} + getUnitOptions={(categoryValue) => + getUnitOptions(vantage.vantage_method_id, categoryValue as number) + } onDelete={() => arrayHelpers.remove(index)} /> diff --git a/app/src/features/surveys/view/survey-spatial/SurveySpatialContainer.tsx b/app/src/features/surveys/view/survey-spatial/SurveySpatialContainer.tsx index 6f4728e5c8..a5b7661d50 100644 --- a/app/src/features/surveys/view/survey-spatial/SurveySpatialContainer.tsx +++ b/app/src/features/surveys/view/survey-spatial/SurveySpatialContainer.tsx @@ -29,7 +29,7 @@ export const SurveySpatialContainer = (): JSX.Element => { const biohubApi = useBiohubApi(); const observationsDataLoader = useDataLoader((pagination?: ApiPaginationRequestOptions) => - biohubApi.observation.getObservationRecords(surveyContext.projectId, surveyContext.surveyId, pagination) + biohubApi.observation.getFlattenedObservationRecords(surveyContext.projectId, surveyContext.surveyId, pagination) ); const [activeView, setActiveView] = useState(SurveySpatialDatasetViewEnum.OBSERVATIONS); diff --git a/app/src/features/surveys/view/survey-spatial/components/observation/SurveySpatialObservationDeployment.tsx b/app/src/features/surveys/view/survey-spatial/components/observation/SurveySpatialObservationDeployment.tsx index cd741d19b3..654acb73e8 100644 --- a/app/src/features/surveys/view/survey-spatial/components/observation/SurveySpatialObservationDeployment.tsx +++ b/app/src/features/surveys/view/survey-spatial/components/observation/SurveySpatialObservationDeployment.tsx @@ -31,6 +31,8 @@ interface IObservationTableRow { /** * Component to display observation data in a table with server-side pagination and sorting. * + * TODO: Deprecated? This is not used anywhere in the codebase. + * * @returns {*} */ export const SurveySpatialObservationDeployment = () => { diff --git a/app/src/features/surveys/view/survey-spatial/components/observation/SurveySpatialObservationTable.tsx b/app/src/features/surveys/view/survey-spatial/components/observation/SurveySpatialObservationTable.tsx index b4bb0feeb9..065eeffe32 100644 --- a/app/src/features/surveys/view/survey-spatial/components/observation/SurveySpatialObservationTable.tsx +++ b/app/src/features/surveys/view/survey-spatial/components/observation/SurveySpatialObservationTable.tsx @@ -1,5 +1,5 @@ import { mdiArrowTopRight } from '@mdi/js'; -import { GridColDef, GridSortModel } from '@mui/x-data-grid'; +import { GridColDef, GridSortModel, GridValidRowModel } from '@mui/x-data-grid'; import { StyledDataGrid } from 'components/data-grid/StyledDataGrid'; import { LoadingGuard } from 'components/loading/LoadingGuard'; import { SkeletonTable } from 'components/loading/SkeletonLoaders'; @@ -14,11 +14,11 @@ import { useContext, useEffect, useState } from 'react'; // Set height so the skeleton loader will match table rows const rowHeight = 52; -interface IObservationTableRow { - survey_observation_id: number; +interface IFlattenedObservationTableRow extends GridValidRowModel { + observation_subcount_id: number; itis_tsn: number | null; itis_scientific_name: string | null; - count: number | null; + subcount: number | null; survey_sample_site_name: string | null; method_technique_name: string | null; survey_sample_period_start_datetime: string | null; @@ -44,7 +44,7 @@ export const SurveySpatialObservationTable = () => { const [sortModel, setSortModel] = useState([]); const paginatedDataLoader = useDataLoader((page: number, limit: number, sort?: string, order?: 'asc' | 'desc') => - biohubApi.observation.getObservationRecords(surveyContext.projectId, surveyContext.surveyId, { + biohubApi.observation.getFlattenedObservationRecords(surveyContext.projectId, surveyContext.surveyId, { page: page + 1, // This fixes an off-by-one error between the front end and the back end limit, sort, @@ -64,14 +64,14 @@ export const SurveySpatialObservationTable = () => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [page, pageSize, sortModel]); - const rows = + const rows: IFlattenedObservationTableRow[] = paginatedDataLoader.data?.surveyObservations.map((item) => { return { - survey_observation_id: item.survey_observation_id, + observation_subcount_id: item.subcount.observation_subcount_id, itis_tsn: item.itis_tsn, itis_scientific_name: (item.itis_tsn && taxonomyContext.getCachedSpeciesTaxonomyById(item.itis_tsn)?.scientificName) || null, - count: item.count, + subcount: item.subcount.subcount, survey_sample_site_name: item.survey_sample_site_name, method_technique_name: item.method_technique_name, survey_sample_period_start_datetime: item.survey_sample_period_start_datetime, @@ -85,7 +85,7 @@ export const SurveySpatialObservationTable = () => { const rowCount = paginatedDataLoader.data?.pagination.total ?? 0; // Define table columns - const columns: GridColDef[] = [ + const columns: GridColDef[] = [ { field: 'itis_scientific_name', headerName: 'Species', @@ -112,7 +112,7 @@ export const SurveySpatialObservationTable = () => { minWidth: 200 }, { - field: 'count', + field: 'subcount', headerName: 'Count', headerAlign: 'right', align: 'right', @@ -148,7 +148,7 @@ export const SurveySpatialObservationTable = () => { return ( } isLoadingFallbackDelay={100} hasNoData={!rows.length} @@ -161,7 +161,7 @@ export const SurveySpatialObservationTable = () => { /> } hasNoDataFallbackDelay={100}> - noRowsMessage="No observation records found" // columns columns={columns} @@ -171,7 +171,7 @@ export const SurveySpatialObservationTable = () => { rowCount={rowCount} rowHeight={rowHeight} rowSelection={false} - getRowId={(row) => row.survey_observation_id} + getRowId={(row: IFlattenedObservationTableRow) => row.observation_subcount_id} autoHeight={false} // pagination paginationMode="server" diff --git a/app/src/hooks/api/useObservationApi.test.ts b/app/src/hooks/api/useObservationApi.test.ts index 35f8a6226f..9253f2a6a6 100644 --- a/app/src/hooks/api/useObservationApi.test.ts +++ b/app/src/hooks/api/useObservationApi.test.ts @@ -30,6 +30,9 @@ describe('useObservationApi', () => { survey_sample_site_name: 'site name', method_technique_name: 'method name', survey_sample_period_start_datetime: '2021-01-01 12:00:00', + observation_sign_id: 1, + qualitative_environments: [], + quantitative_environments: [], subcounts: [] } ], diff --git a/app/src/hooks/api/useObservationApi.ts b/app/src/hooks/api/useObservationApi.ts index 2592daaf35..34a787cff4 100644 --- a/app/src/hooks/api/useObservationApi.ts +++ b/app/src/hooks/api/useObservationApi.ts @@ -5,43 +5,17 @@ import { CBQuantitativeMeasurementTypeDefinition } from 'interfaces/useCritterApi.interface'; import { + ICreateEditObservation, + ICreateObservation, + IGetSurveyFlattenedObservationsResponse, IGetSurveyObservationsGeometryResponse, IGetSurveyObservationsResponse, - ObservationRecord, - StandardObservationColumns + ObservationRecord } from 'interfaces/useObservationApi.interface'; import { EnvironmentTypeIds } from 'interfaces/useReferenceApi.interface'; import { IPartialTaxonomy } from 'interfaces/useTaxonomyApi.interface'; -import qs from 'qs'; import { ApiPaginationRequestOptions } from 'types/misc'; -export interface SubcountToSave { - observation_subcount_id: number | null; - subcount: number | null; - comment: string | null; - qualitative_measurements: { - measurement_id: string; - measurement_option_id: string; - }[]; - 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 { - standardColumns: StandardObservationColumns; - subcounts: SubcountToSave[]; -} - /** * Returns a set of supported api methods for working with observations. * @@ -54,13 +28,13 @@ const useObservationApi = (axios: AxiosInstance) => { * * @param {number} projectId * @param {number} surveyId - * @param {IObservationTableRowToSave[]} surveyObservations + * @param {ICreateEditObservation[]} surveyObservations * @return {*} {Promise} */ const insertUpdateObservationRecords = async ( projectId: number, surveyId: number, - surveyObservations: IObservationTableRowToSave[] + surveyObservations: ICreateEditObservation[] ): Promise => { await axios.put(`/api/project/${projectId}/survey/${surveyId}/observations`, { surveyObservations @@ -68,11 +42,27 @@ const useObservationApi = (axios: AxiosInstance) => { }; /** - * Get observations for a system user id. + * Creates a new observation for the survey + * + * @param {number} projectId + * @param {number} surveyId + * @param {ICreateObservation} surveyObservation + * @return {*} {Promise} + */ + const createObservation = async ( + projectId: number, + surveyId: number, + surveyObservation: ICreateObservation + ): Promise => { + await axios.post(`/api/project/${projectId}/survey/${surveyId}/observations`, surveyObservation); + }; + + /** + * Find observations. * * @param {ApiPaginationRequestOptions} [pagination] * @param {IObservationsAdvancedFilters} filterFieldData - * @return {*} {Promise} + * @return {*} {Promise} */ const findObservations = async ( pagination?: ApiPaginationRequestOptions, @@ -84,8 +74,30 @@ const useObservationApi = (axios: AxiosInstance) => { }; const { data } = await axios.get('/api/observation', { - params, - paramsSerializer: (params) => qs.stringify(params) + params + }); + + return data; + }; + + /** + * Find flattened observations. + * + * @param {ApiPaginationRequestOptions} [pagination] + * @param {IObservationsAdvancedFilters} filterFieldData + * @return {*} {Promise} + */ + const findFlattenedObservations = async ( + pagination?: ApiPaginationRequestOptions, + filterFieldData?: IObservationsAdvancedFilters + ): Promise => { + const params = { + ...pagination, + ...filterFieldData + }; + + const { data } = await axios.get('/api/observation/flattened', { + params }); return data; @@ -118,6 +130,33 @@ const useObservationApi = (axios: AxiosInstance) => { return data; }; + /** + * Retrieves all survey flattened observation records for the given survey + * + * @param {number} projectId + * @param {number} surveyId + * @param {ApiPaginationRequestOptions} [pagination] + * @return {*} {Promise} + */ + const getFlattenedObservationRecords = async ( + projectId: number, + surveyId: number, + pagination?: ApiPaginationRequestOptions + ): Promise => { + const params = { + ...pagination + }; + + const { data } = await axios.get( + `/api/project/${projectId}/survey/${surveyId}/observations/flattened`, + { + params + } + ); + + return data; + }; + /** * Retrieves species observed in a given survey * @@ -323,16 +362,19 @@ const useObservationApi = (axios: AxiosInstance) => { return { insertUpdateObservationRecords, getObservationRecords, + getFlattenedObservationRecords, getObservationRecord, getObservedSpecies, findObservations, + findFlattenedObservations, getObservationsGeometry, getObservationMeasurementDefinitions, deleteObservationRecords, deleteObservationMeasurements, deleteObservationEnvironments, uploadCsvForImport, - processCsvSubmission + processCsvSubmission, + createObservation }; }; diff --git a/app/src/hooks/api/useReferenceApi.ts b/app/src/hooks/api/useReferenceApi.ts index 6967d8a093..72b66d8863 100644 --- a/app/src/hooks/api/useReferenceApi.ts +++ b/app/src/hooks/api/useReferenceApi.ts @@ -13,12 +13,12 @@ import { */ const useReferenceApi = (axios: AxiosInstance) => { /** - * Finds subcount environments by search term. + * Finds environment reference data by search term. * * @param {string} searchTerm * @return {*} {Promise} */ - const findSubcountEnvironments = async (searchTerm: string): Promise => { + const findEnvironmentReferenceData = async (searchTerm: string): Promise => { const { data } = await axios.get(`/api/reference/search/environment?searchTerm=${searchTerm}`); return data; @@ -53,7 +53,7 @@ const useReferenceApi = (axios: AxiosInstance) => { }; return { - findSubcountEnvironments, + findEnvironmentReferenceData, getTechniqueAttributes, getVantageReferenceRecords }; diff --git a/app/src/hooks/api/useTaxonomyApi.ts b/app/src/hooks/api/useTaxonomyApi.ts index 23635a87e1..8afc2c24cc 100644 --- a/app/src/hooks/api/useTaxonomyApi.ts +++ b/app/src/hooks/api/useTaxonomyApi.ts @@ -47,7 +47,6 @@ const useTaxonomyApi = () => { return qs.stringify(params); } }); - return data; }; diff --git a/app/src/hooks/useFocalOrObservedTsns.tsx b/app/src/hooks/useFocalOrObservedTsns.tsx new file mode 100644 index 0000000000..e8875a0a04 --- /dev/null +++ b/app/src/hooks/useFocalOrObservedTsns.tsx @@ -0,0 +1,60 @@ +import { useEffect, useMemo } from 'react'; +import { useBiohubApi } from './useBioHubApi'; +import { useSurveyContext } from './useContext'; +import useDataLoader from './useDataLoader'; + +/** + * Hook to get combined TSNs of focal or observed species, + * and their parent taxa TSNs. + * + * @returns An array: + * - First element: Array of TSNs for focal and observed species. + * - Second element: Array of TSNs including focal/observed species and their parent taxa. + */ +export const useFocalOrObservedSpeciesTsns = () => { + const surveyContext = useSurveyContext(); + const biohubApi = useBiohubApi(); + + const observedSpeciesDataLoader = useDataLoader(() => + biohubApi.observation.getObservedSpecies(surveyContext.projectId, surveyContext.surveyId) + ); + + const hierarchyDataLoader = useDataLoader((tsns: number[]) => biohubApi.taxonomy.getTaxonHierarchyByTSNs(tsns)); + + // Load observed species when component mounts + useEffect(() => { + observedSpeciesDataLoader.load(); + }, [observedSpeciesDataLoader]); + + // Combine focal species and observed species TSNs into a single array + const focalSpeciesTsns = useMemo( + () => surveyContext.surveyDataLoader.data?.surveyData.species.focal_species.map((species) => species.tsn) ?? [], + [surveyContext.surveyDataLoader.data?.surveyData.species.focal_species] + ); + + const observedSpeciesTsns = useMemo( + () => observedSpeciesDataLoader.data?.map((species) => species.tsn) ?? [], + [observedSpeciesDataLoader.data] + ); + + const observedAndFocalSpeciesTsns = useMemo( + () => [...focalSpeciesTsns, ...observedSpeciesTsns], + [focalSpeciesTsns, observedSpeciesTsns] + ); + + // Fetch parent taxa hierarchy for combined species TSNs + useEffect(() => { + if (observedAndFocalSpeciesTsns.length && observedSpeciesDataLoader.data) { + hierarchyDataLoader.load(observedAndFocalSpeciesTsns); + } + }, [observedAndFocalSpeciesTsns, hierarchyDataLoader, observedSpeciesDataLoader]); + + // Combine TSNs of focal/observed species with their parent taxa + const allSpeciesWithParentsTsns = [ + ...observedAndFocalSpeciesTsns, + ...(hierarchyDataLoader.data?.flatMap((taxon) => taxon.hierarchy) ?? []) + ]; + + // Return both observed/focal species TSNs and all species with parent taxa TSNs + return [observedAndFocalSpeciesTsns, allSpeciesWithParentsTsns]; +}; diff --git a/app/src/interfaces/misc/formik.interface.ts b/app/src/interfaces/misc/formik.interface.ts new file mode 100644 index 0000000000..b7665a0109 --- /dev/null +++ b/app/src/interfaces/misc/formik.interface.ts @@ -0,0 +1 @@ +export type FormikKeyUuid = { _id?: string }; diff --git a/app/src/interfaces/observation/environment.interface.ts b/app/src/interfaces/observation/environment.interface.ts new file mode 100644 index 0000000000..71f32841b3 --- /dev/null +++ b/app/src/interfaces/observation/environment.interface.ts @@ -0,0 +1,36 @@ +export type ObservationEnvironmentQualitativeObject = { + /** + * The primary ID for the record. + * May be undefined if the record has not been saved to the database. + */ + observation_environment_qualitative_id?: number; + /** + * The ID of the environment type definition record. + */ + environment_qualitative_id: string; + /** + * The ID of the selected environment type option definition record. + */ + environment_qualitative_option_id: string; +}; + +export type ObservationEnvironmentQuantitativeObject = { + /** + * The primary ID for the record. + * May be undefined if the record has not been saved to the database. + */ + observation_environment_quantitative_id?: number; + /** + * The ID of the environment type definition record. + */ + environment_quantitative_id: string; + /** + * The numeric value selected. + */ + value: number; +}; + +export type ObservationEnvironmentData = { + qualitative_environments: ObservationEnvironmentQualitativeObject[]; + quantitative_environments: ObservationEnvironmentQuantitativeObject[]; +}; diff --git a/app/src/interfaces/observation/observation.interface.ts b/app/src/interfaces/observation/observation.interface.ts new file mode 100644 index 0000000000..5eacc8f61c --- /dev/null +++ b/app/src/interfaces/observation/observation.interface.ts @@ -0,0 +1,119 @@ +import { + ObservationEnvironmentData, + ObservationEnvironmentQualitativeObject, + ObservationEnvironmentQuantitativeObject +} from 'interfaces/observation/environment.interface'; +import { + CBQualitativeMeasurementTypeDefinition, + CBQuantitativeMeasurementTypeDefinition +} from 'interfaces/useCritterApi.interface'; +import { + ObservationSubcountObject, + SubcountObservationColumns, + SubcountQualitativeMeasurement, + SubcountQuantitativeMeasurement, + SubcountToSave +} from 'interfaces/useObservationApi.interface'; +import { + EnvironmentQualitativeTypeDefinition, + EnvironmentQuantitativeTypeDefinition +} from 'interfaces/useReferenceApi.interface'; +import { GetSamplingPeriod } from 'interfaces/useSamplingPeriodApi.interface'; +import { ApiPaginationResponseParams } from 'types/misc'; + +export interface IGetSurveyObservationsResponse { + surveyObservations: ObservationRecordWithSamplingAndSubcountData[]; + supplementaryObservationData: SupplementaryObservationData; + pagination: ApiPaginationResponseParams; +} + +export interface IGetSurveyFlattenedObservationsResponse { + surveyObservations: FlattenedObservationRecordWithSamplingAndSubcountData[]; + supplementaryObservationData: SupplementaryObservationData; + pagination: ApiPaginationResponseParams; +} + +export interface IGetSurveyObservationsGeometryObject { + survey_observation_id: number; + geometry: GeoJSON.Point; +} + +export interface IGetSurveyObservationsGeometryResponse { + surveyObservationsGeometry: IGetSurveyObservationsGeometryObject[]; + supplementaryObservationData: SupplementaryObservationCountData; +} + +export type ObservationSamplingData = { + survey_sample_site_name: string | null; + method_technique_name: string | null; + 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_period_id: number | null; + count: number | null; + observation_date: string | null; + observation_time: string | null; + latitude: number | null; + longitude: number | null; + observation_sign_id: number | null; +}; + +export type ObservationRecord = StandardObservationColumns & ObservationEnvironmentData & SubcountObservationColumns; + +export type SupplementaryObservationCountData = { + observationCount: number; +}; + +export type ObservationSamplingSupplementaryData = { + sampling_data: GetSamplingPeriod[]; +}; + +export type SupplementaryObservationMeasurementData = { + qualitative_measurements: CBQualitativeMeasurementTypeDefinition[]; + quantitative_measurements: CBQuantitativeMeasurementTypeDefinition[]; + qualitative_environments: EnvironmentQualitativeTypeDefinition[]; + quantitative_environments: EnvironmentQuantitativeTypeDefinition[]; +}; + +export type SupplementaryObservationData = SupplementaryObservationCountData & + SupplementaryObservationMeasurementData & + ObservationSamplingSupplementaryData; + +type ObservationRecordWithSamplingAndSubcountData = StandardObservationColumns & + ObservationEnvironmentData & + ObservationSamplingData & { subcounts: ObservationSubcountObject[] }; + +type FlattenedObservationRecordWithSamplingAndSubcountData = StandardObservationColumns & + ObservationEnvironmentData & + ObservationSamplingData & { subcount: ObservationSubcountObject }; + +export interface ICreateEditObservation { + standardColumns: StandardObservationColumns & ObservationEnvironmentData; + subcounts: SubcountToSave[]; +} + +export interface ICreateObservation { + standardColumns: { + itis_tsn: number | null; + itis_scientific_name: string | null; + survey_sample_period_id: number | null; + count: number | null; + observation_date: string | null; + observation_time: string | null; + latitude: number | null; + longitude: number | null; + observation_sign_id: number | null; + qualitative_environments: ObservationEnvironmentQualitativeObject[]; + quantitative_environments: ObservationEnvironmentQuantitativeObject[]; + }; + subcounts: { + subcount: number | null; + qualitative_measurements: SubcountQualitativeMeasurement[]; + quantitative_measurements: SubcountQuantitativeMeasurement[]; + comment: string | null; + }[]; +} diff --git a/app/src/interfaces/observation/subcount.interface.ts b/app/src/interfaces/observation/subcount.interface.ts new file mode 100644 index 0000000000..9a52fddc89 --- /dev/null +++ b/app/src/interfaces/observation/subcount.interface.ts @@ -0,0 +1,88 @@ +import { FormikKeyUuid } from 'interfaces/misc/formik.interface'; + +export type ObservationSubcountRecord = { + observation_subcount_id: number; + survey_observation_id: number; + comment: string; + subcount: number | null; + create_date: string; + create_user: number; + update_date: string | null; + update_user: number | null; + revision_count: number; +}; + +export type ObservationSubcountObject = { + observation_subcount_id: ObservationSubcountRecord['observation_subcount_id']; + comment: ObservationSubcountRecord['comment']; + subcount: ObservationSubcountRecord['subcount']; + qualitative_measurements: ObservationSubcountQualitativeMeasurementObject[]; + quantitative_measurements: ObservationSubcountQuantitativeMeasurementObject[]; +}; + +export type SubcountObservationColumns = { + observation_subcount_id: number | null; + comment: string | null; + subcount: number | null; + qualitative_measurements: { + critterbase_taxon_measurement_id: string; + critterbase_measurement_qualitative_option_id: string; + }[]; + quantitative_measurements: { + critterbase_taxon_measurement_id: string; + value: number; + }[]; + [key: string]: any; +}; + +export interface SubcountToSave { + observation_subcount_id: number | null; + subcount: number | null; + comment: string | null; + qualitative_measurements: SubcountQualitativeMeasurement[]; + quantitative_measurements: SubcountQuantitativeMeasurement[]; +} + +export interface SubcountQualitativeMeasurement { + measurement_id: string; + measurement_option_id: string; +} + +export interface SubcountQuantitativeMeasurement { + measurement_id: string; + measurement_value: number; +} + +export type ObservationSubcountQualitativeMeasurementObject = Pick< + ObservationSubCountQualitativeMeasurementRecord, + 'critterbase_taxon_measurement_id' | 'critterbase_measurement_qualitative_option_id' +> & + FormikKeyUuid; + +export type ObservationSubcountQuantitativeMeasurementObject = Pick< + ObservationSubCountQuantitativeMeasurementRecord, + 'critterbase_taxon_measurement_id' | 'value' +> & + FormikKeyUuid; + +export type ObservationSubCountQualitativeMeasurementRecord = { + observation_subcount_id: number; + critterbase_taxon_measurement_id: string; + critterbase_measurement_qualitative_option_id: string; + create_date: string; + create_user: number; + update_date: string | null; + update_user: number | null; + revision_count: number; +}; + +export type ObservationSubCountQuantitativeMeasurementRecord = { + observation_subcount_id: number; + critterbase_taxon_measurement_id: string; + value: number; + create_date: string; + create_user: number; + update_date: string | null; + update_user: number | null; + revision_count: number; +}; diff --git a/app/src/interfaces/reference/environment.interface.ts b/app/src/interfaces/reference/environment.interface.ts new file mode 100644 index 0000000000..b4ab9f522c --- /dev/null +++ b/app/src/interfaces/reference/environment.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/app/src/interfaces/reference/technique.interface.ts b/app/src/interfaces/reference/technique.interface.ts new file mode 100644 index 0000000000..59ef9cba44 --- /dev/null +++ b/app/src/interfaces/reference/technique.interface.ts @@ -0,0 +1,39 @@ +/** + * Technique quantitative attributes + */ +export interface ITechniqueAttributeQuantitative { + method_lookup_attribute_quantitative_id: string; + name: string; + description: string | null; + unit: string | null; + min: number | null; + max: number | null; +} + +/** + * Technique qualitative attributes + */ +export interface ITechniqueAttributeQualitativeOption { + method_lookup_attribute_qualitative_option_id: string; + name: string; + description: string | null; +} + +/** + * Technique qualitative attributes + */ +export interface ITechniqueAttributeQualitative { + method_lookup_attribute_qualitative_id: string; + name: string; + description: string | null; + options: ITechniqueAttributeQualitativeOption[]; +} + +/** + * Response for fetching technique attributes for a method lookup id + */ +export interface IGetTechniqueAttributes { + method_lookup_id: number; + quantitative_attributes: ITechniqueAttributeQuantitative[]; + qualitative_attributes: ITechniqueAttributeQualitative[]; +} diff --git a/app/src/interfaces/reference/vantage.interface.ts b/app/src/interfaces/reference/vantage.interface.ts new file mode 100644 index 0000000000..66f7bce4da --- /dev/null +++ b/app/src/interfaces/reference/vantage.interface.ts @@ -0,0 +1,16 @@ +export type VantageCategory = { + vantage_category_id: number; + name: string; + description: string | null; +}; + +export type Vantage = { + vantage_method_id: number; + vantage_category_id: number; + name: string; +}; + +/** + * Response for fetching vantage reference records for a method lookup id + */ +export type GetVantageReferenceRecord = Vantage & { vantages: Vantage[] }; diff --git a/app/src/interfaces/useCodesApi.interface.ts b/app/src/interfaces/useCodesApi.interface.ts index 090cad24dd..fb44bd36ee 100644 --- a/app/src/interfaces/useCodesApi.interface.ts +++ b/app/src/interfaces/useCodesApi.interface.ts @@ -40,7 +40,7 @@ export interface IGetAllCodeSetsResponse { sample_methods: CodeSet<{ id: number; name: string; description: string }>; method_response_metrics: CodeSet<{ id: number; name: string; description: string }>; attractants: CodeSet<{ id: number; name: string; description: string }>; - observation_subcount_signs: CodeSet<{ id: number; name: string; description: string }>; + observation_signs: CodeSet<{ id: number; name: string; description: string }>; telemetry_device_makes: CodeSet<{ id: number; name: string; description: string }>; frequency_units: CodeSet<{ id: number; name: string; description: string }>; alert_types: CodeSet<{ id: number; name: string; description: string }>; diff --git a/app/src/interfaces/useObservationApi.interface.ts b/app/src/interfaces/useObservationApi.interface.ts index 50d2d427d2..888d6da413 100644 --- a/app/src/interfaces/useObservationApi.interface.ts +++ b/app/src/interfaces/useObservationApi.interface.ts @@ -1,179 +1,3 @@ -import { - CBQualitativeMeasurementTypeDefinition, - CBQuantitativeMeasurementTypeDefinition -} from 'interfaces/useCritterApi.interface'; -import { - EnvironmentQualitativeTypeDefinition, - EnvironmentQuantitativeTypeDefinition -} from 'interfaces/useReferenceApi.interface'; -import { GetSamplingPeriod } from 'interfaces/useSamplingPeriodApi.interface'; -import { ApiPaginationResponseParams } from 'types/misc'; -export interface IGetSurveyObservationsResponse { - surveyObservations: ObservationRecordWithSamplingAndSubcountData[]; - supplementaryObservationData: SupplementaryObservationData; - pagination: ApiPaginationResponseParams; -} - -export interface IGetSurveyObservationsGeometryObject { - survey_observation_id: number; - geometry: GeoJSON.Point; -} - -export interface IGetSurveyObservationsGeometryResponse { - surveyObservationsGeometry: IGetSurveyObservationsGeometryObject[]; - supplementaryObservationData: SupplementaryObservationCountData; -} - -type ObservationSamplingData = { - survey_sample_site_name: string | null; - method_technique_name: string | null; - 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_period_id: number | null; - count: number | null; - observation_date: string | null; - observation_time: string | null; - latitude: number | null; - longitude: number | null; -}; - -export type SubcountObservationColumns = { - observation_subcount_id: number | null; - observation_subcount_sign_id: number; - comment: string | 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 ObservationSamplingSupplementaryData = { - sampling_data: GetSamplingPeriod[]; -}; - -export type SupplementaryObservationMeasurementData = { - qualitative_measurements: CBQualitativeMeasurementTypeDefinition[]; - quantitative_measurements: CBQuantitativeMeasurementTypeDefinition[]; - qualitative_environments: EnvironmentQualitativeTypeDefinition[]; - quantitative_environments: EnvironmentQuantitativeTypeDefinition[]; -}; - -export type SupplementaryObservationData = SupplementaryObservationCountData & - SupplementaryObservationMeasurementData & - ObservationSamplingSupplementaryData; - -type ObservationSubCountQualitativeMeasurementRecord = { - observation_subcount_id: number; - critterbase_taxon_measurement_id: string; - critterbase_measurement_qualitative_option_id: string; - create_date: string; - create_user: number; - update_date: string | null; - update_user: number | null; - revision_count: number; -}; - -type ObservationSubCountQuantitativeMeasurementRecord = { - observation_subcount_id: number; - critterbase_taxon_measurement_id: string; - value: number; - create_date: string; - create_user: number; - update_date: string | null; - update_user: number | null; - revision_count: number; -}; - -type ObservationSubcountQualitativeMeasurementObject = Pick< - ObservationSubCountQualitativeMeasurementRecord, - 'critterbase_taxon_measurement_id' | 'critterbase_measurement_qualitative_option_id' ->; - -type ObservationSubcountQuantitativeMeasurementObject = Pick< - ObservationSubCountQuantitativeMeasurementRecord, - '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, - 'observation_subcount_qualitative_environment_id' | '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; - observation_subcount_sign_id: number; - comment: string; - subcount: number | null; - create_date: string; - create_user: number; - update_date: string | null; - update_user: number | null; - revision_count: number; -}; - -type ObservationSubcountObject = { - observation_subcount_id: ObservationSubcountRecord['observation_subcount_id']; - observation_subcount_sign_id: ObservationSubcountRecord['observation_subcount_sign_id']; - comment: ObservationSubcountRecord['comment']; - subcount: ObservationSubcountRecord['subcount']; - qualitative_measurements: ObservationSubcountQualitativeMeasurementObject[]; - quantitative_measurements: ObservationSubcountQuantitativeMeasurementObject[]; - qualitative_environments: ObservationSubcountQualitativeEnvironmentObject[]; - quantitative_environments: ObservationSubcountQuantitativeEnvironmentObject[]; -}; - -type ObservationSubcountsObject = { - subcounts: ObservationSubcountObject[]; -}; - -type ObservationRecordWithSamplingAndSubcountData = StandardObservationColumns & - ObservationSamplingData & - ObservationSubcountsObject; +export * from 'interfaces/observation/environment.interface'; +export * from 'interfaces/observation/observation.interface'; +export * from 'interfaces/observation/subcount.interface'; diff --git a/app/src/interfaces/useReferenceApi.interface.ts b/app/src/interfaces/useReferenceApi.interface.ts index cc87a24160..a4f7662fee 100644 --- a/app/src/interfaces/useReferenceApi.interface.ts +++ b/app/src/interfaces/useReferenceApi.interface.ts @@ -1,106 +1,3 @@ -/** - * 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'][]; -}; - -/** - * Technique quantitative attributes - */ -export interface ITechniqueAttributeQuantitative { - method_lookup_attribute_quantitative_id: string; - name: string; - description: string | null; - unit: string | null; - min: number | null; - max: number | null; -} - -/** - * Technique qualitative attributes - */ -export interface ITechniqueAttributeQualitativeOption { - method_lookup_attribute_qualitative_option_id: string; - name: string; - description: string | null; -} - -/** - * Technique qualitative attributes - */ -export interface ITechniqueAttributeQualitative { - method_lookup_attribute_qualitative_id: string; - name: string; - description: string | null; - options: ITechniqueAttributeQualitativeOption[]; -} - -/** - * Response for fetching technique attributes for a method lookup id - */ -export interface IGetTechniqueAttributes { - method_lookup_id: number; - quantitative_attributes: ITechniqueAttributeQuantitative[]; - qualitative_attributes: ITechniqueAttributeQualitative[]; -} - -export type VantageCategory = { - vantage_category_id: number; - name: string; - description: string | null; -}; - -export type Vantage = { - vantage_method_id: number; - vantage_category_id: number; - name: string; -}; - -/** - * Response for fetching vantage reference records for a method lookup id - */ -export type GetVantageReferenceRecord = Vantage & { vantages: Vantage[] }; +export * from 'interfaces/reference/environment.interface'; +export * from 'interfaces/reference/technique.interface'; +export * from 'interfaces/reference/vantage.interface'; diff --git a/app/src/test-helpers/code-helpers.ts b/app/src/test-helpers/code-helpers.ts index 28c5e87504..88d4c5a8fa 100644 --- a/app/src/test-helpers/code-helpers.ts +++ b/app/src/test-helpers/code-helpers.ts @@ -63,7 +63,7 @@ export const codes: IGetAllCodeSetsResponse = { { id: 1, name: 'Bait', description: 'Consumable bait or food used as a lure.' }, { id: 2, name: 'Scent', description: 'A scent used as a lure.' } ], - observation_subcount_signs: [ + observation_signs: [ { id: 1, name: 'Scat', description: 'Scat left by the species.' }, { id: 2, name: 'Direct sighting', description: 'A direct sighting of the species.' } ], diff --git a/app/src/themes/appTheme.ts b/app/src/themes/appTheme.ts index 6dc932c415..ba7744091d 100644 --- a/app/src/themes/appTheme.ts +++ b/app/src/themes/appTheme.ts @@ -116,6 +116,15 @@ const appTheme = createTheme({ } } }, + MuiAccordionSummary: { + styleOverrides: { + root: { + '&.Mui-focused': { + backgroundColor: 'inherit' + } + } + } + }, MuiBreadcrumbs: { styleOverrides: { root: { diff --git a/database/src/migrations/20250128000001_create_observations_updates.ts b/database/src/migrations/20250128000001_create_observations_updates.ts new file mode 100644 index 0000000000..5eb507bcfb --- /dev/null +++ b/database/src/migrations/20250128000001_create_observations_updates.ts @@ -0,0 +1,277 @@ +import { Knex } from 'knex'; + +/** + * Purpose: + * + * The purpose of this migration is to move some of the data that was tracked at the observation subcount level to the + * observation level. This includes the 'sign' data and the 'environment' data. + * + * Changes: + * + * Move table 'observation_subcount_sign' to "observation_sign' + * Add column 'observation_sign_id' to 'survey_observation' + * + * Move table 'observation_subcount_qualitative_environment' to 'observation_environment_qualitative' + * Move table 'observation_subcount_quantitative_environment' to 'observation_environment_quantitative' + * + * Copy 'observation_subcount_sign' data to 'observation_sign' + * + * Migrate 'observation_subcount_sign_id' data from 'observation_subcount' to 'survey_observation' + * + * Migrate 'observation_subcount_qualitative_environment' data to 'observation_environment_qualitative' + * Migrate 'observation_subcount_quantitative_environment' data to 'observation_environment_quantitative' + * + * Drop column 'observation_subcount_sign_id' from 'observation_subcount' + * + * Drop table 'observation_subcount_sign' + * Drop table 'observation_subcount_qualitative_environment' + * Drop table 'observation_subcount_quantitative_environment' + * + * @export + * @param {Knex} knex + * @return {*} {Promise} + */ +export async function up(knex: Knex): Promise { + await knex.raw(`--sql + SET SEARCH_PATH=biohub; + + ---------------------------------------------------------------------------------------- + -- Create new tables + ---------------------------------------------------------------------------------------- + + CREATE TABLE observation_sign ( + observation_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_sign_pk PRIMARY KEY (observation_sign_id) + ); + + COMMENT ON TABLE observation_sign IS 'This table is intended to store options that users can select for the sign of an observation.'; + COMMENT ON COLUMN observation_sign.observation_sign_id IS 'System generated surrogate primary key identifier.'; + COMMENT ON COLUMN observation_sign.name IS 'The name of the sign record.'; + COMMENT ON COLUMN observation_sign.description IS 'The description of the sign record.'; + COMMENT ON COLUMN observation_sign.record_end_date IS 'Record level end date.'; + COMMENT ON COLUMN observation_sign.create_date IS 'The datetime the record was created.'; + COMMENT ON COLUMN observation_sign.create_user IS 'The id of the user who created the record as identified in the system user table.'; + COMMENT ON COLUMN observation_sign.update_date IS 'The datetime the record was updated.'; + COMMENT ON COLUMN observation_sign.update_user IS 'The id of the user who updated the record as identified in the system user table.'; + COMMENT ON COLUMN observation_sign.revision_count IS 'Revision count used for concurrency control.'; + + ---------------------------------------------------------------------------------------- + + CREATE TABLE observation_environment_quantitative ( + observation_environment_quantitative_id integer GENERATED ALWAYS AS IDENTITY (START WITH 1 INCREMENT BY 1), + survey_observation_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_environment_quantitative_pk PRIMARY KEY (observation_environment_quantitative_id) + ); + + COMMENT ON TABLE observation_environment_quantitative IS 'This table is intended to track quantitative environments applied to a particular observation.'; + COMMENT ON COLUMN observation_environment_quantitative.observation_environment_quantitative_id IS 'System generated surrogate primary key identifier.'; + COMMENT ON COLUMN observation_environment_quantitative.survey_observation_id IS 'Foreign key to the survey_observation table.'; + COMMENT ON COLUMN observation_environment_quantitative.environment_quantitative_id IS 'Foreign key to the environment_quantitative table.'; + COMMENT ON COLUMN observation_environment_quantitative.value IS 'Quantitative data value.'; + COMMENT ON COLUMN observation_environment_quantitative.create_date IS 'The datetime the record was created.'; + COMMENT ON COLUMN observation_environment_quantitative.create_user IS 'The id of the user who created the record as identified in the system user table.'; + COMMENT ON COLUMN observation_environment_quantitative.update_date IS 'The datetime the record was updated.'; + COMMENT ON COLUMN observation_environment_quantitative.update_user IS 'The id of the user who updated the record as identified in the system user table.'; + COMMENT ON COLUMN observation_environment_quantitative.revision_count IS 'Revision count used for concurrency control.'; + + -- Add unique constraint + CREATE UNIQUE INDEX observation_environment_quantitative_uk1 ON observation_environment_quantitative(survey_observation_id, environment_quantitative_id); + + -- Add foreign key constraint + ALTER TABLE observation_environment_quantitative + ADD CONSTRAINT observation_environment_quantitative_fk1 + FOREIGN KEY (survey_observation_id) + REFERENCES survey_observation(survey_observation_id); + + ALTER TABLE observation_environment_quantitative + ADD CONSTRAINT observation_environment_quantitative_fk2 + FOREIGN KEY (environment_quantitative_id) + REFERENCES environment_quantitative(environment_quantitative_id); + + -- Add indexes for foreign keys + CREATE INDEX observation_environment_quantitative_idx1 ON observation_environment_quantitative(survey_observation_id); + + CREATE INDEX observation_environment_quantitative_idx2 ON observation_environment_quantitative(environment_quantitative_id); + + ---------------------------------------------------------------------------------------- + + CREATE TABLE observation_environment_qualitative ( + observation_environment_qualitative_id integer GENERATED ALWAYS AS IDENTITY (START WITH 1 INCREMENT BY 1), + survey_observation_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_environment_qualitative_pk PRIMARY KEY (observation_environment_qualitative_id) + ); + + COMMENT ON TABLE observation_environment_qualitative IS 'This table is intended to track qualitative environments applied to a particular observation.'; + COMMENT ON COLUMN observation_environment_qualitative.observation_environment_qualitative_id IS 'System generated surrogate primary key identifier.'; + COMMENT ON COLUMN observation_environment_qualitative.survey_observation_id IS 'Foreign key to the survey_observation table.'; + COMMENT ON COLUMN observation_environment_qualitative.environment_qualitative_id IS 'Foreign key to the environment_qualitative table.'; + COMMENT ON COLUMN observation_environment_qualitative.environment_qualitative_option_id IS 'Foreign key to the environment_qualitative_option table.'; + COMMENT ON COLUMN observation_environment_qualitative.create_date IS 'The datetime the record was created.'; + COMMENT ON COLUMN observation_environment_qualitative.create_user IS 'The id of the user who created the record as identified in the system user table.'; + COMMENT ON COLUMN observation_environment_qualitative.update_date IS 'The datetime the record was updated.'; + COMMENT ON COLUMN observation_environment_qualitative.update_user IS 'The id of the user who updated the record as identified in the system user table.'; + COMMENT ON COLUMN observation_environment_qualitative.revision_count IS 'Revision count used for concurrency control.'; + + -- Add unique constraint + CREATE UNIQUE INDEX observation_environment_qualitative_uk1 ON observation_environment_qualitative(survey_observation_id, environment_qualitative_id, environment_qualitative_option_id); + + -- Add foreign key constraint + ALTER TABLE observation_environment_qualitative + ADD CONSTRAINT observation_environment_qualitative_fk1 + FOREIGN KEY (survey_observation_id) + REFERENCES survey_observation(survey_observation_id); + + ALTER TABLE observation_environment_qualitative + ADD CONSTRAINT observation_environment_qualitative_fk2 + FOREIGN KEY (environment_qualitative_id) + REFERENCES environment_qualitative(environment_qualitative_id); + + ALTER TABLE observation_environment_qualitative + ADD CONSTRAINT observation_environment_qualitative_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_environment_qualitative + ADD CONSTRAINT observation_environment_qualitative_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_environment_qualitative_idx1 ON observation_environment_qualitative(survey_observation_id); + + CREATE INDEX observation_environment_qualitative_idx2 ON observation_environment_qualitative(environment_qualitative_id); + + CREATE INDEX observation_environment_qualitative_idx3 ON observation_environment_qualitative(environment_qualitative_option_id); + + ---------------------------------------------------------------------------------------- + -- Alter Existing Tables + ---------------------------------------------------------------------------------------- + + ALTER TABLE survey_observation ADD COLUMN observation_sign_id INTEGER; + + COMMENT ON COLUMN survey_observation.observation_sign_id IS 'Foreign key referencing a record in the observation_sign table.'; + + -- Add foreign key constraint + ALTER TABLE survey_observation + ADD CONSTRAINT survey_observation_fk5 + FOREIGN KEY (observation_sign_id) + REFERENCES observation_sign(observation_sign_id); + + -- Add indexes on foreign keys + CREATE INDEX survey_observation_idx7 ON survey_observation(observation_sign_id); + + ---------------------------------------------------------------------------------------- + -- Add journal/audit triggers + ---------------------------------------------------------------------------------------- + + CREATE TRIGGER audit_observation_sign BEFORE INSERT OR UPDATE OR DELETE ON biohub.observation_sign FOR EACH ROW EXECUTE PROCEDURE tr_audit_trigger(); + CREATE TRIGGER journal_observation_sign AFTER INSERT OR UPDATE OR DELETE ON biohub.observation_sign FOR EACH ROW EXECUTE PROCEDURE tr_journal_trigger(); + + CREATE TRIGGER audit_observation_environment_quantitative BEFORE INSERT OR UPDATE OR DELETE ON biohub.observation_environment_quantitative FOR EACH ROW EXECUTE PROCEDURE tr_audit_trigger(); + CREATE TRIGGER journal_observation_environment_quantitative AFTER INSERT OR UPDATE OR DELETE ON biohub.observation_environment_quantitative FOR EACH ROW EXECUTE PROCEDURE tr_journal_trigger(); + + CREATE TRIGGER audit_observation_environment_qualitative BEFORE INSERT OR UPDATE OR DELETE ON biohub.observation_environment_qualitative FOR EACH ROW EXECUTE PROCEDURE tr_audit_trigger(); + CREATE TRIGGER journal_observation_environment_qualitative AFTER INSERT OR UPDATE OR DELETE ON biohub.observation_environment_qualitative FOR EACH ROW EXECUTE PROCEDURE tr_journal_trigger(); + + ---------------------------------------------------------------------------------------- + -- Migrate Data + ---------------------------------------------------------------------------------------- + + -- Copy the lookup values from the observation_subcount_sign table into the observation_sign table + INSERT INTO observation_sign ( + name, + description, + record_end_date + ) + SELECT + name, + description, + record_end_date + FROM + observation_subcount_sign; + + -- Migrate the observation_subcount_sign_id values from the observation_subcount table to the survey_observation table + UPDATE survey_observation + SET observation_sign_id = ( + SELECT observation_sign_id + FROM observation_sign + WHERE observation_sign.name = ( + SELECT observation_subcount_sign.name + FROM observation_subcount_sign + INNER JOIN observation_subcount + ON observation_subcount.observation_subcount_sign_id = observation_subcount_sign.observation_subcount_sign_id + WHERE observation_subcount.survey_observation_id = survey_observation.survey_observation_id + ) + ); + + -- Migrate the observation_subcount_qualitative_environment values to the observation_environment_qualitative table + INSERT INTO observation_environment_qualitative ( + survey_observation_id, + environment_qualitative_id, + environment_qualitative_option_id + ) + SELECT + observation_subcount.survey_observation_id, + observation_subcount_qualitative_environment.environment_qualitative_id, + observation_subcount_qualitative_environment.environment_qualitative_option_id + FROM observation_subcount + INNER JOIN observation_subcount_qualitative_environment + ON observation_subcount_qualitative_environment.observation_subcount_id = observation_subcount.observation_subcount_id; + + -- Migrate the observation_subcount_quantitative_environment values to the observation_environment_quantitative table + INSERT INTO observation_environment_quantitative ( + survey_observation_id, + environment_quantitative_id, + value + ) + SELECT + observation_subcount.survey_observation_id, + observation_subcount_quantitative_environment.environment_quantitative_id, + observation_subcount_quantitative_environment.value + FROM observation_subcount + INNER JOIN observation_subcount_quantitative_environment + ON observation_subcount_quantitative_environment.observation_subcount_id = observation_subcount.observation_subcount_id; + + ---------------------------------------------------------------------------------------- + -- Drop Deprecated Columns + ---------------------------------------------------------------------------------------- + + ALTER TABLE observation_subcount DROP COLUMN observation_subcount_sign_id; + + ---------------------------------------------------------------------------------------- + -- Drop Deprecated Tables + ---------------------------------------------------------------------------------------- + + DROP TABLE observation_subcount_sign; + DROP TABLE observation_subcount_qualitative_environment; + DROP TABLE observation_subcount_quantitative_environment; + `); +} + +export async function down(knex: Knex): Promise { + await knex.raw(``); +} diff --git a/database/src/procedures/delete_survey_procedure.ts b/database/src/procedures/delete_survey_procedure.ts index 1e2bd23919..142a4976a4 100644 --- a/database/src/procedures/delete_survey_procedure.ts +++ b/database/src/procedures/delete_survey_procedure.ts @@ -198,7 +198,7 @@ export async function seed(knex: Knex): Promise { -------- delete observation data -------- - DELETE FROM observation_subcount_qualitative_environment + DELETE FROM observation_subcount_qualitative_measurement WHERE observation_subcount_id IN ( SELECT observation_subcount_id FROM observation_subcount WHERE survey_observation_id IN ( @@ -207,7 +207,7 @@ export async function seed(knex: Knex): Promise { ) ); - DELETE FROM observation_subcount_quantitative_environment + DELETE FROM observation_subcount_quantitative_measurement WHERE observation_subcount_id IN ( SELECT observation_subcount_id FROM observation_subcount WHERE survey_observation_id IN ( @@ -216,25 +216,19 @@ export async function seed(knex: Knex): Promise { ) ); - DELETE FROM observation_subcount_qualitative_measurement - WHERE observation_subcount_id IN ( - SELECT observation_subcount_id FROM observation_subcount - WHERE survey_observation_id IN ( - SELECT survey_observation_id FROM survey_observation - WHERE survey_id = p_survey_id - ) + DELETE FROM observation_subcount + WHERE survey_observation_id IN ( + SELECT survey_observation_id FROM survey_observation + WHERE survey_id = p_survey_id ); - DELETE FROM observation_subcount_quantitative_measurement - WHERE observation_subcount_id IN ( - SELECT observation_subcount_id FROM observation_subcount - WHERE survey_observation_id IN ( - SELECT survey_observation_id FROM survey_observation - WHERE survey_id = p_survey_id - ) + DELETE FROM observation_environment_qualitative + WHERE survey_observation_id IN ( + SELECT survey_observation_id FROM survey_observation + WHERE survey_id = p_survey_id ); - DELETE FROM observation_subcount + DELETE FROM observation_environment_quantitative WHERE survey_observation_id IN ( SELECT survey_observation_id FROM survey_observation WHERE survey_id = p_survey_id diff --git a/database/src/seeds/03_basic_project_survey_setup.ts b/database/src/seeds/03_basic_project_survey_setup.ts index 084d233f3e..3ffb0d74b0 100644 --- a/database/src/seeds/03_basic_project_survey_setup.ts +++ b/database/src/seeds/03_basic_project_survey_setup.ts @@ -113,15 +113,6 @@ export async function seed(knex: Knex): Promise { } } - 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 @@ -132,6 +123,20 @@ export async function seed(knex: Knex): Promise { NUM_SEED_SUBCOUNTS_PER_OBSERVATION * 20 + faker.number.int({ min: 1, max: 20 }) ) ); + + if (Math.random() < 0.75) { + // Insert observation qualitative environments 75% of the time + await knex.raw( + insertObservationQualitativeEnvironments(createObservationResponse.rows[0].survey_observation_id) + ); + } + if (Math.random() < 0.75) { + // Insert observation quantitative environments 75% of the time + await knex.raw( + insertObservationQuantitativeEnvironments(createObservationResponse.rows[0].survey_observation_id) + ); + } + for (let l = 0; l < NUM_SEED_SUBCOUNTS_PER_OBSERVATION; l++) { await knex.raw(insertObservationSubCount(createObservationResponse.rows[0].survey_observation_id)); } @@ -934,16 +939,88 @@ const insertObservationSubCount = (surveyObservationId: number) => ` ( survey_observation_id, subcount, - observation_subcount_sign_id + comment ) VALUES ( ${surveyObservationId}, $$${faker.number.int({ min: 1, max: 20 })}$$, - (SELECT observation_subcount_sign_id FROM observation_subcount_sign ORDER BY random() LIMIT 1) + $$${faker.lorem.sentences({ min: 0, max: 4 })}$$ ); `; +/** + * SQL to insert observation qualitative environments. + * + * @param {number} surveyObservationId + */ +const insertObservationQualitativeEnvironments = (surveyObservationId: number) => ` + + WITH + w_environment_qualitative AS ( + -- Select the first 2 records by ID (so that we have some consistency in the environments selected by the observations) + SELECT + environment_qualitative_id + FROM + environment_qualitative + ORDER BY environment_qualitative_id LIMIT 2 + ), + w_environment_qualitative_option AS ( + -- Select 2 random options to associate with the selected environment_qualitative records + SELECT + distinct on (environment_qualitative_option.environment_qualitative_id) + w_environment_qualitative.environment_qualitative_id, + environment_qualitative_option.environment_qualitative_option_id + FROM + environment_qualitative_option + INNER JOIN w_environment_qualitative + ON w_environment_qualitative.environment_qualitative_id = environment_qualitative_option.environment_qualitative_id + ORDER BY environment_qualitative_option.environment_qualitative_id, random() LIMIT 2 + ) + INSERT INTO observation_environment_qualitative + ( + survey_observation_id, + environment_qualitative_id, + environment_qualitative_option_id + ) + SELECT + ${surveyObservationId}, + environment_qualitative_id, + environment_qualitative_option_id + FROM + w_environment_qualitative_option; +`; + +/** + * SQL to insert observation quantitative environments. + * + * @param {number} surveyObservationId + */ +const insertObservationQuantitativeEnvironments = (surveyObservationId: number) => ` + WITH + w_environment_quantitative AS ( + -- Select the first 2 records by ID (so that we have some consistency in the environments selected by the observations) + SELECT + * + FROM + environment_quantitative + ORDER BY environment_quantitative_id LIMIT 2 + ) + INSERT INTO observation_environment_quantitative + ( + survey_observation_id, + environment_quantitative_id, + value + ) + SELECT + ${surveyObservationId}, + environment_quantitative_id, + -- Insert a random value that is between the min and max values, if specified + (SELECT floor(random() * (COALESCE(w_environment_quantitative.max, 10000) - COALESCE(w_environment_quantitative.min, 1) + 1))::int + COALESCE(w_environment_quantitative.min, 1)) + FROM + w_environment_quantitative; +`; + /** * SQL to insert survey observation data. Requires sampling site, method, period. * @@ -963,7 +1040,8 @@ const insertSurveyObservationData = (surveyId: number, count: number) => { count, observation_date, observation_time, - survey_sample_period_id + survey_sample_period_id, + observation_sign_id ) VALUES ( @@ -979,7 +1057,8 @@ const insertSurveyObservationData = (surveyId: number, count: number) => { timestamp $$${faker.date .between({ from: '2000-01-01T00:00:00-08:00', to: '2005-01-01T00:00:00-08:00' }) .toISOString()}$$::time, - (SELECT survey_sample_period_id FROM survey_sample_period WHERE survey_id = ${surveyId} ORDER BY random() LIMIT 1) + (SELECT survey_sample_period_id FROM survey_sample_period WHERE survey_id = ${surveyId} ORDER BY random() LIMIT 1), + (SELECT observation_sign_id FROM observation_sign ORDER BY random() LIMIT 1) ) RETURNING survey_observation_id; `; diff --git a/env_config/env.docker b/env_config/env.docker index 48d819e81b..cb23d5ce29 100644 --- a/env_config/env.docker +++ b/env_config/env.docker @@ -244,7 +244,7 @@ NUM_SEED_SUBCOUNTS_PER_OBSERVATION=1 # ------------------------------------------------------------------------------ # Lotek API -LOTEK_API_HOST=https://api.lotek.com +LOTEK_API_HOST=https://webservice.lotek.com LOTEK_ACCOUNT_USERNAME= LOTEK_ACCOUNT_PASSWORD=