diff --git a/api/apps/api/src/migrations/api/1695040870835-AddClonePieceForProjectCostSurfaces.ts b/api/apps/api/src/migrations/api/1695040870835-AddClonePieceForProjectCostSurfaces.ts new file mode 100644 index 0000000000..7eb4800da7 --- /dev/null +++ b/api/apps/api/src/migrations/api/1695040870835-AddClonePieceForProjectCostSurfaces.ts @@ -0,0 +1,52 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddClonePieceForProjectCostSurfaces1695040870835 + implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TYPE clone_piece_enum ADD VALUE 'project-cost-surfaces'`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TYPE "clone_piece_enum_tmp" AS ENUM( + 'export-config', + 'project-metadata', + 'project-custom-protected-areas', + 'planning-area-gadm', + 'planning-area-custom', + 'planning-area-custom-geojson', + 'planning-units-grid', + 'planning-units-grid-geojson', + 'scenario-metadata', + 'scenario-planning-units-data', + 'scenario-run-results', + 'scenario-protected-areas', + 'project-custom-features', + 'features-specification', + 'scenario-features-data', + 'scenario-input-folder', + 'scenario-output-folder', + 'marxan-execution-metadata', + 'project-puvspr-calculations' + ); + `); + + await queryRunner.query(` + ALTER TABLE export_components + ALTER COLUMN piece TYPE clone_piece_enum_tmp; + `); + await queryRunner.query(` + ALTER TABLE import_components + ALTER COLUMN piece TYPE clone_piece_enum_tmp; + `); + + await queryRunner.query(` + DROP TYPE clone_piece_enum; + `); + await queryRunner.query(` + ALTER TYPE clone_piece_enum_tmp RENAME TO clone_piece_enum; + `); + } +} diff --git a/api/apps/api/src/modules/clone/export/adapters/export-resource-pieces.adapter.spec.ts b/api/apps/api/src/modules/clone/export/adapters/export-resource-pieces.adapter.spec.ts index 509b5888ac..c40f1f32eb 100644 --- a/api/apps/api/src/modules/clone/export/adapters/export-resource-pieces.adapter.spec.ts +++ b/api/apps/api/src/modules/clone/export/adapters/export-resource-pieces.adapter.spec.ts @@ -97,6 +97,7 @@ const getFixtures = async () => { ClonePiece.PlanningUnitsGrid, ClonePiece.PlanningUnitsGridGeojson, ClonePiece.ProjectCustomProtectedAreas, + ClonePiece.ProjectCostSurfaces, ClonePiece.ProjectCustomFeatures, ClonePiece.ProjectPuvsprCalculations, ]; diff --git a/api/apps/api/src/modules/clone/export/adapters/export-resource-pieces.adapter.ts b/api/apps/api/src/modules/clone/export/adapters/export-resource-pieces.adapter.ts index 9984f433d2..271193f6f1 100644 --- a/api/apps/api/src/modules/clone/export/adapters/export-resource-pieces.adapter.ts +++ b/api/apps/api/src/modules/clone/export/adapters/export-resource-pieces.adapter.ts @@ -56,6 +56,7 @@ export class ExportResourcePiecesAdapter implements ExportResourcePieces { ExportComponent.newOne(id, ClonePiece.PlanningUnitsGrid), ExportComponent.newOne(id, ClonePiece.PlanningUnitsGridGeojson), ExportComponent.newOne(id, ClonePiece.ProjectCustomProtectedAreas), + ExportComponent.newOne(id, ClonePiece.ProjectCostSurfaces), ExportComponent.newOne(id, ClonePiece.ProjectCustomFeatures), ExportComponent.newOne(id, ClonePiece.ProjectPuvsprCalculations), ...scenarioPieces, diff --git a/api/apps/api/src/modules/cost-surface/cost-surface.service.ts b/api/apps/api/src/modules/cost-surface/cost-surface.service.ts index 4e31a0ba6a..fafbe7dad3 100644 --- a/api/apps/api/src/modules/cost-surface/cost-surface.service.ts +++ b/api/apps/api/src/modules/cost-surface/cost-surface.service.ts @@ -4,7 +4,10 @@ import { CostSurface } from '@marxan-api/modules/cost-surface/cost-surface.api.e import { Repository } from 'typeorm'; import { ProjectAclService } from '@marxan-api/modules/access-control/projects-acl/project-acl.service'; import { Either, left, right } from 'fp-ts/lib/Either'; -import { projectNotEditable, projectNotVisible } from "@marxan-api/modules/projects/projects.service"; +import { + projectNotEditable, + projectNotVisible, +} from '@marxan-api/modules/projects/projects.service'; import { UploadCostSurfaceShapefileDto } from '@marxan-api/modules/cost-surface/dto/upload-cost-surface-shapefile.dto'; import { UpdateCostSurfaceDto } from '@marxan-api/modules/cost-surface/dto/update-cost-surface.dto'; import { CostSurfaceCalculationPort } from '@marxan-api/modules/cost-surface/ports/project/cost-surface-calculation.port'; @@ -149,12 +152,7 @@ export class CostSurfaceService { CostRange > > { - if ( - !(await this.projectAclService.canViewProject( - userId, - projectId, - )) - ) { + if (!(await this.projectAclService.canViewProject(userId, projectId))) { return left(projectNotVisible); } const costRange = await this.costSurfaceRepository.findOne({ diff --git a/api/apps/api/src/modules/projects/project-cost-surface.controller.ts b/api/apps/api/src/modules/projects/project-cost-surface.controller.ts index 182a63ca97..eb9b70cee4 100644 --- a/api/apps/api/src/modules/projects/project-cost-surface.controller.ts +++ b/api/apps/api/src/modules/projects/project-cost-surface.controller.ts @@ -133,7 +133,8 @@ export class ProjectCostSurfaceController { @UseGuards(JwtAuthGuard) @ApiParam({ name: 'costSurfaceId', - description: 'The id of the Cost Surface for which to retrieve [min,max] cost range', + description: + 'The id of the Cost Surface for which to retrieve [min,max] cost range', }) @ApiParam({ name: 'projectId', diff --git a/api/apps/api/src/modules/projects/projects.service.ts b/api/apps/api/src/modules/projects/projects.service.ts index 33c7f632d0..e3ab41386f 100644 --- a/api/apps/api/src/modules/projects/projects.service.ts +++ b/api/apps/api/src/modules/projects/projects.service.ts @@ -109,7 +109,10 @@ import { GetScenarioFailure } from '@marxan-api/modules/blm/values/blm-repos'; import stream from 'stream'; import { AppConfig } from '@marxan-api/utils/config.utils'; import { WebshotBasicPdfConfig } from '@marxan/webshot/webshot.dto'; -import { ScenariosService } from '@marxan-api/modules/scenarios/scenarios.service'; +import { + ScenariosService, + SubmitProtectedAreaError, +} from '@marxan-api/modules/scenarios/scenarios.service'; import { OutputProjectSummariesService, outputProjectSummaryNotFound, @@ -122,7 +125,6 @@ import { } from '@marxan-api/modules/projects/protected-area/add-protected-area.service'; import { ensureShapefileHasRequiredFiles } from '@marxan-api/utils/file-uploads.utils'; import { CostSurfaceService } from '@marxan-api/modules/cost-surface/cost-surface.service'; - export { validationFailed } from '../planning-areas'; export const projectNotFound = Symbol(`project not found`); diff --git a/api/apps/geoprocessing/src/export/pieces-exporters/pieces-exporters.module.ts b/api/apps/geoprocessing/src/export/pieces-exporters/pieces-exporters.module.ts index 7bc8df7462..0f0e3cfb8e 100644 --- a/api/apps/geoprocessing/src/export/pieces-exporters/pieces-exporters.module.ts +++ b/api/apps/geoprocessing/src/export/pieces-exporters/pieces-exporters.module.ts @@ -30,6 +30,7 @@ import { ScenarioOutputFolderPieceExporter } from './scenario-output-folder.piec import { ScenarioPlanningUnitsDataPieceExporter } from './scenario-planning-units-data.piece-exporter'; import { ScenarioProtectedAreasPieceExporter } from './scenario-protected-areas.piece-exporter'; import { ScenarioRunResultsPieceExporter } from './scenario-run-results.piece-exporter'; +import { ProjectCostSurfacesPieceExporter } from '@marxan-geoprocessing/export/pieces-exporters/project-cost-surfaces.piece-exporter'; @Module({ imports: [ @@ -52,6 +53,7 @@ import { ScenarioRunResultsPieceExporter } from './scenario-run-results.piece-ex ExportConfigProjectPieceExporter, ExportConfigScenarioPieceExporter, ProjectCustomFeaturesPieceExporter, + ProjectCostSurfacesPieceExporter, PlanningAreaGadmPieceExporter, PlanningAreaCustomPieceExporter, PlanningAreaCustomGeojsonPieceExporter, diff --git a/api/apps/geoprocessing/src/export/pieces-exporters/project-cost-surfaces.piece-exporter.ts b/api/apps/geoprocessing/src/export/pieces-exporters/project-cost-surfaces.piece-exporter.ts new file mode 100644 index 0000000000..95c2ed19e9 --- /dev/null +++ b/api/apps/geoprocessing/src/export/pieces-exporters/project-cost-surfaces.piece-exporter.ts @@ -0,0 +1,144 @@ +import { geoprocessingConnections } from '@marxan-geoprocessing/ormconfig'; +import { ClonePiece, ExportJobInput, ExportJobOutput } from '@marxan/cloning'; +import { ComponentLocation, ResourceKind } from '@marxan/cloning/domain'; +import { ClonePieceRelativePathResolver } from '@marxan/cloning/infrastructure/clone-piece-data'; +import { + ProjectCustomFeature, + ProjectCustomFeaturesContent, +} from '@marxan/cloning/infrastructure/clone-piece-data/project-custom-features'; +import { CloningFilesRepository } from '@marxan/cloning-files-repository'; +import { GeometrySource } from '@marxan/geofeatures'; +import { Injectable, Logger } from '@nestjs/common'; +import { InjectEntityManager } from '@nestjs/typeorm'; +import { isLeft } from 'fp-ts/lib/Either'; +import { Readable } from 'stream'; +import { EntityManager } from 'typeorm'; +import { + ExportPieceProcessor, + PieceExportProvider, +} from '../pieces/export-piece-processor'; +import { ProjectsPuEntity } from '@marxan-jobs/planning-unit-geometry'; +import { ProjectCostSurfacesContent } from '@marxan/cloning/infrastructure/clone-piece-data/project-cost-surfaces'; + +type CreationStatus = ProjectCustomFeature['creation_status']; + +type ProjectCostSurfacesSelectResult = { + id: string; + name: string; + min: number; + max: number; + is_default: boolean; +}; + +type CostSurfaceDataSelectResult = { + cost_surface_id: string; + cost: number; + projects_pu_id: number; +}; + +@Injectable() +@PieceExportProvider() +export class ProjectCostSurfacesPieceExporter implements ExportPieceProcessor { + private readonly logger: Logger = new Logger( + ProjectCostSurfacesPieceExporter.name, + ); + + constructor( + private readonly fileRepository: CloningFilesRepository, + @InjectEntityManager(geoprocessingConnections.apiDB) + private readonly apiEntityManager: EntityManager, + @InjectEntityManager(geoprocessingConnections.default) + private readonly geoprocessingEntityManager: EntityManager, + ) {} + + isSupported(piece: ClonePiece, kind: ResourceKind): boolean { + return ( + piece === ClonePiece.ProjectCostSurfaces && kind === ResourceKind.Project + ); + } + + async run(input: ExportJobInput): Promise { + const costSurfaces: ProjectCostSurfacesSelectResult[] = await this.apiEntityManager + .createQueryBuilder() + .select(['cs.id', 'cs.name', 'cs.min', 'cs.max', 'cs.is_default']) + .from('cost_surfaces', 'cs') + .where('cs.project_id = :projectId', { projectId: input.resourceId }) + .execute(); + + const costSurfacesIds = costSurfaces.map((costSurface) => costSurface.id); + let costSurfaceData: CostSurfaceDataSelectResult[] = []; + let projectPusMap: Record = {}; + + if (!costSurfacesIds) { + const errorMessage = `${ProjectCostSurfacesPieceExporter.name} - Project Cost Surfaces - couldn't find cost surfaces for project ${input.resourceId}`; + this.logger.error(errorMessage); + throw new Error(errorMessage); + } + costSurfaceData = await this.geoprocessingEntityManager + .createQueryBuilder() + .select(['cost_surface_id', 'cost', 'projects_pu_id']) + .from('cost_surface_pu_data', 'scpd') + .where('cost_surface_id IN (:...costSurfacesIds)', { + costSurfacesIds, + }) + .execute(); + + projectPusMap = await this.getProjectPusMap(input.resourceId); + + const fileContent: ProjectCostSurfacesContent = { + costSurfaces: costSurfaces.map(({ id, ...costSurface }) => ({ + ...costSurface, + data: costSurfaceData + .filter( + (data: CostSurfaceDataSelectResult) => data.cost_surface_id === id, + ) + .map(({ cost_surface_id, projects_pu_id, ...data }) => { + const puid = projectPusMap[projects_pu_id]; + return { puid, ...data }; + }), + })), + }; + + const relativePath = ClonePieceRelativePathResolver.resolveFor( + ClonePiece.ProjectCostSurfaces, + ); + + const outputFile = await this.fileRepository.saveCloningFile( + input.exportId, + Readable.from(JSON.stringify(fileContent)), + relativePath, + ); + + if (isLeft(outputFile)) { + const errorMessage = `${ProjectCostSurfacesPieceExporter.name} - Project Cost Surfaces - couldn't save file - ${outputFile.left.description}`; + this.logger.error(errorMessage); + throw new Error(errorMessage); + } + + return { + ...input, + uris: [new ComponentLocation(outputFile.right, relativePath)], + }; + } + + private async getProjectPusMap( + projectId: string, + ): Promise> { + const projectPus: { + id: string; + puid: number; + }[] = await this.geoprocessingEntityManager + .createQueryBuilder() + .select(['id', 'puid']) + .from(ProjectsPuEntity, 'ppus') + .where('ppus.project_id = :projectId', { projectId }) + .execute(); + + const projectPuIdByPuid: Record = {}; + projectPus.forEach(({ puid, id }) => { + projectPuIdByPuid[id] = puid; + }); + + return projectPuIdByPuid; + } +} diff --git a/api/apps/geoprocessing/src/import/pieces-importers/pieces-importers.module.ts b/api/apps/geoprocessing/src/import/pieces-importers/pieces-importers.module.ts index 244f8e43b5..6d797e537e 100644 --- a/api/apps/geoprocessing/src/import/pieces-importers/pieces-importers.module.ts +++ b/api/apps/geoprocessing/src/import/pieces-importers/pieces-importers.module.ts @@ -19,6 +19,7 @@ import { ScenarioMetadataPieceImporter } from './scenario-metadata.piece-importe import { ScenarioPlanningUnitsDataPieceImporter } from './scenario-planning-units-data.piece-importer'; import { ScenarioProtectedAreasPieceImporter } from './scenario-protected-areas.piece-importer'; import { ScenarioRunResultsPieceImporter } from './scenario-run-results.piece-importer'; +import { ProjectCostSurfacesPieceImporter } from '@marxan-geoprocessing/import/pieces-importers/project-cost-surfaces.piece-importer'; @Module({ imports: [ @@ -37,6 +38,7 @@ import { ScenarioRunResultsPieceImporter } from './scenario-run-results.piece-im PlanningUnitsGridPieceImporter, ProjectCustomProtectedAreasPieceImporter, ProjectCustomFeaturesPieceImporter, + ProjectCostSurfacesPieceImporter, ScenarioProtectedAreasPieceImporter, ScenarioPlanningUnitsDataPieceImporter, ScenarioRunResultsPieceImporter, diff --git a/api/apps/geoprocessing/src/import/pieces-importers/project-cost-surfaces.piece-importer.ts b/api/apps/geoprocessing/src/import/pieces-importers/project-cost-surfaces.piece-importer.ts new file mode 100644 index 0000000000..5ab1ee5b2a --- /dev/null +++ b/api/apps/geoprocessing/src/import/pieces-importers/project-cost-surfaces.piece-importer.ts @@ -0,0 +1,170 @@ +import { geoprocessingConnections } from '@marxan-geoprocessing/ormconfig'; +import { ClonePiece, ImportJobInput, ImportJobOutput } from '@marxan/cloning'; +import { ResourceKind } from '@marxan/cloning/domain'; +import { ProjectCustomFeaturesContent } from '@marxan/cloning/infrastructure/clone-piece-data/project-custom-features'; +import { CloningFilesRepository } from '@marxan/cloning-files-repository'; +import { GeoFeatureGeometry } from '@marxan/geofeatures'; +import { readableToBuffer } from '@marxan/utils'; +import { Injectable, Logger } from '@nestjs/common'; +import { InjectEntityManager } from '@nestjs/typeorm'; +import { isLeft } from 'fp-ts/lib/Either'; +import { EntityManager } from 'typeorm'; +import { v4 } from 'uuid'; +import { + ImportPieceProcessor, + PieceImportProvider, +} from '../pieces/import-piece-processor'; +import { chunk } from 'lodash'; +import { ProjectsPuEntity } from '@marxan-jobs/planning-unit-geometry'; +import { CHUNK_SIZE_FOR_BATCH_GEODB_OPERATIONS } from '@marxan-geoprocessing/utils/chunk-size-for-batch-geodb-operations'; +import { + CostSurfaceData, + ProjectCostSurfacesContent, +} from '@marxan/cloning/infrastructure/clone-piece-data/project-cost-surfaces'; +import { CostSurfacePuDataEntity } from '@marxan/cost-surfaces'; + +@Injectable() +@PieceImportProvider() +export class ProjectCostSurfacesPieceImporter implements ImportPieceProcessor { + private readonly logger: Logger = new Logger( + ProjectCostSurfacesPieceImporter.name, + ); + + constructor( + private readonly fileRepository: CloningFilesRepository, + @InjectEntityManager(geoprocessingConnections.apiDB) + private readonly apiEntityManager: EntityManager, + @InjectEntityManager(geoprocessingConnections.default) + private readonly geoprocessingEntityManager: EntityManager, + ) {} + + isSupported(piece: ClonePiece, kind: ResourceKind): boolean { + return ( + piece === ClonePiece.ProjectCostSurfaces && kind === ResourceKind.Project + ); + } + + async run(input: ImportJobInput): Promise { + const { uris, pieceResourceId, projectId, piece } = input; + let returnValue: ImportJobOutput = {} as ImportJobOutput; + + try { + if (uris.length !== 1) { + const errorMessage = `uris array has an unexpected amount of elements: ${uris.length}`; + this.logger.error(errorMessage); + throw new Error(errorMessage); + } + const [projectCostSurfacesLocation] = uris; + + const readableOrError = await this.fileRepository.get( + projectCostSurfacesLocation.uri, + ); + if (isLeft(readableOrError)) { + const errorMessage = `File with piece data for ${piece}/${pieceResourceId} is not available at ${projectCostSurfacesLocation.uri}`; + this.logger.error(errorMessage); + throw new Error(errorMessage); + } + + const buffer = await readableToBuffer(readableOrError.right); + const projectCostSurfacesOrError = buffer.toString(); + + const { costSurfaces }: ProjectCostSurfacesContent = JSON.parse( + projectCostSurfacesOrError, + ); + + returnValue = { + importId: input.importId, + componentId: input.componentId, + pieceResourceId, + projectId, + piece: input.piece, + }; + + if (!costSurfaces.length) return returnValue; + + const projectPusMap = await this.getProjectPusMap(projectId); + + await this.apiEntityManager.transaction(async (apiEm) => { + const costSurfacesInsertValues: any[] = []; + let costSurfacesDataInsertValues: any[] = []; + costSurfaces.forEach(({ data, ...costSurface }) => { + const costSurfaceId = v4(); + + costSurfacesInsertValues.push({ + ...costSurface, + project_id: projectId, + id: costSurfaceId, + }); + + const costSurfaceData = data.map((data: CostSurfaceData) => ({ + ...data, + cost_surface_id: costSurfaceId, + })); + + const costSurfaceInsertData = costSurfaceData.map( + (data: CostSurfaceData) => ({ + costSurfaceId: costSurfaceId, + cost: data.cost, + projectsPuId: projectPusMap[data.puid], + }), + ); + + costSurfacesDataInsertValues = costSurfacesDataInsertValues.concat( + costSurfaceInsertData, + ); + }); + + await Promise.all( + costSurfacesInsertValues.map((values) => + apiEm + .createQueryBuilder() + .insert() + .into('cost_surfaces') + .values(values) + .execute(), + ), + ); + + await Promise.all( + chunk( + costSurfacesDataInsertValues, + CHUNK_SIZE_FOR_BATCH_GEODB_OPERATIONS, + ).map((values) => + this.geoprocessingEntityManager + .createQueryBuilder() + .insert() + .into(CostSurfacePuDataEntity) + .values(values) + .execute(), + ), + ); + }); + } catch (e) { + this.logger.error(e); + throw e; + } + + return returnValue; + } + + private async getProjectPusMap( + projectId: string, + ): Promise> { + const projectPus: { + id: string; + puid: number; + }[] = await this.geoprocessingEntityManager + .createQueryBuilder() + .select(['id', 'puid']) + .from(ProjectsPuEntity, 'ppus') + .where('ppus.project_id = :projectId', { projectId }) + .execute(); + + const projectPuIdByPuid: Record = {}; + projectPus.forEach(({ puid, id }) => { + projectPuIdByPuid[puid] = id; + }); + + return projectPuIdByPuid; + } +} diff --git a/api/apps/geoprocessing/test/integration/cloning/fixtures.ts b/api/apps/geoprocessing/test/integration/cloning/fixtures.ts index 236747b047..e121d4da71 100644 --- a/api/apps/geoprocessing/test/integration/cloning/fixtures.ts +++ b/api/apps/geoprocessing/test/integration/cloning/fixtures.ts @@ -22,6 +22,7 @@ import { Readable, Transform } from 'stream'; import { DeepPartial, EntityManager, In } from 'typeorm'; import { v4 } from 'uuid'; import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity'; +import { CostSurfacePuDataEntity } from '@marxan/cost-surfaces'; export type TestSpecification = { id: string; @@ -595,6 +596,58 @@ export async function GivenOutputScenarioFeaturesData( .execute(); } +export async function GivenCostSurfaces( + em: EntityManager, + min: number, + max: number, + name: string, + projectId: string, +) { + const costSurface = Array({ + id: v4(), + min, + max, + name, + project_id: projectId, + }); + + await Promise.all( + costSurface.map((values) => + em + .createQueryBuilder() + .insert() + .into('cost_surfaces') + .values(values) + .execute(), + ), + ); + + return costSurface[0]; +} + +export async function GivenCostSurfaceData( + em: EntityManager, + projectId: string, + costSurfaceId: string, +): Promise<{ id: string; hash: string; feature_id: string }[]> { + const projectPus = await GivenProjectPus(em, projectId, 10); + const insertValues = projectPus.map((pu, index) => ({ + id: v4(), + costSurfaceId: costSurfaceId, + projectsPuId: pu.id, + cost: index + 1, + })); + + const result = await em + .createQueryBuilder() + .insert() + .into(CostSurfacePuDataEntity) + .values(insertValues) + .returning(['id', 'cost', 'costSurfaceId']) + .execute(); + + return result.raw; +} export async function GivenSpecifications( em: EntityManager, featuresIds: string[], diff --git a/api/apps/geoprocessing/test/integration/cloning/piece-exporters/project-cost-surface.piece-exporter.e2e-spec.ts b/api/apps/geoprocessing/test/integration/cloning/piece-exporters/project-cost-surface.piece-exporter.e2e-spec.ts new file mode 100644 index 0000000000..4cb26bcfee --- /dev/null +++ b/api/apps/geoprocessing/test/integration/cloning/piece-exporters/project-cost-surface.piece-exporter.e2e-spec.ts @@ -0,0 +1,155 @@ +import { geoprocessingConnections } from '@marxan-geoprocessing/ormconfig'; +import { ClonePiece, ExportJobInput } from '@marxan/cloning'; +import { ResourceKind } from '@marxan/cloning/domain'; +import { ProjectCustomFeaturesContent } from '@marxan/cloning/infrastructure/clone-piece-data/project-custom-features'; +import { CloningFilesRepository } from '@marxan/cloning-files-repository'; +import { GeoFeatureGeometry } from '@marxan/geofeatures'; +import { FixtureType } from '@marxan/utils/tests/fixture-type'; +import { Test } from '@nestjs/testing'; +import { getEntityManagerToken, TypeOrmModule } from '@nestjs/typeorm'; +import { isLeft, Right } from 'fp-ts/lib/Either'; +import { Readable } from 'stream'; +import { EntityManager, In } from 'typeorm'; +import { v4 } from 'uuid'; +import { + DeleteProjectAndOrganization, + GivenCostSurfaceData, + GivenCostSurfaces, + GivenFeatures, + GivenFeaturesData, + GivenProjectExists, + readSavedFile, +} from '../fixtures'; +import { GeoCloningFilesRepositoryModule } from '@marxan-geoprocessing/modules/cloning-files-repository'; +import { FakeLogger } from '@marxan-geoprocessing/utils/__mocks__/fake-logger'; +import { ProjectCostSurfacesPieceExporter } from '@marxan-geoprocessing/export/pieces-exporters/project-cost-surfaces.piece-exporter'; +import { CostSurfacePuDataEntity } from '@marxan/cost-surfaces'; +import { ProjectCostSurfacesContent } from '@marxan/cloning/infrastructure/clone-piece-data/project-cost-surfaces'; + +let fixtures: FixtureType; + +describe(ProjectCostSurfacesPieceExporter, () => { + beforeEach(async () => { + fixtures = await getFixtures(); + }, 10_000); + + afterEach(async () => { + await fixtures?.cleanUp(); + }); + + it('saves successfully cost surfaces data', async () => { + const input = fixtures.GivenAProjectCostSurfacesExportJob(); + await fixtures.GivenProjectExist(); + await fixtures.GivenCostSurfacesForProject(); + await fixtures + .WhenPieceExporterIsInvoked(input) + .ThenAProjectCostSurfacesFileIsSaved(); + }); +}); + +const getFixtures = async () => { + const sandbox = await Test.createTestingModule({ + imports: [ + TypeOrmModule.forRoot({ + ...geoprocessingConnections.apiDB, + keepConnectionAlive: true, + logging: false, + }), + TypeOrmModule.forRoot({ + ...geoprocessingConnections.default, + keepConnectionAlive: true, + logging: false, + }), + TypeOrmModule.forFeature([GeoFeatureGeometry]), + GeoCloningFilesRepositoryModule, + ], + providers: [ProjectCostSurfacesPieceExporter], + }).compile(); + + await sandbox.init(); + sandbox.useLogger(new FakeLogger()); + + const projectId = v4(); + const organizationId = v4(); + const sut = sandbox.get(ProjectCostSurfacesPieceExporter); + const apiEntityManager: EntityManager = sandbox.get( + getEntityManagerToken(geoprocessingConnections.apiDB), + ); + const geoEntityManager: EntityManager = sandbox.get( + getEntityManagerToken(geoprocessingConnections.default), + ); + const costSurfacesDataRepo = geoEntityManager.getRepository( + CostSurfacePuDataEntity, + ); + const fileRepository = sandbox.get(CloningFilesRepository); + + return { + cleanUp: async () => { + await DeleteProjectAndOrganization( + apiEntityManager, + projectId, + organizationId, + ); + await costSurfacesDataRepo.delete({}); + }, + GivenAProjectCostSurfacesExportJob: (): ExportJobInput => { + return { + allPieces: [ + { resourceId: projectId, piece: ClonePiece.ProjectMetadata }, + { + resourceId: projectId, + piece: ClonePiece.ProjectCostSurfaces, + }, + ], + componentId: v4(), + exportId: v4(), + piece: ClonePiece.ProjectCostSurfaces, + resourceId: projectId, + resourceKind: ResourceKind.Project, + }; + }, + GivenProjectExist: async () => { + return GivenProjectExists(apiEntityManager, projectId, organizationId); + }, + GivenCostSurfacesForProject: async () => { + const costSurface = await GivenCostSurfaces( + apiEntityManager, + 1, + 10, + 'Cost Surface', + projectId, + ); + + await GivenCostSurfaceData(geoEntityManager, projectId, costSurface.id); + return costSurface.id; + }, + GivenTagOnFeature: async (featureId: string, tag: string) => + await apiEntityManager.query(`INSERT INTO project_feature_tags + (project_id, feature_id, tag) + VALUES + ('${projectId}', '${featureId}', '${tag}' ) `), + + WhenPieceExporterIsInvoked: (input: ExportJobInput) => { + return { + ThenAProjectCostSurfacesFileIsSaved: async () => { + const result = await sut.run(input); + const file = await fileRepository.get(result.uris[0].uri); + expect((file as Right).right).toBeDefined(); + if (isLeft(file)) throw new Error(); + const savedStrem = file.right; + const content = await readSavedFile( + savedStrem, + ); + expect(content.costSurfaces).toHaveLength(2); + const costSurfacesExported = content.costSurfaces; + + const nonDefaultCostSurface = costSurfacesExported.find( + (costSurface) => costSurface.name === 'Cost Surface', + ); + expect(nonDefaultCostSurface).toBeDefined(); + expect(nonDefaultCostSurface?.data).toHaveLength(10); + }, + }; + }, + }; +}; diff --git a/api/apps/geoprocessing/test/integration/cloning/piece-importers/project-cost-surface.piece-importer.e2e-spec.ts b/api/apps/geoprocessing/test/integration/cloning/piece-importers/project-cost-surface.piece-importer.e2e-spec.ts new file mode 100644 index 0000000000..23a437fc1e --- /dev/null +++ b/api/apps/geoprocessing/test/integration/cloning/piece-importers/project-cost-surface.piece-importer.e2e-spec.ts @@ -0,0 +1,251 @@ +import { geoprocessingConnections } from '@marxan-geoprocessing/ormconfig'; +import { ImportJobInput } from '@marxan/cloning'; +import { CloningFilesRepository } from '@marxan/cloning-files-repository'; +import { + ArchiveLocation, + ClonePiece, + ResourceKind, +} from '@marxan/cloning/domain'; +import { ClonePieceRelativePathResolver } from '@marxan/cloning/infrastructure/clone-piece-data'; +import { FixtureType } from '@marxan/utils/tests/fixture-type'; +import { Test } from '@nestjs/testing'; +import { + getEntityManagerToken, + getRepositoryToken, + TypeOrmModule, +} from '@nestjs/typeorm'; +import { isLeft } from 'fp-ts/lib/Either'; +import { Readable } from 'stream'; +import { DeepPartial, EntityManager, In, Repository } from 'typeorm'; +import { v4 } from 'uuid'; +import { + DeleteProjectAndOrganization, + GenerateRandomGeometries, + GivenProjectExists, + GivenProjectPus, +} from '../fixtures'; +import { GeoCloningFilesRepositoryModule } from '@marxan-geoprocessing/modules/cloning-files-repository'; +import { FakeLogger } from '@marxan-geoprocessing/utils/__mocks__/fake-logger'; +import { ProjectCostSurfacesPieceImporter } from '@marxan-geoprocessing/import/pieces-importers/project-cost-surfaces.piece-importer'; +import { ProjectCostSurfacesContent } from '@marxan/cloning/infrastructure/clone-piece-data/project-cost-surfaces'; +import { CostSurfacePuDataEntity } from '@marxan/cost-surfaces'; +import { ProjectCostSurfacesPieceExporter } from '@marxan-geoprocessing/export/pieces-exporters/project-cost-surfaces.piece-exporter'; + +let fixtures: FixtureType; + +describe(ProjectCostSurfacesPieceImporter, () => { + beforeEach(async () => { + fixtures = await getFixtures(); + }, 10_000); + + afterEach(async () => { + await fixtures?.cleanUp(); + }); + + it('fails when project cost surfaces file uri is missing in uris array', async () => { + const input = fixtures.GivenJobInputWithoutUris(); + await fixtures + .WhenPieceImporterIsInvoked(input) + .ThenAnUrisArrayErrorShouldBeThrown(); + }); + + it('fails when the file cannot be retrieved from file repo', async () => { + const archiveLocation = fixtures.GivenNoProjectCostSurfacesFileIsAvailable(); + const input = fixtures.GivenJobInput(archiveLocation); + await fixtures + .WhenPieceImporterIsInvoked(input) + .ThenADataNotAvailableErrorShouldBeThrown(); + }); + + it('imports project cost surfaces', async () => { + const archiveLocation = await fixtures.GivenValidProjectCostSurfacesFile(); + await fixtures.GivenProject(); + await fixtures.GivenProjectPus(); + const input = fixtures.GivenJobInput(archiveLocation); + await fixtures + .WhenPieceImporterIsInvoked(input) + .ThenCostSurfacesShouldBeAddedToProject(); + }); +}); + +const getFixtures = async () => { + const sandbox = await Test.createTestingModule({ + imports: [ + TypeOrmModule.forRoot({ + ...geoprocessingConnections.default, + keepConnectionAlive: true, + logging: false, + }), + TypeOrmModule.forRoot({ + ...geoprocessingConnections.apiDB, + keepConnectionAlive: true, + logging: false, + }), + TypeOrmModule.forFeature([CostSurfacePuDataEntity]), + TypeOrmModule.forFeature([], geoprocessingConnections.apiDB.name), + GeoCloningFilesRepositoryModule, + ], + providers: [ProjectCostSurfacesPieceImporter], + }).compile(); + + await sandbox.init(); + sandbox.useLogger(new FakeLogger()); + + const projectId = v4(); + const organizationId = v4(); + const userId = v4(); + + const geoEntityManager = sandbox.get(getEntityManagerToken()); + const apiEntityManager = sandbox.get( + getEntityManagerToken(geoprocessingConnections.apiDB.name), + ); + const costSurfaceDataRepo = sandbox.get>( + getRepositoryToken( + CostSurfacePuDataEntity, + geoprocessingConnections.default.name, + ), + ); + + const sut = sandbox.get(ProjectCostSurfacesPieceImporter); + const fileRepository = sandbox.get(CloningFilesRepository); + + let validProjectCostSurfacesFile: DeepPartial; + + return { + cleanUp: async () => { + const costSurfaces: { + id: string; + }[] = await apiEntityManager + .createQueryBuilder() + .select('id') + .from('cost_surfaces', 'cs') + .where('project_id = :projectId', { projectId }) + .execute(); + + const costSurfacesIds = costSurfaces.map((cs) => cs.id); + await costSurfaceDataRepo.delete({ + costSurfaceId: In(costSurfacesIds), + }); + + await DeleteProjectAndOrganization( + apiEntityManager, + projectId, + organizationId, + ); + }, + GivenProject: () => + GivenProjectExists(apiEntityManager, projectId, organizationId), + GivenProjectPus: () => GivenProjectPus(geoEntityManager, projectId, 3), + GivenJobInput: (archiveLocation: ArchiveLocation): ImportJobInput => { + const relativePath = ClonePieceRelativePathResolver.resolveFor( + ClonePiece.ProjectCostSurfaces, + ); + return { + componentId: v4(), + pieceResourceId: v4(), + importId: v4(), + projectId, + piece: ClonePiece.ProjectCostSurfaces, + resourceKind: ResourceKind.Project, + uris: [{ relativePath, uri: archiveLocation.value }], + ownerId: userId, + }; + }, + GivenJobInputWithoutUris: (): ImportJobInput => { + return { + componentId: v4(), + pieceResourceId: v4(), + importId: v4(), + projectId, + piece: ClonePiece.ProjectCostSurfaces, + resourceKind: ResourceKind.Project, + uris: [], + ownerId: userId, + }; + }, + GivenNoProjectCostSurfacesFileIsAvailable: () => { + return new ArchiveLocation('not found'); + }, + GivenValidProjectCostSurfacesFile: async () => { + const geometries = await GenerateRandomGeometries( + geoEntityManager, + 10, + false, + ); + + validProjectCostSurfacesFile = { + costSurfaces: [ + { + name: 'Cost Surface', + min: 1, + max: 10, + data: Array(3) + .fill(0) + .map((_, dataIndex) => ({ + cost: dataIndex * 2, + puid: dataIndex + 1, + })), + }, + ], + }; + + const exportId = v4(); + const relativePath = ClonePieceRelativePathResolver.resolveFor( + ClonePiece.ProjectCostSurfaces, + ); + + const uriOrError = await fileRepository.saveCloningFile( + exportId, + Readable.from(JSON.stringify(validProjectCostSurfacesFile)), + relativePath, + ); + + if (isLeft(uriOrError)) throw new Error("couldn't save file"); + return new ArchiveLocation(uriOrError.right); + }, + WhenPieceImporterIsInvoked: (input: ImportJobInput) => { + return { + ThenAnUrisArrayErrorShouldBeThrown: async () => { + await expect(sut.run(input)).rejects.toThrow(/uris/gi); + }, + ThenADataNotAvailableErrorShouldBeThrown: async () => { + await expect(sut.run(input)).rejects.toThrow( + /File with piece data for/gi, + ); + }, + ThenCostSurfacesShouldBeAddedToProject: async () => { + await sut.run(input); + + const costSurfaces: { + id: string; + min: boolean; + max: string; + name: string; + }[] = await apiEntityManager + .createQueryBuilder() + .select('cs.id') + .addSelect('cs.min', 'min') + .addSelect('cs.max', 'max') + .addSelect('cs.name', 'name') + .from('cost_surfaces', 'cs') + .where('cs.project_id = :projectId', { projectId }) + .andWhere('cs.is_default = false') + .execute(); + + expect(costSurfaces).toHaveLength(1); + expect(costSurfaces[0].min).toEqual(1); + expect(costSurfaces[0].max).toEqual(10); + expect(costSurfaces[0].name).toEqual('Cost Surface'); + + const costSurfaceData = await costSurfaceDataRepo.find({ + where: { + costSurfaceId: In(costSurfaces.map((cs) => cs.id)), + }, + }); + + expect(costSurfaceData).toHaveLength(3); + }, + }; + }, + }; +}; diff --git a/api/apps/geoprocessing/test/integration/cost-surface/cleanup.fixtures.ts b/api/apps/geoprocessing/test/integration/cost-surface/cleanup.fixtures.ts index 6b1c6ec6df..a02e2f121e 100644 --- a/api/apps/geoprocessing/test/integration/cost-surface/cleanup.fixtures.ts +++ b/api/apps/geoprocessing/test/integration/cost-surface/cleanup.fixtures.ts @@ -106,9 +106,14 @@ export const getFixtures = async (app: INestApplication) => { }, cleanup: async () => { - await geoEntityManager.query(`DELETE FROM cost_surface_pu_data`); + await geoEntityManager.query( + `DELETE FROM cost_surface_pu_data WHERE cost_surface_id = $1`, + [costSurfaceId], + ); await geoEntityManager.query(`DELETE FROM projects_pu`); - await apiEntityManager.query(`DELETE FROM cost_surfaces`); + await apiEntityManager.query(`DELETE FROM cost_surfaces WHERE id = $1`, [ + costSurfaceId, + ]); await apiEntityManager.query(`DELETE FROM projects`); await apiEntityManager.query(`DELETE FROM organizations`); }, diff --git a/api/libs/cloning/src/domain/clone-piece.ts b/api/libs/cloning/src/domain/clone-piece.ts index 348606a888..422282eed2 100644 --- a/api/libs/cloning/src/domain/clone-piece.ts +++ b/api/libs/cloning/src/domain/clone-piece.ts @@ -1,6 +1,7 @@ export enum ClonePiece { ExportConfig = 'export-config', ProjectMetadata = 'project-metadata', + ProjectCostSurfaces = 'project-cost-surfaces', ProjectCustomProtectedAreas = 'project-custom-protected-areas', ProjectCustomFeatures = 'project-custom-features', ProjectPuvsprCalculations = 'project-puvspr-calculations', diff --git a/api/libs/cloning/src/infrastructure/clone-piece-data/index.ts b/api/libs/cloning/src/infrastructure/clone-piece-data/index.ts index e65dea42a1..b92dd8fbba 100644 --- a/api/libs/cloning/src/infrastructure/clone-piece-data/index.ts +++ b/api/libs/cloning/src/infrastructure/clone-piece-data/index.ts @@ -21,6 +21,7 @@ import { scenarioOutputFolderRelativePath } from './scenario-output-folder'; import { scenarioPlanningUnitsDataRelativePath } from './scenario-planning-units-data'; import { scenarioProtectedAreasRelativePath } from './scenario-protected-areas'; import { scenarioRunResultsRelativePath } from './scenario-run-results'; +import { projectCostSurfacesRelativePath } from '@marxan/cloning/infrastructure/clone-piece-data/project-cost-surfaces'; export const exportOnlyClonePieces: ClonePiece[] = [ ClonePiece.ExportConfig, @@ -60,18 +61,23 @@ export const clonePieceImportOrder: Record = { [ClonePiece.PlanningUnitsGrid]: 1, [ClonePiece.ProjectCustomProtectedAreas]: 1, // - [ClonePiece.ScenarioMetadata]: 2, + [ClonePiece.ProjectCostSurfaces]: 2, + [ClonePiece.ProjectCustomFeatures]: 2, // - [ClonePiece.ProjectCustomFeatures]: 3, [ClonePiece.ScenarioProtectedAreas]: 3, [ClonePiece.ScenarioPlanningUnitsData]: 3, + [ClonePiece.ScenarioMetadata]: 3, // - [ClonePiece.ProjectPuvsprCalculations]: 4, - [ClonePiece.ScenarioFeaturesData]: 4, + [ClonePiece.ProjectCustomFeatures]: 4, + [ClonePiece.ScenarioProtectedAreas]: 4, + [ClonePiece.ScenarioPlanningUnitsData]: 4, // - [ClonePiece.ScenarioRunResults]: 5, - [ClonePiece.MarxanExecutionMetadata]: 5, - [ClonePiece.FeaturesSpecification]: 5, + [ClonePiece.ProjectPuvsprCalculations]: 5, + [ClonePiece.ScenarioFeaturesData]: 5, + // + [ClonePiece.ScenarioRunResults]: 6, + [ClonePiece.MarxanExecutionMetadata]: 6, + [ClonePiece.FeaturesSpecification]: 6, }; export class ClonePieceRelativePathResolver { @@ -92,6 +98,7 @@ export class ClonePieceRelativePathResolver { [ClonePiece.ProjectMetadata]: () => projectMetadataRelativePath, [ClonePiece.ProjectCustomProtectedAreas]: () => projectCustomProtectedAreasRelativePath, + [ClonePiece.ProjectCostSurfaces]: () => projectCostSurfacesRelativePath, [ClonePiece.ProjectCustomFeatures]: () => projectCustomFeaturesRelativePath, [ClonePiece.ProjectPuvsprCalculations]: () => projectPuvsprCalculationsRelativePath, diff --git a/api/libs/cloning/src/infrastructure/clone-piece-data/project-cost-surfaces.ts b/api/libs/cloning/src/infrastructure/clone-piece-data/project-cost-surfaces.ts new file mode 100644 index 0000000000..445ebeed0b --- /dev/null +++ b/api/libs/cloning/src/infrastructure/clone-piece-data/project-cost-surfaces.ts @@ -0,0 +1,18 @@ +export const projectCostSurfacesRelativePath = 'cost-surfaces.json'; + +export type CostSurfaceData = { + cost: number; + puid: number; +}; + +export type ProjectCostSurface = { + name: string; + min: number; + max: number; + is_default: boolean; + data: CostSurfaceData[]; +}; + +export type ProjectCostSurfacesContent = { + costSurfaces: ProjectCostSurface[]; +};