From 5497a69243f974e97a9ee2c47b259df3bb3f1a1c Mon Sep 17 00:00:00 2001 From: Kevin Date: Thu, 19 Oct 2023 17:19:16 +0200 Subject: [PATCH] feat(CostSurface): Link Cost Surface To Scenario PR Tweaks --- .../link-cost-surface-to-scenario.command.ts | 6 +- .../cost-surface/cost-surface.service.ts | 29 ++++++++ .../job-status/job-status.view.api.entity.ts | 2 - .../modules/scenarios/scenarios.controller.ts | 13 ++-- .../api/test/project-scenarios.e2e-spec.ts | 4 +- .../project-cost-surface.e2e-spec.ts | 34 +++++++++ .../project-cost-surface.fixtures.ts | 12 ++- .../scenario/typeorm-scenario-cost-surface.ts | 73 +++++++------------ ...scenario-cost-surface-processor.service.ts | 4 +- .../scenario-cost-surface-persistence.port.ts | 3 + .../link-cost-surface.e2e-spec.ts | 19 ++++- .../integration/cost-surface/steps/world.ts | 5 +- .../src/surface-cost-job-input.ts | 5 +- 13 files changed, 143 insertions(+), 66 deletions(-) diff --git a/api/apps/api/src/modules/cost-surface/application/scenario/link-cost-surface-to-scenario.command.ts b/api/apps/api/src/modules/cost-surface/application/scenario/link-cost-surface-to-scenario.command.ts index c2e185a5d0..9471730a1f 100644 --- a/api/apps/api/src/modules/cost-surface/application/scenario/link-cost-surface-to-scenario.command.ts +++ b/api/apps/api/src/modules/cost-surface/application/scenario/link-cost-surface-to-scenario.command.ts @@ -1,11 +1,13 @@ import { Command } from '@nestjs-architects/typed-cqrs'; import { Either } from 'fp-ts/lib/Either'; +import { LinkCostSurfaceToScenarioMode } from '@marxan/artifact-cache/surface-cost-job-input'; export const linkCostSurfaceToScenarioFailed = Symbol( 'link surface cost to scenario failed', ); -export type LinkCostSurfaceToScenarioError = typeof linkCostSurfaceToScenarioFailed; +export type LinkCostSurfaceToScenarioError = + typeof linkCostSurfaceToScenarioFailed; export type LinkCostSurfaceToScenarioResponse = Either< LinkCostSurfaceToScenarioError, @@ -20,7 +22,7 @@ export class LinkCostSurfaceToScenarioCommand extends Command + > { + const scenario = await this.scenarioRepository.findOne({ + where: { id: scenarioId }, + }); + if (!scenario) { + return left(scenarioNotFound); + } + + const costSurface = await this.costSurfaceRepository.findOne({ + where: { projectId: scenario.projectId, isDefault: true }, + }); + if (!costSurface) { + return left(costSurfaceNotFound); + } + + return this.linkCostSurfaceToScenario(userId, scenarioId, costSurface.id); + } + async update( userId: string, projectId: string, diff --git a/api/apps/api/src/modules/projects/job-status/job-status.view.api.entity.ts b/api/apps/api/src/modules/projects/job-status/job-status.view.api.entity.ts index e1a6735ddf..e691da2422 100644 --- a/api/apps/api/src/modules/projects/job-status/job-status.view.api.entity.ts +++ b/api/apps/api/src/modules/projects/job-status/job-status.view.api.entity.ts @@ -193,6 +193,4 @@ const eventToJobStatusMapping: Record< ApiEventJobStatus.done, [API_EVENT_KINDS.scenario__protectedAreas__failed__v1__alpha]: ApiEventJobStatus.failure, - - // RENAME Y AÑADIR MAPPING }; diff --git a/api/apps/api/src/modules/scenarios/scenarios.controller.ts b/api/apps/api/src/modules/scenarios/scenarios.controller.ts index 16ef6bb6ba..16e002ca47 100644 --- a/api/apps/api/src/modules/scenarios/scenarios.controller.ts +++ b/api/apps/api/src/modules/scenarios/scenarios.controller.ts @@ -445,7 +445,7 @@ export class ScenariosController { required: true, }) @ApiTags(asyncJobTag) - @Post(`:scenarioId/link-cost-surface/:costSurfaceId`) + @Post(`:scenarioId/cost-surface/:costSurfaceId`) async linkCostSurfaceToScenario( @Param('scenarioId') scenarioId: string, @Param('costSurfaceId') costSurfaceId: string, @@ -469,25 +469,22 @@ export class ScenariosController { } @ApiOperation({ - description: - 'To be removed soon to POST /projects/:projectId/cost-surface/shapefile', + description: `Unlinks the currently applied CostSurface from the given Scenario, and links back the default Cost Surface of the Scenario's Project`, }) @ApiParam({ name: 'scenarioId', - description: 'Id of the Scenario that the Cost Surface will be applied', + description: 'Id of the Scenario that will have its Cost Surface unlinked', required: true, }) @ApiTags(asyncJobTag) - @Post(`:scenarioId/unlink-cost-surface/`) + @Delete(`:scenarioId/cost-surface/`) async unlinkCostSurfaceToScenario( @Param('scenarioId') scenarioId: string, - @Param('costSurfaceId') costSurfaceId: string, @Req() req: RequestWithAuthenticatedUser, ): Promise { - const result = await this.costSurfaceService.linkCostSurfaceToScenario( + const result = await this.costSurfaceService.unlinkCostSurfaceFromScenario( req.user.id, scenarioId, - costSurfaceId, ); if (isLeft(result)) { diff --git a/api/apps/api/test/project-scenarios.e2e-spec.ts b/api/apps/api/test/project-scenarios.e2e-spec.ts index e650ddc00e..9ceb0d9d9a 100644 --- a/api/apps/api/test/project-scenarios.e2e-spec.ts +++ b/api/apps/api/test/project-scenarios.e2e-spec.ts @@ -27,7 +27,7 @@ let fixtures: FixtureType; beforeEach(async () => { fixtures = await getFixtures(); -}, 1000000); +}, 12_000); describe('ScenariosModule (e2e)', () => { it('Creating a scenario with incomplete data should fail', async () => { @@ -92,7 +92,7 @@ describe('ScenariosModule (e2e)', () => { const response = await fixtures.WhenCreatingAScenarioWithMinimumRequiredDataAsOwner(false); fixtures.ThenCostSurfaceNotFoundMessageIsReturned(response); - }, 1000000); + }); it('Creating a scenario with complete data should succeed', async () => { const response = diff --git a/api/apps/api/test/projects/cost-surfaces/project-cost-surface.e2e-spec.ts b/api/apps/api/test/projects/cost-surfaces/project-cost-surface.e2e-spec.ts index 6fd857839d..62bd4781f0 100644 --- a/api/apps/api/test/projects/cost-surfaces/project-cost-surface.e2e-spec.ts +++ b/api/apps/api/test/projects/cost-surfaces/project-cost-surface.e2e-spec.ts @@ -315,6 +315,40 @@ describe('Cost Surface', () => { scenario.id, ); }); + it(`should link back to the scenario's project default cost surface when unlinkind`, async () => { + // ARRANGE + const projectId = await fixtures.GivenProject('someProject'); + const defaultCostSurface = await fixtures.GivenDefaultCostSurfaceForProject( + projectId, + ); + const costSurface = await fixtures.GivenCostSurfaceMetadataForProject( + projectId, + 'someCostSurface', + ); + const scenario = await fixtures.GivenScenario( + projectId, + costSurface.id, + 'someName', + ); + fixtures.GivenNoJobsOnScenarioCostSurfaceQueue(); + + // ACT + await fixtures.WhenUnlinkingCostSurfaceToScenario(scenario.id); + + // ASSERT + await fixtures.ThenCostSurfaceIsLinkedToScenario( + scenario.id, + defaultCostSurface.id, + ); + await fixtures.ThenLinkCostSurfaceToScenarioJobWasSent( + scenario.id, + defaultCostSurface.id, + costSurface.id, + ); + await fixtures.ThenLinkCostSurfaceToScenarioSubmittedApiEventWasSaved( + scenario.id, + ); + }); it(`should return error when the Scenario was not found`, async () => { // ARRANGE diff --git a/api/apps/api/test/projects/cost-surfaces/project-cost-surface.fixtures.ts b/api/apps/api/test/projects/cost-surfaces/project-cost-surface.fixtures.ts index 83fd48aa78..3c3acb212a 100644 --- a/api/apps/api/test/projects/cost-surfaces/project-cost-surface.fixtures.ts +++ b/api/apps/api/test/projects/cost-surfaces/project-cost-surface.fixtures.ts @@ -250,7 +250,17 @@ export const getProjectCostSurfaceFixtures = async () => { ) => { return request(app.getHttpServer()) .post( - `/api/v1/scenarios/${scenarioId}/link-cost-surface/${costSurfaceId}`, + `/api/v1/scenarios/${scenarioId}/cost-surface/${costSurfaceId}`, + ) + .set('Authorization', `Bearer ${token}`) + .send(); + }, + WhenUnlinkingCostSurfaceToScenario: async ( + scenarioId: string, + ) => { + return request(app.getHttpServer()) + .delete( + `/api/v1/scenarios/${scenarioId}/cost-surface/`, ) .set('Authorization', `Bearer ${token}`) .send(); diff --git a/api/apps/geoprocessing/src/modules/cost-surface/adapters/scenario/typeorm-scenario-cost-surface.ts b/api/apps/geoprocessing/src/modules/cost-surface/adapters/scenario/typeorm-scenario-cost-surface.ts index c20cccd3c5..68bb394f9b 100644 --- a/api/apps/geoprocessing/src/modules/cost-surface/adapters/scenario/typeorm-scenario-cost-surface.ts +++ b/api/apps/geoprocessing/src/modules/cost-surface/adapters/scenario/typeorm-scenario-cost-surface.ts @@ -1,19 +1,14 @@ -import { CHUNK_SIZE_FOR_BATCH_GEODB_OPERATIONS } from '@marxan-geoprocessing/utils/chunk-size-for-batch-geodb-operations'; import { Injectable } from '@nestjs/common'; import { InjectEntityManager } from '@nestjs/typeorm'; -import { chunk } from 'lodash'; import { EntityManager } from 'typeorm'; -import { CostSurfacePuDataEntity } from '@marxan/cost-surfaces'; import { geoprocessingConnections } from '@marxan-geoprocessing/ormconfig'; import { ScenarioCostSurfacePersistencePort } from '@marxan-geoprocessing/modules/cost-surface/ports/persistence/scenario-cost-surface-persistence.port'; -import { - ScenariosPuCostDataGeo, - ScenariosPuPaDataGeo, -} from '@marxan/scenarios-planning-unit'; +import { LinkCostSurfaceToScenarioMode } from '@marxan/artifact-cache/surface-cost-job-input'; @Injectable() export class TypeormScenarioCostSurface - implements ScenarioCostSurfacePersistencePort { + implements ScenarioCostSurfacePersistencePort +{ constructor( @InjectEntityManager(geoprocessingConnections.default) private readonly geoprocessingEntityManager: EntityManager, @@ -22,45 +17,33 @@ export class TypeormScenarioCostSurface async linkScenarioToCostSurface( scenarioId: string, costSurfaceId: string, + mode: LinkCostSurfaceToScenarioMode, ): Promise { await this.geoprocessingEntityManager.transaction(async (em) => { - const costsForScenarioPus: { - scenariosPuId: string; - cost: number; - }[] = await em - .createQueryBuilder() - .select('spd.id', 'scenariosPuId') - .addSelect('csp.cost', 'cost') - .from(ScenariosPuPaDataGeo, 'spd') - .leftJoin( - CostSurfacePuDataEntity, - 'csp', - 'csp.projects_pu_id = spd.project_pu_id', - ) - .where('spd.scenario_id = :scenarioId', { scenarioId }) - .andWhere('csp.cost_surface_id = :costSurfaceId', { costSurfaceId }) - .execute(); - - await em.query( - ` DELETE FROM scenarios_pu_cost_data spcd - USING scenarios_pu_data spd - WHERE spcd.scenarios_pu_data_id = spd.id and spd.scenario_id = $1`, - [scenarioId], - ); - - await Promise.all( - chunk(costsForScenarioPus, CHUNK_SIZE_FOR_BATCH_GEODB_OPERATIONS).map( - async (rows) => { - await em.insert( - ScenariosPuCostDataGeo, - rows.map((row) => ({ - cost: row.cost, - scenariosPuDataId: row.scenariosPuId, - })), - ); - }, - ), - ); + if (mode === 'update') { + await em.query( + ` UPDATE scenarios_pu_cost_data + SET cost = cost_surface."cost_value" + FROM + ( + SELECT spd.id, cspd."cost" as cost_value + FROM scenarios_pu_data spd + LEFT JOIN cost_surface_pu_data cspd ON cspd.projects_pu_id = spd.project_pu_id + WHERE spd.scenario_id = $1 AND cspd.cost_surface_id = $2 + ) cost_surface + WHERE scenarios_pu_cost_data.scenarios_pu_data_id = cost_surface.id`, + [scenarioId, costSurfaceId], + ); + } else if (mode === 'creation') { + await em.query( + ` INSERT INTO scenarios_pu_cost_data (scenarios_pu_data_id, cost) + SELECT spd.id as scenarios_pu_data_id, cspd."cost" as cost + FROM scenarios_pu_data spd + LEFT JOIN cost_surface_pu_data cspd ON cspd.projects_pu_id = spd.project_pu_id + WHERE spd.scenario_id = $1 AND cspd.cost_surface_id = $2`, + [scenarioId, costSurfaceId], + ); + } }); } } diff --git a/api/apps/geoprocessing/src/modules/cost-surface/application/scenario-cost-surface-processor.service.ts b/api/apps/geoprocessing/src/modules/cost-surface/application/scenario-cost-surface-processor.service.ts index 5789d1f30c..6c9af75b7c 100644 --- a/api/apps/geoprocessing/src/modules/cost-surface/application/scenario-cost-surface-processor.service.ts +++ b/api/apps/geoprocessing/src/modules/cost-surface/application/scenario-cost-surface-processor.service.ts @@ -9,7 +9,8 @@ import { ScenarioCostSurfacePersistencePort } from '@marxan-geoprocessing/module @Injectable() export class ScenarioCostSurfaceProcessor - implements WorkerProcessor { + implements WorkerProcessor +{ constructor(private readonly repo: ScenarioCostSurfacePersistencePort) {} private async linkCostSurfaceToScenario({ @@ -18,6 +19,7 @@ export class ScenarioCostSurfaceProcessor await this.repo.linkScenarioToCostSurface( data.scenarioId, data.costSurfaceId, + data.mode, ); return true; diff --git a/api/apps/geoprocessing/src/modules/cost-surface/ports/persistence/scenario-cost-surface-persistence.port.ts b/api/apps/geoprocessing/src/modules/cost-surface/ports/persistence/scenario-cost-surface-persistence.port.ts index 1842ad7274..ceff94e125 100644 --- a/api/apps/geoprocessing/src/modules/cost-surface/ports/persistence/scenario-cost-surface-persistence.port.ts +++ b/api/apps/geoprocessing/src/modules/cost-surface/ports/persistence/scenario-cost-surface-persistence.port.ts @@ -1,6 +1,9 @@ +import { LinkCostSurfaceToScenarioMode } from '@marxan/artifact-cache/surface-cost-job-input'; + export abstract class ScenarioCostSurfacePersistencePort { abstract linkScenarioToCostSurface( scenarioId: string, costSurface: string, + mode: LinkCostSurfaceToScenarioMode, ): Promise; } diff --git a/api/apps/geoprocessing/test/integration/cost-surface/link-cost-surface.e2e-spec.ts b/api/apps/geoprocessing/test/integration/cost-surface/link-cost-surface.e2e-spec.ts index 5c6ba995a4..adf938aa45 100644 --- a/api/apps/geoprocessing/test/integration/cost-surface/link-cost-surface.e2e-spec.ts +++ b/api/apps/geoprocessing/test/integration/cost-surface/link-cost-surface.e2e-spec.ts @@ -8,7 +8,7 @@ describe('should process cost surface', () => { const app = await bootstrapApplication(); world = await createWorld(app); }); - it('should link the cost surface data to the scenario data', async () => { + it('should update the cost surface cost data when linking in update mode', async () => { const scenarioId = v4(); const projectId = v4(); await world.GivenScenarioPuDataExists(projectId, scenarioId); @@ -19,6 +19,23 @@ describe('should process cost surface', () => { const linkCostSurfaceJob = world.getLinkCostSurfaceToScenarioJob( scenarioId, costSurfaceId, + 'update', + ); + await world.WhenTheCostSurfaceLinkingJobIsProcessed(linkCostSurfaceJob); + await world.ThenTheScenarioPuCostDataIsUpdated(costSurfaceId, 42); + }); + + it('should insert cost surface cost data when linking in creation mode', async () => { + const scenarioId = v4(); + const projectId = v4(); + await world.GivenScenarioPuDataExists(projectId, scenarioId); + const costSurfaceId = v4(); + await world.GivenCostSurfacePuDataExists(costSurfaceId); + + const linkCostSurfaceJob = world.getLinkCostSurfaceToScenarioJob( + scenarioId, + costSurfaceId, + 'creation', ); await world.WhenTheCostSurfaceLinkingJobIsProcessed(linkCostSurfaceJob); await world.ThenTheScenarioPuCostDataIsUpdated(costSurfaceId, 42); diff --git a/api/apps/geoprocessing/test/integration/cost-surface/steps/world.ts b/api/apps/geoprocessing/test/integration/cost-surface/steps/world.ts index 9afae224b4..68e41272e2 100644 --- a/api/apps/geoprocessing/test/integration/cost-surface/steps/world.ts +++ b/api/apps/geoprocessing/test/integration/cost-surface/steps/world.ts @@ -14,7 +14,7 @@ import { getFixtures } from '../planning-unit-fixtures'; import { CostSurfaceShapefileRecord } from '@marxan-geoprocessing/modules/cost-surface/ports/cost-surface-shapefile-record'; import { FromProjectShapefileJobInput, - LinkCostSurfaceToScenarioJobInput, + LinkCostSurfaceToScenarioJobInput, LinkCostSurfaceToScenarioMode, ProjectCostSurfaceJobInput, } from '@marxan/artifact-cache/surface-cost-job-input'; import { CostSurfacePuDataEntity } from '@marxan/cost-surfaces'; @@ -64,6 +64,7 @@ export const createWorld = async (app: INestApplication) => { getLinkCostSurfaceToScenarioJob: ( scenarioId: string, costSurfaceId: string, + mode: LinkCostSurfaceToScenarioMode ) => (({ data: { @@ -72,7 +73,7 @@ export const createWorld = async (app: INestApplication) => { scenarioId, costSurfaceId, originalCostSurfaceId: v4(), - mode: 'creation', + mode: mode, }, id: 'test-job', } as unknown) as Job), diff --git a/api/libs/artifact-cache/src/surface-cost-job-input.ts b/api/libs/artifact-cache/src/surface-cost-job-input.ts index abe1d4c866..2afddc7771 100644 --- a/api/libs/artifact-cache/src/surface-cost-job-input.ts +++ b/api/libs/artifact-cache/src/surface-cost-job-input.ts @@ -20,10 +20,11 @@ export type LinkCostSurfaceToScenarioJobInput = { scenarioId: string; costSurfaceId: string; originalCostSurfaceId: string; - - mode: 'creation' | 'update'; + mode: LinkCostSurfaceToScenarioMode; }; +export type LinkCostSurfaceToScenarioMode = 'creation' | 'update'; + export type ScenarioCostSurfaceJobInput = LinkCostSurfaceToScenarioJobInput; /**