diff --git a/api/apps/api/src/migrations/api/1645026803969-AddTokenIdColumnToLocks.ts b/api/apps/api/src/migrations/api/1645026803969-AddTokenIdColumnToLocks.ts index 9c9699885b..926997d4af 100644 --- a/api/apps/api/src/migrations/api/1645026803969-AddTokenIdColumnToLocks.ts +++ b/api/apps/api/src/migrations/api/1645026803969-AddTokenIdColumnToLocks.ts @@ -1,3 +1,4 @@ + import { MigrationInterface, QueryRunner } from 'typeorm'; export class AddTokenIdColumnToLocks1645026803969 diff --git a/api/apps/api/src/migrations/api/1697210673344-AddMinMaxAmountColumnsToFeatures.ts b/api/apps/api/src/migrations/api/1697210673344-AddMinMaxAmountColumnsToFeatures.ts new file mode 100644 index 0000000000..93626d8911 --- /dev/null +++ b/api/apps/api/src/migrations/api/1697210673344-AddMinMaxAmountColumnsToFeatures.ts @@ -0,0 +1,21 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class AddMinMaxAmountColumnsToFeatures1697210673344 implements MigrationInterface { + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE features + ADD COLUMN amount_min float8, + ADD COLUMN amount_max float8; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE features + DROP COLUMN amount_min float8, + DROP COLUMN amount_max float8; + `); + } + +} diff --git a/api/apps/api/src/modules/geo-features/geo-feature.api.entity.ts b/api/apps/api/src/modules/geo-features/geo-feature.api.entity.ts index 37e5578a22..6b2a9327e2 100644 --- a/api/apps/api/src/modules/geo-features/geo-feature.api.entity.ts +++ b/api/apps/api/src/modules/geo-features/geo-feature.api.entity.ts @@ -27,6 +27,11 @@ export interface GeoFeatureProperty { distinctValues: Array; } +export type FeatureAmountRange = { + min: number | null; + max: number | null; +}; + @Entity('features') export class GeoFeature extends BaseEntity { @ApiProperty() @@ -95,6 +100,12 @@ export class GeoFeature extends BaseEntity { @Column('boolean', { name: 'is_custom' }) isCustom?: boolean; + @Column('float8', { name: 'amount_min' }) + amountMin?: number; + + @Column('float8', { name: 'amount_max' }) + amountMax?: number; + @Column('boolean', { name: 'is_legacy' }) isLegacy!: boolean; @@ -109,6 +120,9 @@ export class GeoFeature extends BaseEntity { @ApiPropertyOptional() scenarioUsageCount?: number; + + @ApiPropertyOptional() + amountRange?: FeatureAmountRange; } export class JSONAPIGeoFeaturesData { diff --git a/api/apps/api/src/modules/geo-features/geo-features.controller.ts b/api/apps/api/src/modules/geo-features/geo-features.controller.ts index e02eaf91a2..ad3f896d34 100644 --- a/api/apps/api/src/modules/geo-features/geo-features.controller.ts +++ b/api/apps/api/src/modules/geo-features/geo-features.controller.ts @@ -56,7 +56,6 @@ import { mapAclDomainToHttpError } from '@marxan-api/utils/acl.utils'; ) export class GeoFeaturesController { constructor( - public readonly service: GeoFeaturesService, private readonly geoFeatureService: GeoFeaturesService, public readonly geoFeaturesTagService: GeoFeatureTagsService, private readonly proxyService: ProxyService, @@ -75,8 +74,10 @@ export class GeoFeaturesController { async findAll( @ProcessFetchSpecification() fetchSpecification: FetchSpecification, ): Promise { - const results = await this.service.findAllPaginated(fetchSpecification); - return this.service.serialize(results.data, results.metadata); + const results = await this.geoFeatureService.findAllPaginated( + fetchSpecification, + ); + return this.geoFeatureService.serialize(results.data, results.metadata); } @ImplementsAcl() diff --git a/api/apps/api/src/modules/geo-features/geo-features.service.ts b/api/apps/api/src/modules/geo-features/geo-features.service.ts index 830f427c13..812696aa04 100644 --- a/api/apps/api/src/modules/geo-features/geo-features.service.ts +++ b/api/apps/api/src/modules/geo-features/geo-features.service.ts @@ -143,6 +143,7 @@ export class GeoFeaturesService extends AppBaseService< 'isCustom', 'tag', 'scenarioUsageCount', + 'amountRange', ], keyForAttribute: 'camelCase', }; @@ -840,6 +841,38 @@ export class GeoFeaturesService extends AppBaseService< } as GeoFeature; } + async saveAmountRangeForFeatures(featureIds: string[]) { + this.logger.log(`Saving min and max amounts for new features...`); + + const minAndMaxAmountsForFeatures = await this.geoEntityManager + .createQueryBuilder() + .select('feature_id', 'id') + .addSelect('MIN(amount)', 'amountMin') + .addSelect('MAX(amount)', 'amountMax') + .from('puvspr_calculations', 'puvspr') + .where('puvspr.feature_id IN (:...featureIds)', { featureIds }) + .groupBy('puvspr.feature_id') + .getRawMany(); + + const minMaxSqlValueStringForFeatures = minAndMaxAmountsForFeatures + .map( + (feature) => + `(uuid('${feature.id}'), ${feature.amountMin}, ${feature.amountMax})`, + ) + .join(', '); + + const query = ` + update features set + amount_min = minmax.min, + amount_max = minmax.max + from ( + values + ${minMaxSqlValueStringForFeatures} + ) as minmax(feature_id, min, max) + where features.id = minmax.feature_id;`; + await this.geoFeaturesRepository.query(query); + } + async checkProjectFeatureVisibility( userId: string, projectId: string, diff --git a/api/apps/api/src/modules/geo-features/import/features-amounts-upload.service.ts b/api/apps/api/src/modules/geo-features/import/features-amounts-upload.service.ts index 2435a3ed7d..75f31629c9 100644 --- a/api/apps/api/src/modules/geo-features/import/features-amounts-upload.service.ts +++ b/api/apps/api/src/modules/geo-features/import/features-amounts-upload.service.ts @@ -1,4 +1,10 @@ -import { BadRequestException, Injectable, Logger } from '@nestjs/common'; +import { + BadRequestException, + forwardRef, + Inject, + Injectable, + Logger, +} from '@nestjs/common'; import { DbConnections } from '@marxan-api/ormconfig.connections'; import { DataSource, EntityManager, QueryRunner } from 'typeorm'; import { InjectDataSource, InjectEntityManager } from '@nestjs/typeorm'; @@ -6,6 +12,7 @@ import { featureAmountCsvParser } from '@marxan-api/modules/geo-features/import/ import { FeatureAmountCSVDto } from '@marxan-api/modules/geo-features/dto/feature-amount-csv.dto'; import { FeatureAmountUploadRegistry } from '@marxan-api/modules/geo-features/import/features-amounts-upload-registry.api.entity'; import { + GeoFeaturesService, importedFeatureNameAlreadyExist, unknownPuidsInFeatureAmountCsvUpload, } from '@marxan-api/modules/geo-features/geo-features.service'; @@ -19,6 +26,7 @@ import { CHUNK_SIZE_FOR_BATCH_APIDB_OPERATIONS } from '@marxan-api/utils/chunk-s import { UploadedFeatureAmount } from '@marxan-api/modules/geo-features/import/features-amounts-data.api.entity'; import { Project } from '@marxan-api/modules/projects/project.api.entity'; import { ProjectSourcesEnum } from '@marxan/projects'; +import { ScenariosService } from '@marxan-api/modules/scenarios/scenarios.service'; @Injectable() export class FeatureAmountUploadService { @@ -33,6 +41,8 @@ export class FeatureAmountUploadService { @InjectEntityManager(DbConnections.default) private readonly apiEntityManager: EntityManager, private readonly events: FeatureImportEventsService, + @Inject(forwardRef(() => GeoFeaturesService)) + private readonly geoFeaturesService: GeoFeaturesService, ) {} async uploadFeatureFromCsv(data: { @@ -107,11 +117,17 @@ export class FeatureAmountUploadService { apiQueryRunner.manager, ); + this.logger.log(`Saving min and max amounts for new features...`); + this.logger.log(`Csv file upload process finished successfully`); // Committing transaction await apiQueryRunner.commitTransaction(); await geoQueryRunner.commitTransaction(); + + await this.geoFeaturesService.saveAmountRangeForFeatures( + newFeaturesFromCsvUpload.map((feature) => feature.id), + ); } catch (err) { await this.events.failEvent(err); await apiQueryRunner.rollbackTransaction(); @@ -211,7 +227,7 @@ export class FeatureAmountUploadService { queryRunner: QueryRunner, uploadId: string, projectId: string, - ) { + ): Promise { const newFeaturesToCreate = ( await queryRunner.manager .createQueryBuilder() @@ -318,6 +334,10 @@ export class FeatureAmountUploadService { `, parameters, ); + await geoQueryRunner.manager.query( + ` INSERT INTO puvspr_calculations (project_id, feature_id, amount, project_pu_id) select $1, $2, amount, project_pu_id from features_data where feature_id = $2`, + [projectId, newFeature.id], + ); this.logger.log( `Chunk with index ${amountIndex} saved to (geoDB).features_data`, ); diff --git a/api/apps/api/src/modules/projects/projects.service.ts b/api/apps/api/src/modules/projects/projects.service.ts index ddbae825df..6d30c49bf5 100644 --- a/api/apps/api/src/modules/projects/projects.service.ts +++ b/api/apps/api/src/modules/projects/projects.service.ts @@ -178,16 +178,29 @@ export class ProjectsService { return project; } - return right( - await this.geoCrud.findAllPaginated(fetchSpec, { - ...appInfo, - params: { - ...appInfo.params, - projectId: project.right.id, - bbox: project.right.bbox, - }, + const result = await this.geoCrud.findAllPaginated(fetchSpec, { + ...appInfo, + params: { + ...appInfo.params, + projectId: project.right.id, + bbox: project.right.bbox, + }, + }); + + const resultWithMappedAmountRange = { + data: result.data.map((feature) => { + return { + ...feature, + amountRange: { + min: feature?.amountMin ?? null, + max: feature?.amountMax ?? null, + }, + }; }), - ); + metadata: result.metadata, + }; + + return right(resultWithMappedAmountRange); } async findAll(fetchSpec: FetchSpecification, info: ProjectsServiceRequest) { diff --git a/api/apps/api/test/geo-features.e2e-spec.ts b/api/apps/api/test/geo-features.e2e-spec.ts index 591305bcdd..265a60a725 100644 --- a/api/apps/api/test/geo-features.e2e-spec.ts +++ b/api/apps/api/test/geo-features.e2e-spec.ts @@ -9,7 +9,7 @@ import { import { bootstrapApplication } from './utils/api-application'; import { GivenUserIsLoggedIn } from './steps/given-user-is-logged-in'; -import { createWorld } from './project/projects-world'; +import { createWorld } from './projects/projects-world'; import { Repository } from 'typeorm'; import { ScenarioFeaturesData } from '@marxan/features'; import { v4 } from 'uuid'; @@ -65,6 +65,10 @@ describe('GeoFeaturesModule (e2e)', () => { const geoFeaturesForProject: GeoFeature[] = response.body.data; expect(geoFeaturesForProject.length).toBeGreaterThan(0); expect(response.body.data[0].type).toBe(geoFeatureResource.name.plural); + expect(response.body.data[0].attributes.amountRange).toEqual({ + min: null, + max: null, + }); }); test('should include correct scenarioUsageCounts for the given project', async () => { diff --git a/api/apps/api/test/upload-feature/import-files/feature_amount_upload.csv b/api/apps/api/test/upload-feature/import-files/feature_amount_upload.csv index 5b8aed641d..dc2d861d5c 100644 --- a/api/apps/api/test/upload-feature/import-files/feature_amount_upload.csv +++ b/api/apps/api/test/upload-feature/import-files/feature_amount_upload.csv @@ -1,4 +1,4 @@ puid,feat_1d666bd,feat_28135ef 1,4.245387225,0 2,4.245387225,0 -3,4.245387225,0 \ No newline at end of file +3,3.245387225,0 diff --git a/api/apps/api/test/upload-feature/upload-feature.fixtures.ts b/api/apps/api/test/upload-feature/upload-feature.fixtures.ts index 1719574abc..00fbb2cb28 100644 --- a/api/apps/api/test/upload-feature/upload-feature.fixtures.ts +++ b/api/apps/api/test/upload-feature/upload-feature.fixtures.ts @@ -271,6 +271,8 @@ export const getFixtures = async () => { featureClassName: name, description, alias: null, + amountMax: null, + amountMin: null, propertyName: null, intersection: null, creationStatus: `done`, @@ -305,6 +307,8 @@ export const getFixtures = async () => { expect(newFeaturesAdded[0].projectId).toBe(projectId); expect(newFeaturesAdded[0].isLegacy).toBe(true); expect(newFeaturesAdded[1].isLegacy).toBe(true); + expect(newFeaturesAdded[0].amountMin).toEqual(3.245387225); + expect(newFeaturesAdded[0].amountMax).toEqual(4.245387225); }, ThenNewFeaturesAmountsAreCreated: async () => { @@ -320,6 +324,9 @@ export const getFixtures = async () => { }); const newFeature1Amounts = await featuresAmounsGeoDbRepository.find({ where: { featureId: newFeatures1?.id }, + order: { + amount: 'DESC', + }, }); const newFeature2Amounts = await featuresAmounsGeoDbRepository.find({ where: { featureId: newFeatures2?.id }, @@ -329,7 +336,7 @@ export const getFixtures = async () => { expect(newFeature2Amounts).toHaveLength(3); expect(newFeature1Amounts[0].amount).toBe(4.245387225); expect(newFeature1Amounts[1].amount).toBe(4.245387225); - expect(newFeature1Amounts[2].amount).toBe(4.245387225); + expect(newFeature1Amounts[2].amount).toBe(3.245387225); expect(newFeature2Amounts[0].amount).toBe(0); expect(newFeature2Amounts[1].amount).toBe(0); diff --git a/api/apps/geoprocessing/src/export/pieces-exporters/project-custom-features.piece-exporter.ts b/api/apps/geoprocessing/src/export/pieces-exporters/project-custom-features.piece-exporter.ts index a51bd975eb..a565406a86 100644 --- a/api/apps/geoprocessing/src/export/pieces-exporters/project-custom-features.piece-exporter.ts +++ b/api/apps/geoprocessing/src/export/pieces-exporters/project-custom-features.piece-exporter.ts @@ -32,6 +32,8 @@ type ProjectCustomFeaturesSelectResult = { list_property_keys: string[]; is_legacy: boolean; tag: string; + amount_min: number | null; + amount_max: number | null; }; type FeaturesDataSelectResult = { @@ -81,6 +83,8 @@ export class ProjectCustomFeaturesPieceExporter 'f.creation_status', 'f.list_property_keys', 'f.is_legacy', + 'f.amount_min', + 'f.amount_max', 'pft.tag', ]) .from('features', 'f') diff --git a/api/apps/geoprocessing/src/migrations/geoprocessing/1697707458392-AddIndexToFeatureIdOfPuvsprCalculations.ts b/api/apps/geoprocessing/src/migrations/geoprocessing/1697707458392-AddIndexToFeatureIdOfPuvsprCalculations.ts new file mode 100644 index 0000000000..8ff9f32241 --- /dev/null +++ b/api/apps/geoprocessing/src/migrations/geoprocessing/1697707458392-AddIndexToFeatureIdOfPuvsprCalculations.ts @@ -0,0 +1,15 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddIndexToFeatureIdOfPuvsprCalculations1697707458392 + implements MigrationInterface +{ + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE INDEX puvspr_calculations_feature_id__idx ON "puvspr_calculations" ("feature_id") `, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX puvspr_calculations_feature_id__idx`); + } +}