diff --git a/api/src/paths/submission/{submissionId}/feature-type/index.test.ts b/api/src/paths/submission/{submissionId}/feature-type/index.test.ts new file mode 100644 index 00000000..5344aafa --- /dev/null +++ b/api/src/paths/submission/{submissionId}/feature-type/index.test.ts @@ -0,0 +1,74 @@ +import chai, { expect } from 'chai'; +import { describe } from 'mocha'; +import sinon from 'sinon'; +import sinonChai from 'sinon-chai'; +import { getSubmissionFeatureTypes } from '.'; +import * as db from '../../../../database/db'; +import { FeatureTypeRecord } from '../../../../repositories/submission-repository'; +import { SubmissionService } from '../../../../services/submission-service'; +import { getMockDBConnection, getRequestHandlerMocks } from '../../../../__mocks__/db'; + +chai.use(sinonChai); + +describe('getSubmissionFeatureTypes', () => { + afterEach(() => { + sinon.restore(); + }); + + it('throws error if submissionService throws error', async () => { + const dbConnectionObj = getMockDBConnection(); + + sinon.stub(db, 'getAPIUserDBConnection').returns(dbConnectionObj); + + const getSubmissionFeatureTypesStub = sinon + .stub(SubmissionService.prototype, 'getSubmissionFeatureTypes') + .throws(new Error('test error')); + + const requestHandler = getSubmissionFeatureTypes(); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + + const submissionId = 1; + + mockReq.params = { + submissionId: String(submissionId) + }; + + try { + await requestHandler(mockReq, mockRes, mockNext); + + expect.fail(); + } catch (error) { + expect(getSubmissionFeatureTypesStub).to.have.been.calledOnceWith(submissionId); + expect((error as Error).message).to.equal('test error'); + } + }); + + it('should return 200 on success', async () => { + const dbConnectionObj = getMockDBConnection(); + + sinon.stub(db, 'getAPIUserDBConnection').returns(dbConnectionObj); + + const mockFeatureTypes: FeatureTypeRecord[] = []; + + const getSubmissionFeatureTypesStub = sinon + .stub(SubmissionService.prototype, 'getSubmissionFeatureTypes') + .resolves(mockFeatureTypes); + + const requestHandler = getSubmissionFeatureTypes(); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + + const submissionId = 1; + + mockReq.params = { + submissionId: String(submissionId) + }; + + await requestHandler(mockReq, mockRes, mockNext); + + expect(getSubmissionFeatureTypesStub).to.have.been.calledOnceWith(submissionId); + expect(mockRes.statusValue).to.eql(200); + expect(mockRes.jsonValue).to.eql({ feature_types: mockFeatureTypes }); + }); +}); diff --git a/api/src/paths/submission/{submissionId}/feature-type/index.ts b/api/src/paths/submission/{submissionId}/feature-type/index.ts new file mode 100644 index 00000000..67149d2b --- /dev/null +++ b/api/src/paths/submission/{submissionId}/feature-type/index.ts @@ -0,0 +1,148 @@ +import { RequestHandler } from 'express'; +import { Operation } from 'express-openapi'; +import { getAPIUserDBConnection } from '../../../../database/db'; +import { defaultErrorResponses } from '../../../../openapi/schemas/http-responses'; +import { SubmissionService } from '../../../../services/submission-service'; +import { getLogger } from '../../../../utils/logger'; + +const defaultLog = getLogger('paths/submission/{submissionId}'); + +export const GET: Operation = [getSubmissionFeatureTypes()]; + +GET.apiDoc = { + description: 'Retrieves a sorted and distinct array of feature type records for a submission.', + tags: ['submission'], + parameters: [ + { + description: 'Submission ID.', + in: 'path', + name: 'submissionId', + schema: { + type: 'integer', + minimum: 1 + }, + required: true + } + ], + responses: { + 200: { + description: 'A sorted and distinct array of feature type records.', + content: { + 'application/json': { + schema: { + type: 'array', + items: { + type: 'object', + required: ['features_types'], + properties: { + features_types: { + description: 'A sorted and distinct array of feature type records.', + type: 'array', + items: { + type: 'object', + required: [ + 'feature_type_id', + 'name', + 'display_name', + 'description', + 'record_effective_date', + 'record_end_date', + 'create_date', + 'create_user', + 'update_date', + 'update_user', + 'revision_count' + ], + properties: { + feature_type_id: { + type: 'integer', + minimum: 1 + }, + name: { + type: 'string', + maxLength: 100 + }, + display_name: { + type: 'string', + maxLength: 100 + }, + description: { + type: 'string', + nullable: true, + maxLength: 500 + }, + sort: { + type: 'number', + nullable: true + }, + record_effective_date: { + type: 'string' + }, + record_end_date: { + type: 'string', + nullable: true + }, + create_date: { + type: 'string' + }, + create_user: { + type: 'integer', + minimum: 1 + }, + update_date: { + type: 'string', + nullable: true + }, + update_user: { + type: 'integer', + minimum: 1, + nullable: true + }, + revision_count: { + type: 'integer', + minimum: 0 + } + } + } + } + } + } + } + } + } + }, + ...defaultErrorResponses + } +}; + +/** + * Retrieves a sorted and distinct list of all feature type records for a submission. + * + * @export + * @return {*} {RequestHandler} + */ +export function getSubmissionFeatureTypes(): RequestHandler { + return async (req, res) => { + const connection = getAPIUserDBConnection(); + + const submissionId = Number(req.params.submissionId); + + try { + await connection.open(); + + const submissionService = new SubmissionService(connection); + + const featureTypes = await submissionService.getSubmissionFeatureTypes(submissionId); + + await connection.commit(); + + res.status(200).json({ feature_types: featureTypes }); + } catch (error) { + defaultLog.error({ label: 'getSubmissionFeatures', message: 'error', error }); + await connection.rollback(); + throw error; + } finally { + connection.release(); + } + }; +} diff --git a/api/src/paths/submission/{submissionId}/features/index.ts b/api/src/paths/submission/{submissionId}/features/index.ts index eee978d4..433d06b6 100644 --- a/api/src/paths/submission/{submissionId}/features/index.ts +++ b/api/src/paths/submission/{submissionId}/features/index.ts @@ -163,7 +163,7 @@ export function getSubmissionFeatures(): RequestHandler { const submissionService = new SubmissionService(connection); - const result = await submissionService.getSubmissionFeaturesBySubmissionId(submissionId); + const result = await submissionService.getSubmissionFeaturesWithSearchKeyValuesBySubmissionId(submissionId); await connection.commit(); diff --git a/api/src/repositories/search-index-respository.ts b/api/src/repositories/search-index-respository.ts index 5f48d12e..3822f1e6 100644 --- a/api/src/repositories/search-index-respository.ts +++ b/api/src/repositories/search-index-respository.ts @@ -6,6 +6,7 @@ import { ApiExecuteSQLError } from '../errors/api-error'; import { getLogger } from '../utils/logger'; import { generateGeometryCollectionSQL } from '../utils/spatial-utils'; import { GeoJSONFeatureCollectionZodSchema } from '../zod-schema/geoJsonZodSchema'; +import { shallowJsonSchema } from '../zod-schema/json'; import { BaseRepository } from './base-repository'; const defaultLog = getLogger('repositories/search-index-repository'); @@ -109,6 +110,28 @@ export type InsertNumberSearchableRecord = z.infer; export type InsertSpatialSearchableRecord = z.infer; +export const SubmissionFeatureSearchKeyValues = z.object({ + search_id: z.number(), + submission_feature_id: z.number(), + feature_property_id: z.number(), + feature_property_name: z.string(), + value: z.union([z.string(), z.number(), shallowJsonSchema]) +}); + +export type SubmissionFeatureSearchKeyValues = z.infer; + +export const SubmissionFeatureCombinedSearchValues = z.object({ + search_string_id: z.number(), + submission_feature_id: z.number(), + feature_property_id: z.number(), + string_value: z.string(), + number_value: z.number(), + datetime_value: z.string(), + spatial_value: z.string() +}); + +export type SubmissionFeatureCombinedSearchValues = z.infer; + /** * A class for creating searchable records */ @@ -268,4 +291,153 @@ export class SearchIndexRepository extends BaseRepository { return response.rows; } + + /** + * Retrieves all search values, for all search types (string, number, datetime, spatial), for the given submission + * feature in one unified result set. + * + * @param {number} submissionFeatureId + * @return {*} {Promise} + * @memberof SearchIndexRepository + */ + async getCombinedSearchKeyValuesBySubmissionFeatureId( + submissionFeatureId: number + ): Promise { + const sqlStatement = SQL` + SELECT + search_string_id as search_id, + submission_feature_id, + feature_property_id, + value AS string_value, + null::numeric AS number_value, + null::timestamptz(6) AS datetime_value, + null::public.geometry AS spatial_value + FROM + search_string + WHERE + submission_feature_id = ${submissionFeatureId} + UNION ALL + SELECT + search_number_id as search_id, + submission_feature_id, + feature_property_id, + null AS string_value, + value AS number_value, + null::timestamptz(6) AS datetime_value, + null::public.geometry AS spatial_value + FROM + search_number + WHERE + submission_feature_id = ${submissionFeatureId} + UNION ALL + SELECT + search_datetime_id as search_id, + submission_feature_id, + feature_property_id, + null AS string_value, + null::numeric AS number_value, + value AS datetime_value, + null::public.geometry AS spatial_value + FROM + search_datetime + WHERE + submission_feature_id = ${submissionFeatureId} + UNION ALL + SELECT + search_spatial_id as search_id, + submission_feature_id, + feature_property_id, + null AS string_value, + null::numeric AS number_value, + null::timestamptz(6) AS datetime_value, + value AS spatial_value + FROM + search_spatial + WHERE + submission_feature_id = ${submissionFeatureId}; + `; + + const response = await this.connection.sql(sqlStatement, SubmissionFeatureCombinedSearchValues); + + return response.rows; + } + + /** + * Retrieves all search values, for all search types (string, number, datetime, spatial), for the given submission + * feature in one unified result set. + * + * @param {number} submissionFeatureId + * @return {*} {Promise} + * @memberof SearchIndexRepository + */ + async getSearchKeyValuesBySubmissionId(submissionId: number): Promise { + const sqlStatement = SQL` + with w_submission_features as ( + select submission_feature_id from submission_feature where submission_id = ${submissionId} + ) + SELECT + search_string.search_string_id as search_id, + search_string.submission_feature_id, + search_string.feature_property_id, + feature_property.name as feature_property_name, + search_string.value + FROM + w_submission_features + inner join + search_string + on w_submission_features.submission_feature_id = search_string.submission_feature_id + inner join + feature_property + on search_string.feature_property_id = feature_property.feature_property_id + UNION ALL + SELECT + search_number.search_number_id as search_id, + search_number.submission_feature_id, + search_number.feature_property_id, + feature_property.name as feature_property_name, + search_number.value::text + from + w_submission_features + inner join + search_number + on w_submission_features.submission_feature_id = search_number.submission_feature_id + inner join + feature_property + on search_number.feature_property_id = feature_property.feature_property_id + UNION ALL + SELECT + search_datetime.search_datetime_id as search_id, + search_datetime.submission_feature_id, + search_datetime.feature_property_id, + feature_property.name as feature_property_name, + search_datetime.value::text + from + w_submission_features + inner join + search_datetime + on w_submission_features.submission_feature_id = search_datetime.submission_feature_id + inner join + feature_property + on search_datetime.feature_property_id = feature_property.feature_property_id + UNION ALL + SELECT + search_spatial.search_spatial_id as search_id, + search_spatial.submission_feature_id, + search_spatial.feature_property_id, + feature_property.name as feature_property_name, + search_spatial.value::json::text + from + w_submission_features + inner join + search_spatial + on w_submission_features.submission_feature_id = search_spatial.submission_feature_id + inner join + feature_property + on search_spatial.feature_property_id = feature_property.feature_property_id; + `; + + const response = await this.connection.sql(sqlStatement, SubmissionFeatureSearchKeyValues); + + return response.rows; + } } diff --git a/api/src/repositories/submission-repository.ts b/api/src/repositories/submission-repository.ts index e835fa6c..ed2a77ac 100644 --- a/api/src/repositories/submission-repository.ts +++ b/api/src/repositories/submission-repository.ts @@ -103,6 +103,7 @@ export const FeatureTypeRecord = z.object({ name: z.string(), display_name: z.string(), description: z.string(), + sort: z.number().nullable(), record_effective_date: z.string(), record_end_date: z.string().nullable(), create_date: z.string(), @@ -1681,11 +1682,32 @@ export class SubmissionRepository extends BaseRepository { /** * Get all published submissions. * + * Note: Will only return the most recent published submission for each uuid. + * * @return {*} {Promise} * @memberof SubmissionRepository */ async getPublishedSubmissions(): Promise { const sqlStatement = SQL` + WITH RankedRows AS ( + SELECT + t1.*, + ROW_NUMBER() OVER (PARTITION BY t1.uuid ORDER BY t1.publish_timestamp DESC) AS rank + FROM + submission t1 + WHERE + t1.security_review_timestamp IS NOT NULL + AND + t1.publish_timestamp IS NOT NULL + ), + FilteredRows as ( + SELECT + t2.* + FROM + RankedRows t2 + WHERE + t2.rank = 1 + ) SELECT submission.*, feature_type.feature_type_id as root_feature_type_id, @@ -1697,7 +1719,11 @@ export class SubmissionRepository extends BaseRepository { ELSE ${SECURITY_APPLIED_STATUS.PARTIALLY_SECURED} END as security FROM + FilteredRows + INNER JOIN submission + ON + FilteredRows.submission_id = submission.submission_id INNER JOIN submission_feature ON @@ -2114,4 +2140,38 @@ export class SubmissionRepository extends BaseRepository { return response.rows[0].value; } + + /** + * Get a sorted and distinct list of all unique feature type records for a submission. + * + * @param {number} submissionId + * @return {*} {Promise} + * @memberof SubmissionRepository + */ + async getSubmissionFeatureTypes(submissionId: number): Promise { + const sqlStatement = SQL` + WITH w_distinct_feature_types AS ( + SELECT DISTINCT ON (feature_type.feature_type_id) + feature_type.* + FROM + submission_feature + INNER JOIN + feature_type + ON + submission_feature.feature_type_id = feature_type.feature_type_id + WHERE + submission_feature.submission_id = ${submissionId} + ) + SELECT + * + FROM + w_distinct_feature_types + ORDER BY + sort ASC; + `; + + const response = await this.connection.sql(sqlStatement, FeatureTypeRecord); + + return response.rows; + } } diff --git a/api/src/services/search-index-service.ts b/api/src/services/search-index-service.ts index bb9b7939..d9b3fd50 100644 --- a/api/src/services/search-index-service.ts +++ b/api/src/services/search-index-service.ts @@ -5,7 +5,9 @@ import { InsertNumberSearchableRecord, InsertSpatialSearchableRecord, InsertStringSearchableRecord, - SearchIndexRepository + SearchIndexRepository, + SubmissionFeatureCombinedSearchValues, + SubmissionFeatureSearchKeyValues } from '../repositories/search-index-respository'; import { SubmissionRepository } from '../repositories/submission-repository'; import { getLogger } from '../utils/logger'; @@ -130,4 +132,30 @@ export class SearchIndexService extends DBService { await Promise.all(promises); } + + /** + * Retrieves all search values, for all search types (string, number, datetime, spatial), for the given submission + * feature in one unified result set. + * + * @param {number} submissionFeatureId + * @return {*} {Promise} + * @memberof SearchIndexService + */ + async getCombinedSearchKeyValuesBySubmissionFeatureId( + submissionFeatureId: number + ): Promise { + return this.searchIndexRepository.getCombinedSearchKeyValuesBySubmissionFeatureId(submissionFeatureId); + } + + /** + * Retrieves all search values, for all search types (string, number, datetime, spatial), for all submission feature + * belonging to the given submission. + * + * @param {number} submissionId + * @return {*} {Promise} + * @memberof SearchIndexService + */ + async getSearchKeyValuesBySubmissionId(submissionId: number): Promise { + return this.searchIndexRepository.getSearchKeyValuesBySubmissionId(submissionId); + } } diff --git a/api/src/services/submission-service.test.ts b/api/src/services/submission-service.test.ts index dac8ef74..b8b03202 100644 --- a/api/src/services/submission-service.test.ts +++ b/api/src/services/submission-service.test.ts @@ -5,6 +5,7 @@ import { QueryResult } from 'pg'; import sinon from 'sinon'; import sinonChai from 'sinon-chai'; import { ApiExecuteSQLError, ApiGeneralError } from '../errors/api-error'; +import { SubmissionFeatureSearchKeyValues } from '../repositories/search-index-respository'; import { SECURITY_APPLIED_STATUS } from '../repositories/security-repository'; import { ISourceTransformModel, @@ -30,6 +31,7 @@ import { SystemUserExtended } from '../repositories/user-repository'; import * as fileUtils from '../utils/file-utils'; import { EMLFile } from '../utils/media/eml/eml-file'; import { getMockDBConnection } from '../__mocks__/db'; +import { SearchIndexService } from './search-index-service'; import { SubmissionService } from './submission-service'; import { UserService } from './user-service'; @@ -1346,6 +1348,199 @@ describe('SubmissionService', () => { }); }); + describe('getSubmissionFeaturesWithSearchKeyValuesBySubmissionId', () => { + it('should return an array of submission features', async () => { + const mockDBConnection = getMockDBConnection(); + + const submissionId = 1; + + const mockSubmissionRecords: SubmissionFeatureRecordWithTypeAndSecurity[] = [ + { + submission_feature_id: 1, + uuid: '111-234-345', + submission_id: submissionId, + feature_type_id: 2, + source_id: 'source-id-1', + data: {}, + parent_submission_feature_id: 4, + record_effective_date: '2020-01-01', + record_end_date: null, + create_date: '2020-01-01', + create_user: 1, + update_date: null, + update_user: null, + revision_count: 0, + feature_type_name: 'dataset', + feature_type_display_name: 'Dataset', + submission_feature_security_ids: [] + }, + { + submission_feature_id: 2, + uuid: '222-234-345', + submission_id: submissionId, + feature_type_id: 2, + source_id: 'source-id-2', + data: {}, + parent_submission_feature_id: 1, + record_effective_date: '2020-01-01', + record_end_date: null, + create_date: '2020-01-01', + create_user: 1, + update_date: null, + update_user: null, + revision_count: 0, + feature_type_name: 'observation', + feature_type_display_name: 'Observation', + submission_feature_security_ids: [] + }, + { + submission_feature_id: 3, + uuid: '333-234-345', + submission_id: submissionId, + feature_type_id: 2, + source_id: 'source-id-3', + data: {}, + parent_submission_feature_id: 1, + record_effective_date: '2020-01-01', + record_end_date: null, + create_date: '2020-01-01', + create_user: 1, + update_date: null, + update_user: null, + revision_count: 0, + feature_type_name: 'observation', + feature_type_display_name: 'Observation', + submission_feature_security_ids: [] + }, + { + submission_feature_id: 4, + uuid: '444-234-345', + submission_id: submissionId, + feature_type_id: 3, + source_id: 'source-id-4', + data: {}, + parent_submission_feature_id: 1, + record_effective_date: '2020-01-01', + record_end_date: null, + create_date: '2020-01-01', + create_user: 1, + update_date: null, + update_user: null, + revision_count: 0, + feature_type_name: 'artifact', + feature_type_display_name: 'Artifact', + submission_feature_security_ids: [] + } + ]; + + const mockSubmissionFeatureSearchKeyValues: SubmissionFeatureSearchKeyValues[] = [ + { + search_id: 1, + submission_feature_id: 1, + feature_property_id: 1, + feature_property_name: 'name', + value: 'name1' + }, + { + search_id: 2, + submission_feature_id: 1, + feature_property_id: 2, + feature_property_name: 'description', + value: 'description1' + }, + { + search_id: 3, + submission_feature_id: 2, + feature_property_id: 3, + feature_property_name: 'geometry', + value: { type: 'Point', coordinates: [100.0, 0.0] } + }, + { + search_id: 4, + submission_feature_id: 3, + feature_property_id: 1, + feature_property_name: 'name', + value: 'name3' + }, + { + search_id: 5, + submission_feature_id: 4, + feature_property_id: 4, + feature_property_name: 'start_date', + value: '2024-02-02' + }, + { + search_id: 6, + submission_feature_id: 4, + feature_property_id: 5, + feature_property_name: 'count', + value: 15 + } + ]; + + const getReviewedSubmissionsForAdminsStub = sinon + .stub(SubmissionRepository.prototype, 'getSubmissionFeaturesBySubmissionId') + .resolves(mockSubmissionRecords); + + const getSearchKeyValuesBySubmissionIdStub = sinon + .stub(SearchIndexService.prototype, 'getSearchKeyValuesBySubmissionId') + .resolves(mockSubmissionFeatureSearchKeyValues); + + const submissionService = new SubmissionService(mockDBConnection); + + const response = await submissionService.getSubmissionFeaturesWithSearchKeyValuesBySubmissionId(submissionId); + + expect(getReviewedSubmissionsForAdminsStub).to.be.calledOnceWith(submissionId); + expect(getSearchKeyValuesBySubmissionIdStub).to.be.calledOnceWith(submissionId); + expect(response).to.be.eql([ + { + feature_type_name: 'dataset', + feature_type_display_name: 'Dataset', + features: [ + { + ...mockSubmissionRecords[0], + data: { + name: 'name1', + description: 'description1' + } + } + ] + }, + { + feature_type_name: 'observation', + feature_type_display_name: 'Observation', + features: [ + { + ...mockSubmissionRecords[1], + data: { + geometry: { type: 'Point', coordinates: [100.0, 0.0] } + } + }, + { + ...mockSubmissionRecords[2], + data: { + name: 'name3' + } + } + ] + }, + { + feature_type_name: 'artifact', + feature_type_display_name: 'Artifact', + features: [ + { + ...mockSubmissionRecords[3], + data: { + start_date: '2024-02-02', + count: 15 + } + } + ] + } + ]); + }); + }); + describe('createMessages', () => { beforeEach(() => { sinon.restore(); diff --git a/api/src/services/submission-service.ts b/api/src/services/submission-service.ts index efdd7a54..e494a9ba 100644 --- a/api/src/services/submission-service.ts +++ b/api/src/services/submission-service.ts @@ -3,7 +3,9 @@ import { JSONPath } from 'jsonpath-plus'; import { z } from 'zod'; import { IDBConnection } from '../database/db'; import { ApiExecuteSQLError, ApiGeneralError } from '../errors/api-error'; +import { SubmissionFeatureSearchKeyValues } from '../repositories/search-index-respository'; import { + FeatureTypeRecord, IDatasetsForReview, ISourceTransformModel, ISubmissionFeature, @@ -32,6 +34,7 @@ import { getS3SignedURL } from '../utils/file-utils'; import { getLogger } from '../utils/logger'; import { EMLFile } from '../utils/media/eml/eml-file'; import { DBService } from './db-service'; +import { SearchIndexService } from './search-index-service'; const defaultLog = getLogger('submission-service'); @@ -641,7 +644,7 @@ export class SubmissionService extends DBService { } /** - * Retrieves submission features with type and name. + * Retrieves submission feature records with type, name, and security data included. * * @param {number} submissionId * @return {*} {Promise< @@ -685,6 +688,74 @@ export class SubmissionService extends DBService { return submissionFeatures; } + /** + * Retrieves submission features with type and name. + * + * Note: This method replaces the original feature data object with one built from only the search key values (from + * the `search_` tables). + * + * @param {number} submissionId + * @return {*} {Promise< + * { + * feature_type_name: string; + * feature_type_display_name: string; + * features: SubmissionFeatureRecordWithTypeAndSecurity[]; + * }[] + * >} + * @memberof SubmissionService + */ + async getSubmissionFeaturesWithSearchKeyValuesBySubmissionId(submissionId: number): Promise< + { + feature_type_name: string; + feature_type_display_name: string; + features: SubmissionFeatureRecordWithTypeAndSecurity[]; + }[] + > { + const uncategorizedFeatures = await this.submissionRepository.getSubmissionFeaturesBySubmissionId(submissionId); + + const searchIndexService = new SearchIndexService(this.connection); + const submissionFeatureSearchKeyValues = await searchIndexService.getSearchKeyValuesBySubmissionId(submissionId); + + console.log('111111111111111111111111111111111'); + console.log(JSON.stringify(submissionFeatureSearchKeyValues)); + console.log('111111111111111111111111111111111'); + + const categorizedFeatures: Record = {}; + + for (const feature of uncategorizedFeatures) { + const featureCategoryArray = categorizedFeatures[feature.feature_type_name]; + + const featureSearchKeyValueData = submissionFeatureSearchKeyValues + .filter((item) => item.submission_feature_id === feature.submission_feature_id) + .reduce((acc, obj) => { + acc[obj.feature_property_name] = obj.value; + return acc; + }, {} as Record); + + const featureWithSearchkeyValues = { + ...feature, + data: featureSearchKeyValueData // overwrite original data with search key values + }; + + if (featureCategoryArray) { + // Append to existing array of matching feature type + categorizedFeatures[featureWithSearchkeyValues.feature_type_name] = + featureCategoryArray.concat(featureWithSearchkeyValues); + } else { + // Create new array for feature type + categorizedFeatures[featureWithSearchkeyValues.feature_type_name] = [featureWithSearchkeyValues]; + } + } + + const submissionFeatures = Object.entries(categorizedFeatures).map(([featureType, submissionFeatures]) => ({ + feature_type_name: featureType, + feature_type_display_name: submissionFeatures[0].feature_type_display_name, + features: submissionFeatures + })); + + return submissionFeatures; + } + /** * Get all messages for a submission. * @@ -817,4 +888,15 @@ export class SubmissionService extends DBService { return signedUrl; } + + /** + * Get a sorted and distinct list of all unique feature type records for a submission. + * + * @param {number} submissionId + * @return {*} {Promise} + * @memberof SubmissionService + */ + async getSubmissionFeatureTypes(submissionId: number): Promise { + return this.submissionRepository.getSubmissionFeatureTypes(submissionId); + } } diff --git a/api/src/zod-schema/json.ts b/api/src/zod-schema/json.ts new file mode 100644 index 00000000..07456b56 --- /dev/null +++ b/api/src/zod-schema/json.ts @@ -0,0 +1,18 @@ +import * as z from 'zod'; + +const literalSchema = z.union([z.string(), z.number(), z.boolean(), z.null()]); + +type Literal = z.infer; + +type Json = Literal | { [key: string]: Json } | Json[]; + +// Defines a Zod Schema for a valid JSON value +// Not safe for massive JSON objects as it may cause a heap out of memory error +export const jsonSchema: z.ZodType = z.lazy(() => + z.union([literalSchema, z.array(jsonSchema), z.record(jsonSchema)]) +); + +// Defines a Zod Schema for a valid JSON value using shallow validation for use with massive JSON objects. +export const shallowJsonSchema: z.ZodType = z.lazy(() => + z.union([literalSchema, z.array(z.any()), z.record(z.string()), z.record(z.any())]) +);