diff --git a/api/apps/api/src/migrations/api/1692296547437-AddUploadingProtectedAreaShapefileToProjectEvents.ts b/api/apps/api/src/migrations/api/1692296547437-AddUploadingProtectedAreaShapefileToProjectEvents.ts index ee56bf8640..7b707a2849 100644 --- a/api/apps/api/src/migrations/api/1692296547437-AddUploadingProtectedAreaShapefileToProjectEvents.ts +++ b/api/apps/api/src/migrations/api/1692296547437-AddUploadingProtectedAreaShapefileToProjectEvents.ts @@ -3,7 +3,6 @@ import { MigrationInterface, QueryRunner } from 'typeorm'; export class AddUploadingProtectedAreaShapefileToProjectEvents1692296547437 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { - await queryRunner.query(` INSERT INTO api_event_kinds (id) values ('project.protectedAreas.submitted/v1/alpha'), @@ -13,7 +12,6 @@ export class AddUploadingProtectedAreaShapefileToProjectEvents1692296547437 } public async down(queryRunner: QueryRunner): Promise { - await queryRunner.query( `DELETE FROM api_event_kinds WHERE id = 'project.protectedAreas.submitted/v1/alpha';`, ); diff --git a/api/apps/api/src/modules/async-jobs-garbage-collector/project-async-jobs.garbage-collector.ts b/api/apps/api/src/modules/async-jobs-garbage-collector/project-async-jobs.garbage-collector.ts index a6a73d193f..282a94d4c4 100644 --- a/api/apps/api/src/modules/async-jobs-garbage-collector/project-async-jobs.garbage-collector.ts +++ b/api/apps/api/src/modules/async-jobs-garbage-collector/project-async-jobs.garbage-collector.ts @@ -5,7 +5,8 @@ import { PlanningUnitsAsyncJob, ProjectCloneAsyncJob, ProjectExportAsyncJob, - ProjectImportAsyncJob, ProtectedAreasAsyncJob, + ProjectImportAsyncJob, + ProtectedAreasAsyncJob, } from './async-jobs'; import { AsyncJobsGarbageCollector } from './async-jobs.garbage-collector'; @@ -20,18 +21,26 @@ export class ProjectAsyncJobsGarbageCollector private readonly projectExportAsyncJob: ProjectExportAsyncJob, private readonly projectImportAsyncJob: ProjectImportAsyncJob, private readonly protectedAreasAsyncJob: ProtectedAreasAsyncJob, - ) {} public async sendFailedApiEventsForStuckAsyncJobs(projectId: string) { await this.gridAsyncJob.sendFailedApiEventForStuckAsyncJob(projectId); await this.legacyImportAsyncJob.sendFailedApiEventForStuckAsyncJob( projectId, ); - await this.planningUnitsAsyncJob.sendFailedApiEventForStuckAsyncJob(projectId); - await this.projectCloneAsyncJob.sendFailedApiEventForStuckAsyncJob(projectId); - await this.projectExportAsyncJob.sendFailedApiEventForStuckAsyncJob(projectId); - await this.projectImportAsyncJob.sendFailedApiEventForStuckAsyncJob(projectId); - await this.protectedAreasAsyncJob.sendFailedApiEventForStuckAsyncJob(projectId); - + await this.planningUnitsAsyncJob.sendFailedApiEventForStuckAsyncJob( + projectId, + ); + await this.projectCloneAsyncJob.sendFailedApiEventForStuckAsyncJob( + projectId, + ); + await this.projectExportAsyncJob.sendFailedApiEventForStuckAsyncJob( + projectId, + ); + await this.projectImportAsyncJob.sendFailedApiEventForStuckAsyncJob( + projectId, + ); + await this.protectedAreasAsyncJob.sendFailedApiEventForStuckAsyncJob( + projectId, + ); } } diff --git a/api/apps/api/src/modules/projects/project-protected-areas.controller.ts b/api/apps/api/src/modules/projects/project-protected-areas.controller.ts index efbcd88dec..21637dc0d2 100644 --- a/api/apps/api/src/modules/projects/project-protected-areas.controller.ts +++ b/api/apps/api/src/modules/projects/project-protected-areas.controller.ts @@ -33,8 +33,8 @@ import { FetchSpecification, ProcessFetchSpecification, } from 'nestjs-base-service'; -import { JSONAPIQueryParams } from "@marxan-api/decorators/json-api-parameters.decorator"; -import { ProtectedAreaResult } from "@marxan-api/modules/protected-areas/protected-area.geo.entity"; +import { JSONAPIQueryParams } from '@marxan-api/decorators/json-api-parameters.decorator'; +import { ProtectedAreaResult } from '@marxan-api/modules/protected-areas/protected-area.geo.entity'; @UseGuards(JwtAuthGuard) @ApiBearerAuth() diff --git a/api/apps/api/src/modules/projects/projects.module.ts b/api/apps/api/src/modules/projects/projects.module.ts index ecbd24bdf7..0b21e0b1f3 100644 --- a/api/apps/api/src/modules/projects/projects.module.ts +++ b/api/apps/api/src/modules/projects/projects.module.ts @@ -100,7 +100,7 @@ import { CostSurfaceModule } from '@marxan-api/modules/cost-surface/cost-surface ProtectedAreaModule, ProtectedAreasCrudModule, CostSurfaceModule, - ProtectedAreaModule + ProtectedAreaModule, ], providers: [ ProjectProtectedAreasService, diff --git a/api/apps/api/src/modules/projects/projects.service.ts b/api/apps/api/src/modules/projects/projects.service.ts index 4dcbffa273..077cc6c73e 100644 --- a/api/apps/api/src/modules/projects/projects.service.ts +++ b/api/apps/api/src/modules/projects/projects.service.ts @@ -124,7 +124,7 @@ import { AddProtectedAreaService, submissionFailed, } from '@marxan-api/modules/projects/protected-area/add-protected-area.service'; -import {ensureShapefileHasRequiredFiles} from "@marxan-api/utils/file-uploads.utils"; +import { ensureShapefileHasRequiredFiles } from '@marxan-api/utils/file-uploads.utils'; export { validationFailed } from '../planning-areas'; diff --git a/api/apps/api/src/modules/protected-areas/protected-areas-crud.service.ts b/api/apps/api/src/modules/protected-areas/protected-areas-crud.service.ts index cd3c4f89cf..116c854480 100644 --- a/api/apps/api/src/modules/protected-areas/protected-areas-crud.service.ts +++ b/api/apps/api/src/modules/protected-areas/protected-areas-crud.service.ts @@ -8,7 +8,7 @@ import { } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { AppInfoDTO } from '@marxan-api/dto/info.dto'; -import { Brackets, IsNull, Not, Repository, SelectQueryBuilder} from 'typeorm'; +import { Brackets, IsNull, Not, Repository, SelectQueryBuilder } from 'typeorm'; import { CreateProtectedAreaDTO } from './dto/create.protected-area.dto'; import { UpdateProtectedAreaDTO } from './dto/update.protected-area.dto'; import { ProtectedArea } from '@marxan/protected-areas'; @@ -30,7 +30,7 @@ import { AppConfig } from '@marxan-api/utils/config.utils'; import { IUCNCategory } from '@marxan/iucn'; import { isDefined } from '@marxan/utils'; import { Scenario } from '../scenarios/scenario.api.entity'; -import _, {groupBy, intersection, isArray} from 'lodash'; +import { groupBy, intersection, isArray } from 'lodash'; import { ProjectSnapshot } from '@marxan/projects'; import { SelectionGetService } from '@marxan-api/modules/scenarios/protected-area/getter/selection-get.service'; import { Either, left, right } from 'fp-ts/Either'; @@ -210,11 +210,13 @@ export class ProtectedAreasCrudService extends AppBaseService< project, ); - const uniqueCategoryGlobalProtectedAreasIds: string[] = [] + const uniqueCategoryGlobalProtectedAreasIds: string[] = []; for (const category of projectGlobalProtectedAreasData.categories) { - const wdpaOfCategorySample = await this.repository.findOneOrFail({where: {iucnCategory: category as IUCNCategory}}) - uniqueCategoryGlobalProtectedAreasIds.push(wdpaOfCategorySample.id) + const wdpaOfCategorySample = await this.repository.findOneOrFail({ + where: { iucnCategory: category as IUCNCategory }, + }); + uniqueCategoryGlobalProtectedAreasIds.push(wdpaOfCategorySample.id); } query.andWhere( @@ -223,9 +225,9 @@ export class ProtectedAreasCrudService extends AppBaseService< projectId: project.id, }).orWhere(`${this.alias}.id in (:...ids)`, { ids: uniqueCategoryGlobalProtectedAreasIds, - }) + }); }), - ) + ); } if (Array.isArray(info?.params?.ids) && info?.params?.ids.length) { @@ -362,71 +364,44 @@ export class ProtectedAreasCrudService extends AppBaseService< /** * Get a list of all the protected areas that are linked to a given project, - * and add a count of the number of scenarios where each protected area is - * used. + * apply search, filtering and sorting if requested, and add a count of the + * number of scenarios where each protected area is used. */ info!.params.project = project; - /** if the fetchSpecification contains sort or filter with 'name' property, we need to remove it from fetch specification - * used for base service findAll method, because 'name' column does not exist in protected_areas table and error will occur - * Filtering and sorting by 'name' will be done manually later in this method - */ + let projectProtectedAreas = await this.selectionGetService.getForProject(project) - const editedFetchSpecification = JSON.parse(JSON.stringify(fetchSpecification)) - if(fetchSpecification?.filter?.name) { - delete editedFetchSpecification?.filter?.name; + if (info?.params?.fullNameAndCategoryFilter) { + projectProtectedAreas = this.applySearchToProtectedAreas(projectProtectedAreas, info.params.fullNameAndCategoryFilter) } - if(fetchSpecification?.sort?.includes('name') || fetchSpecification?.sort?.includes('-name')) { - editedFetchSpecification?.sort?.splice(editedFetchSpecification?.sort?.indexOf('name'), 1); - editedFetchSpecification?.sort?.splice(editedFetchSpecification?.sort?.indexOf('-name'), 1); + if (fetchSpecification?.filter?.name) { + projectProtectedAreas = this.applyNameFilterToProtectedAreas(projectProtectedAreas, fetchSpecification?.filter?.name as string[]) } - const projectProtectedAreas = await this.findAll( - editedFetchSpecification, - info, - ); + if ( + fetchSpecification?.sort + ) { + const order: 'asc' | 'desc' = fetchSpecification?.sort?.includes('-name') ? 'desc' : 'asc' + projectProtectedAreas = this.sortProtectedAreasByName(projectProtectedAreas, order) + } - let result: any[] = []; + const result: ProtectedArea[] = []; - projectProtectedAreas[0].forEach((protectedArea: any) => { + projectProtectedAreas.forEach((protectedArea: any) => { const scenarioUsageCount: number = protectedAreaUsedInProjectScenarios[ protectedArea!.id! ] ? protectedAreaUsedInProjectScenarios[protectedArea!.id!].length : 0; - const name = protectedArea.fullName === null ? protectedArea.iucnCategory : protectedArea.fullName; result.push({ ...protectedArea, - scenarioUsageCount, - isCustom: protectedArea!.projectId !== null, - name + scenarioUsageCount: protectedArea.isCustom ? scenarioUsageCount : 1, }); }); - - // Applying filtering and sorting manually for 'name' property - - if(fetchSpecification?.filter?.name) { - const filterNames: string[] = fetchSpecification?.filter?.name as string[]; - result = result.filter((pa) => { - return filterNames.includes(pa.name) - }); - } - - if (fetchSpecification?.sort?.includes('name') || fetchSpecification?.sort?.includes('-name')) { - if(fetchSpecification?.sort?.includes('name')) { - result.sort((a, b) => a.name.localeCompare(b.name)); - } else { - result.sort((a, b) => b.name.localeCompare(a.name)); - } - - } - - // Serialising final result - const serializer = new JSONAPISerializer.Serializer( 'protected_areas', this.serializerConfig, ); @@ -522,4 +497,24 @@ export class ProtectedAreasCrudService extends AppBaseService< return right(true); } + + private applySearchToProtectedAreas(protectedAreas: Partial[], search: string) { + return protectedAreas.filter((protectedArea) => { + return protectedArea!.name!.toLowerCase().includes(search.toLowerCase()); + }); + } + + private applyNameFilterToProtectedAreas(protectedAreas: Partial[], filterNames: string[]) { + return protectedAreas.filter((protectedArea) => { + return filterNames.includes(protectedArea!.name!); + }); + } + + private sortProtectedAreasByName(protectedAreas: Partial[], order: 'asc' | 'desc') { + if (order === 'asc') { + return protectedAreas.sort((a, b) => a!.name!.localeCompare(b!.name!)); + } else { + return protectedAreas.sort((a, b) => b!.name!.localeCompare(a!.name!)); + } + } } diff --git a/api/apps/api/src/modules/scenarios/protected-area/getter/selection-get.service.ts b/api/apps/api/src/modules/scenarios/protected-area/getter/selection-get.service.ts index d1a0d21d49..8a456a4d12 100644 --- a/api/apps/api/src/modules/scenarios/protected-area/getter/selection-get.service.ts +++ b/api/apps/api/src/modules/scenarios/protected-area/getter/selection-get.service.ts @@ -56,6 +56,31 @@ export class SelectionGetService { ]; } + async getForProject( + project: ProjectSnapshot, + ): Promise[]> { + const { areas, categories } = await this.getGlobalProtectedAreas(project); + + const projectCustomAreas = await this.repository.find({ + where: { + projectId: project.id, + }, + }); + + return [ + ...categories.map((category) => ({ + name: category, + id: category, + isCustom: false, + })), + ...projectCustomAreas.map((area) => ({ + name: area.fullName ?? '', + id: area.id, + isCustom: true, + })), + ]; + } + async getGlobalProtectedAreas( project: Pick< ProjectSnapshot, diff --git a/api/apps/api/src/modules/scenarios/scenarios.controller.ts b/api/apps/api/src/modules/scenarios/scenarios.controller.ts index d232106498..d7e167ce4a 100644 --- a/api/apps/api/src/modules/scenarios/scenarios.controller.ts +++ b/api/apps/api/src/modules/scenarios/scenarios.controller.ts @@ -108,9 +108,9 @@ import { RequestScenarioCloneResponseDto } from './dto/scenario-clone.dto'; import { ensureShapefileHasRequiredFiles } from '@marxan-api/utils/file-uploads.utils'; import { WebshotPdfReportConfig } from '@marxan/webshot/webshot.dto'; import { ClearLockStatusParams } from '@marxan-api/modules/scenarios/dto/clear-lock-status-param.dto'; -import { CostRangeDto } from "@marxan-api/modules/scenarios/dto/cost-range.dto"; -import { plainToClass } from "class-transformer"; -import { ProjectsService } from "@marxan-api/modules/projects/projects.service"; +import { CostRangeDto } from '@marxan-api/modules/scenarios/dto/cost-range.dto'; +import { plainToClass } from 'class-transformer'; +import { ProjectsService } from '@marxan-api/modules/projects/projects.service'; const basePath = `${apiGlobalPrefixes.v1}/scenarios`; const solutionsSubPath = `:id/marxan/solutions`; @@ -138,7 +138,7 @@ export class ScenariosController { private readonly zipFilesSerializer: ZipFilesSerializer, private readonly planningUnitsSerializer: ScenarioPlanningUnitSerializer, private readonly scenarioAclService: ScenarioAccessControl, - private readonly projectsService: ProjectsService + private readonly projectsService: ProjectsService, ) {} @ApiOperation({ @@ -1449,7 +1449,6 @@ export class ScenariosController { }); } - const outcome = await this.projectsService.addProtectedAreaFor( scenario.right.projectId, file, diff --git a/api/apps/api/test/scenario-protected-areas/fixtures.ts b/api/apps/api/test/scenario-protected-areas/fixtures.ts index 07f9dbf172..6468cb84c3 100644 --- a/api/apps/api/test/scenario-protected-areas/fixtures.ts +++ b/api/apps/api/test/scenario-protected-areas/fixtures.ts @@ -99,18 +99,30 @@ export const getFixtures = async () => { .get(`/api/v1/projects/${projectId}/protected-areas`) .set('Authorization', `Bearer ${ownerToken}`) .then((response) => response.body), - WhenGettingProtectedAreasListForProjectWithSearch: async (projectId: string, search: string) => + WhenGettingProtectedAreasListForProjectWithSearch: async ( + projectId: string, + search: string, + ) => request(app.getHttpServer()) .get(`/api/v1/projects/${projectId}/protected-areas?q=${search}`) .set('Authorization', `Bearer ${ownerToken}`) .then((response) => response.body), - WhenGettingProtectedAreasListForProjectWithFilter: async (projectId: string, filterColumn: string, filterValue: string) => + WhenGettingProtectedAreasListForProjectWithFilter: async ( + projectId: string, + filterColumn: string, + filterValue: string, + ) => request(app.getHttpServer()) - .get(`/api/v1/projects/${projectId}/protected-areas?filter[${filterColumn}]=${filterValue}`) + .get( + `/api/v1/projects/${projectId}/protected-areas?filter[${filterColumn}]=${filterValue}`, + ) .set('Authorization', `Bearer ${ownerToken}`) .then((response) => response.body), - WhenGettingProtectedAreasListForProjectWithSort: async (projectId: string, sortValue: string) => + WhenGettingProtectedAreasListForProjectWithSort: async ( + projectId: string, + sortValue: string, + ) => request(app.getHttpServer()) .get(`/api/v1/projects/${projectId}/protected-areas?sort=${sortValue}`) .set('Authorization', `Bearer ${ownerToken}`) @@ -129,7 +141,7 @@ export const getFixtures = async () => { response.data.sort(); expect(response.data).toHaveLength(2); expect(response.data[0].attributes.isCustom).toBe(false); - expect(response.data[1].attributes.fullName).toBe( + expect(response.data[1].attributes.name).toBe( 'custom protected area', ); expect(response.data[1].attributes.scenarioUsageCount).toBe(1); @@ -139,7 +151,7 @@ export const getFixtures = async () => { response.data.sort(); expect(response.data).toHaveLength(1); expect(response.data[0].attributes.isCustom).toBe(true); - expect(response.data[0].attributes.fullName).toBe( + expect(response.data[0].attributes.name).toBe( 'custom protected area', ); expect(response.data[0].attributes.scenarioUsageCount).toBe(1); @@ -148,9 +160,7 @@ export const getFixtures = async () => { response.data.sort(); expect(response.data).toHaveLength(1); expect(response.data[0].attributes.isCustom).toBe(false); - expect(response.data[0].attributes.iucnCategory).toBe( - 'III', - ); + expect(response.data[0].attributes.name).toBe('III'); expect(response.data[0].attributes.scenarioUsageCount).toBe(1); }, @@ -158,14 +168,9 @@ export const getFixtures = async () => { response.data.sort(); expect(response.data).toHaveLength(2); expect(response.data[0].attributes.isCustom).toBe(true); - expect(response.data[0].attributes.name).toBe( - 'custom protected area', - ); + expect(response.data[0].attributes.name).toBe('custom protected area'); expect(response.data[1].attributes.isCustom).toBe(false); - expect(response.data[1].attributes.name).toBe( - 'III', - ); - + expect(response.data[1].attributes.name).toBe('III'); }, GivenCustomProtectedAreaWasAddedToProject: async () => { diff --git a/api/apps/api/test/scenario-protected-areas/list-project-protected-ares.e2e-spec.ts b/api/apps/api/test/scenario-protected-areas/list-project-protected-ares.e2e-spec.ts index daf4b65462..948c4edfa5 100644 --- a/api/apps/api/test/scenario-protected-areas/list-project-protected-ares.e2e-spec.ts +++ b/api/apps/api/test/scenario-protected-areas/list-project-protected-ares.e2e-spec.ts @@ -35,7 +35,8 @@ test(`getting list of protected areas with scenario usage count for a project an areaId, ); const areas = await fixtures.WhenGettingProtectedAreasListForProjectWithSearch( - projectId, 'custom' + projectId, + 'custom', ); await fixtures.ThenItContainsSearchedProtectedAreas(areas); }); @@ -50,7 +51,9 @@ test(`getting list of protected areas with scenario usage count for a project an areaId, ); const areas = await fixtures.WhenGettingProtectedAreasListForProjectWithFilter( - projectId, 'iucnCategory', 'III' + projectId, + 'name', + 'III', ); await fixtures.ThenItContainsFilteredProtectedAreas(areas); }); @@ -66,7 +69,8 @@ test(`getting list of protected areas with scenario usage count for a project an areaId, ); const areas = await fixtures.WhenGettingProtectedAreasListForProjectWithSort( - projectId, 'name' + projectId, + 'name', ); await fixtures.ThenItContainsSortedProtectedAreas(areas); }); diff --git a/api/apps/geoprocessing/src/modules/features/features.service.ts b/api/apps/geoprocessing/src/modules/features/features.service.ts index 701e878ff3..7c6c6bebf9 100644 --- a/api/apps/geoprocessing/src/modules/features/features.service.ts +++ b/api/apps/geoprocessing/src/modules/features/features.service.ts @@ -72,7 +72,7 @@ export class FeatureService { bbox?: BBox, ): Promise { const { z, x, y, id } = tileSpecification; - const simplificationLevel = 360/(Math.pow(2, z+1)*100); + const simplificationLevel = 360 / (Math.pow(2, z + 1) * 100); const attributes = 'feature_id, properties'; const table = `(select ST_RemoveRepeatedPoints((st_dump(the_geom)).geom, ${simplificationLevel}) as the_geom, (coalesce(properties,'{}'::jsonb) || jsonb_build_object('amount', amount)) as properties, feature_id from "${this.featuresRepository.metadata.tableName}")`; const customQuery = this.buildFeaturesWhereQuery(id, bbox);