diff --git a/api/apps/api/src/migrations/api/1696603545456-AddCostSurfaceLinkingNewEvents.ts b/api/apps/api/src/migrations/api/1696603545456-AddCostSurfaceLinkingNewEvents.ts new file mode 100644 index 0000000000..220b5da103 --- /dev/null +++ b/api/apps/api/src/migrations/api/1696603545456-AddCostSurfaceLinkingNewEvents.ts @@ -0,0 +1,25 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddCostSurfaceLinkingNewEvents1696603545456 + implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + INSERT INTO api_event_kinds (id) values + ('scenario.costSurface.link.submitted/v1/alpha1'), + ('scenario.costSurface.link.finished/v1/alpha1'), + ('scenario.costSurface.link.failed/v1/alpha1'); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DELETE FROM api_event_kinds WHERE id = 'scenario.costSurface.link.submitted/v1/alpha1';`, + ); + await queryRunner.query( + `DELETE FROM api_event_kinds WHERE id = 'scenario.costSurface.link.finished/v1/alpha1';`, + ); + await queryRunner.query( + `DELETE FROM api_event_kinds WHERE id = 'scenario.costSurface.link.failed/v1/alpha1';`, + ); + } +} diff --git a/api/apps/api/src/modules/async-jobs-garbage-collector/async-jobs/scenario-async-jobs/cost-surface.async-job.ts b/api/apps/api/src/modules/async-jobs-garbage-collector/async-jobs/scenario-async-jobs/cost-surface.async-job.ts index 2ef54f155a..679349756c 100644 --- a/api/apps/api/src/modules/async-jobs-garbage-collector/async-jobs/scenario-async-jobs/cost-surface.async-job.ts +++ b/api/apps/api/src/modules/async-jobs-garbage-collector/async-jobs/scenario-async-jobs/cost-surface.async-job.ts @@ -30,7 +30,7 @@ export class CostSurfaceAsyncJob extends AsyncJob { getFailedAsyncJobState(): CostSurfaceApiEvents { /* At the moment in the codebase, we are not using this two api events: - API_EVENT_KINDS.scenario__costSurface__shapeConverted__v1_alpha1 + API_EVENT_KINDS.scenario__costSurface__shapeConverted__v1_alpha1 API_EVENT_KINDS.scenario__costSurface__shapeConversionFailed__v1_alpha1 In case we start using them, we migh have to do add new state to getEndAsynJobStates() and check the latestApitEvent when executing getFailedAsyncJobState() diff --git a/api/apps/api/src/modules/cost-surface/adapters/scenario-cost-surface-adapters.module.ts b/api/apps/api/src/modules/cost-surface/adapters/scenario-cost-surface-adapters.module.ts new file mode 100644 index 0000000000..dc9d610de1 --- /dev/null +++ b/api/apps/api/src/modules/cost-surface/adapters/scenario-cost-surface-adapters.module.ts @@ -0,0 +1,17 @@ +import { Module } from '@nestjs/common'; +import { ApiEventsModule } from '../../api-events'; +import { CqrsModule } from '@nestjs/cqrs'; +import { ScenarioCostSurfaceEventsPort } from '@marxan-api/modules/cost-surface/ports/scenario/scenario-cost-surface-events.port'; +import { ScenarioCostSurfaceApiEvents } from '@marxan-api/modules/cost-surface/adapters/scenario/scenario-cost-surface-api-events'; + +@Module({ + imports: [ApiEventsModule, CqrsModule], + providers: [ + { + provide: ScenarioCostSurfaceEventsPort, + useClass: ScenarioCostSurfaceApiEvents, + }, + ], + exports: [ScenarioCostSurfaceEventsPort], +}) +export class ScenarioCostSurfaceAdaptersModule {} diff --git a/api/apps/api/src/modules/cost-surface/adapters/scenario/scenario-cost-surface-api-events.ts b/api/apps/api/src/modules/cost-surface/adapters/scenario/scenario-cost-surface-api-events.ts new file mode 100644 index 0000000000..c2e0e0d8dc --- /dev/null +++ b/api/apps/api/src/modules/cost-surface/adapters/scenario/scenario-cost-surface-api-events.ts @@ -0,0 +1,36 @@ +import { Injectable } from '@nestjs/common'; +import { API_EVENT_KINDS } from '@marxan/api-events'; +import { ApiEventsService } from '@marxan-api/modules/api-events'; +import { + ScenarioCostSurfaceEventsPort, + ScenarioCostSurfaceState, +} from '@marxan-api/modules/cost-surface/ports/scenario/scenario-cost-surface-events.port'; + +@Injectable() +export class ScenarioCostSurfaceApiEvents + extends ApiEventsService + implements ScenarioCostSurfaceEventsPort { + private readonly eventsMap: Record< + ScenarioCostSurfaceState, + API_EVENT_KINDS + > = { + [ScenarioCostSurfaceState.LinkToScenarioFailed]: + API_EVENT_KINDS.scenario__costSurface__link__failed__v1_alpha1, + [ScenarioCostSurfaceState.LinkToScenarioFinished]: + API_EVENT_KINDS.scenario__costSurface__link__finished__v1_alpha1, + [ScenarioCostSurfaceState.LinkToScenarioSubmitted]: + API_EVENT_KINDS.scenario__costSurface__link__submitted__v1_alpha1, + }; + + async event( + scenarioId: string, + state: ScenarioCostSurfaceState, + context?: Record, + ): Promise { + await this.create({ + data: context ?? {}, + topic: scenarioId, + kind: this.eventsMap[state], + }); + } +} 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 new file mode 100644 index 0000000000..c2e185a5d0 --- /dev/null +++ b/api/apps/api/src/modules/cost-surface/application/scenario/link-cost-surface-to-scenario.command.ts @@ -0,0 +1,27 @@ +import { Command } from '@nestjs-architects/typed-cqrs'; +import { Either } from 'fp-ts/lib/Either'; + +export const linkCostSurfaceToScenarioFailed = Symbol( + 'link surface cost to scenario failed', +); + +export type LinkCostSurfaceToScenarioError = typeof linkCostSurfaceToScenarioFailed; + +export type LinkCostSurfaceToScenarioResponse = Either< + LinkCostSurfaceToScenarioError, + true +>; + +/** + * @todo: Temporal substitute for UpdateCostSurface command, which works at scenario level. It should be + * removed and use there once the implementation is fully validated + */ +export class LinkCostSurfaceToScenarioCommand extends Command { + constructor( + public readonly scenarioId: string, + public readonly costSurfaceId: string, + public readonly mode: 'creation' | 'update', + ) { + super(); + } +} diff --git a/api/apps/api/src/modules/cost-surface/application/scenario/link-cost-surface-to-scenario.handler.ts b/api/apps/api/src/modules/cost-surface/application/scenario/link-cost-surface-to-scenario.handler.ts new file mode 100644 index 0000000000..e608c831b7 --- /dev/null +++ b/api/apps/api/src/modules/cost-surface/application/scenario/link-cost-surface-to-scenario.handler.ts @@ -0,0 +1,81 @@ +import { Inject, Logger } from '@nestjs/common'; +import { CommandHandler, IInferredCommandHandler } from '@nestjs/cqrs'; +import { Queue } from 'bullmq'; +import { left, right } from 'fp-ts/lib/Either'; +import { LinkCostSurfaceToScenarioJobInput } from '@marxan/artifact-cache/surface-cost-job-input'; +import { + LinkCostSurfaceToScenarioCommand, + linkCostSurfaceToScenarioFailed, + LinkCostSurfaceToScenarioResponse, +} from '@marxan-api/modules/cost-surface/application/scenario/link-cost-surface-to-scenario.command'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Scenario } from '@marxan-api/modules/scenarios/scenario.api.entity'; +import { Repository } from 'typeorm'; +import { + ScenarioCostSurfaceEventsPort, + ScenarioCostSurfaceState, +} from '@marxan-api/modules/cost-surface/ports/scenario/scenario-cost-surface-events.port'; +import { scenarioCostSurfaceQueueToken } from '@marxan-api/modules/cost-surface/infra/scenario/scenario-cost-surface-queue.provider'; + +@CommandHandler(LinkCostSurfaceToScenarioCommand) +export class LinkCostSurfaceToScenarioHandler + implements IInferredCommandHandler { + private readonly logger: Logger = new Logger( + LinkCostSurfaceToScenarioHandler.name, + ); + + constructor( + @Inject(scenarioCostSurfaceQueueToken) + private readonly queue: Queue, + @InjectRepository(Scenario) + private readonly scenarioRepo: Repository, + private readonly events: ScenarioCostSurfaceEventsPort, + ) {} + + async execute({ + scenarioId, + costSurfaceId, + mode, + }: LinkCostSurfaceToScenarioCommand): Promise { + try { + const scenario = await this.scenarioRepo.findOneOrFail({ + where: { id: scenarioId }, + }); + const originalCostSurfaceId = scenario.costSurfaceId; + + await this.queue.add(`link-cost-surface-for-scenario-${scenarioId}`, { + type: 'LinkCostSurfaceToScenarioJobInput', + scenarioId, + costSurfaceId, + originalCostSurfaceId, + mode, + }); + + await this.scenarioRepo.update(scenarioId, { costSurfaceId }); + + await this.events.event( + scenarioId, + ScenarioCostSurfaceState.LinkToScenarioSubmitted, + ); + } catch (error) { + await this.markAsFailed(scenarioId, error); + return left(linkCostSurfaceToScenarioFailed); + } + + return right(true); + } + + private markAsFailed = async (scenarioId: string, error: unknown) => { + this.logger.error( + `Failed executing link-cost-surface-to-scenario command for scenario ${scenarioId}`, + String(error), + ); + await this.events.event( + scenarioId, + ScenarioCostSurfaceState.LinkToScenarioFailed, + { + error, + }, + ); + }; +} diff --git a/api/apps/api/src/modules/cost-surface/application/scenario/scenario-cost-surface-application.module.ts b/api/apps/api/src/modules/cost-surface/application/scenario/scenario-cost-surface-application.module.ts new file mode 100644 index 0000000000..343433fb48 --- /dev/null +++ b/api/apps/api/src/modules/cost-surface/application/scenario/scenario-cost-surface-application.module.ts @@ -0,0 +1,18 @@ +import { Module } from '@nestjs/common'; +import { CqrsModule } from '@nestjs/cqrs'; +import { LinkCostSurfaceToScenarioHandler } from '@marxan-api/modules/cost-surface/application/scenario/link-cost-surface-to-scenario.handler'; +import { ScenarioCostSurfaceInfraModule } from '@marxan-api/modules/cost-surface/infra/scenario/scenario-cost-surface-infra.module'; +import { ScenarioCostSurfaceAdaptersModule } from '@marxan-api/modules/cost-surface/adapters/scenario-cost-surface-adapters.module'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Scenario } from '@marxan-api/modules/scenarios/scenario.api.entity'; + +@Module({ + imports: [ + ScenarioCostSurfaceInfraModule, + ScenarioCostSurfaceAdaptersModule, + CqrsModule, + TypeOrmModule.forFeature([Scenario]), + ], + providers: [LinkCostSurfaceToScenarioHandler], +}) +export class ScenarioCostSurfaceApplicationModule {} diff --git a/api/apps/api/src/modules/cost-surface/cost-surface.module.ts b/api/apps/api/src/modules/cost-surface/cost-surface.module.ts index 624ed0f939..8de226b6e3 100644 --- a/api/apps/api/src/modules/cost-surface/cost-surface.module.ts +++ b/api/apps/api/src/modules/cost-surface/cost-surface.module.ts @@ -11,15 +11,22 @@ import { DeleteCostSurfaceModule } from '@marxan-api/modules/cost-surface/delete import { ProjectCostSurfaceAdaptersModule } from '@marxan-api/modules/cost-surface/adapters/cost-surface-adapters.module'; import { CostRangeService } from '@marxan-api/modules/scenarios/cost-range-service'; import { CqrsModule } from '@nestjs/cqrs'; +import { ScenarioAclModule } from '@marxan-api/modules/access-control/scenarios-acl/scenario-acl.module'; +import { Scenario } from '@marxan-api/modules/scenarios/scenario.api.entity'; +import { ScenarioCostSurfaceApplicationModule } from '@marxan-api/modules/cost-surface/application/scenario/scenario-cost-surface-application.module'; +import { ScenarioCostSurfaceAdaptersModule } from '@marxan-api/modules/cost-surface/adapters/scenario-cost-surface-adapters.module'; @Module({ imports: [ ProjectCostSurfaceApplicationModule, ProjectCostSurfaceAdaptersModule, + ScenarioCostSurfaceApplicationModule, + ScenarioCostSurfaceAdaptersModule, CostSurfaceApplicationModule, DeleteProjectModule, - TypeOrmModule.forFeature([CostSurface]), + TypeOrmModule.forFeature([CostSurface, Scenario]), ProjectAclModule, + ScenarioAclModule, DeleteCostSurfaceModule, CqrsModule, ], 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 608e4d382a..8df84b55a3 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 @@ -18,6 +18,13 @@ import { } from '@marxan-api/modules/cost-surface/delete-cost-surface/delete-cost-surface.command'; import { CostSurfaceCalculationPort } from '@marxan-api/modules/cost-surface/ports/project/cost-surface-calculation.port'; import { CommandBus } from '@nestjs/cqrs'; +import { scenarioNotFound } from '@marxan-api/modules/blm/values/blm-repos'; +import { + LinkCostSurfaceToScenarioCommand, + LinkCostSurfaceToScenarioError, +} from '@marxan-api/modules/cost-surface/application/scenario/link-cost-surface-to-scenario.command'; +import { scenarioNotEditable } from '@marxan-api/modules/scenarios/scenarios.service'; +import { ScenarioAclService } from '@marxan-api/modules/access-control/scenarios-acl/scenario-acl.service'; export const costSurfaceNotEditableWithinProject = Symbol( `cost surface not editable within project`, @@ -45,7 +52,10 @@ export class CostSurfaceService { constructor( @InjectRepository(CostSurface) private readonly costSurfaceRepository: Repository, + @InjectRepository(Scenario) + private readonly scenarioRepository: Repository, private readonly projectAclService: ProjectAclService, + private readonly scenarioAclService: ScenarioAclService, private readonly calculateCost: CostSurfaceCalculationPort, private readonly commandBus: CommandBus, ) {} @@ -155,6 +165,49 @@ export class CostSurfaceService { ); } + async linkCostSurfaceToScenario( + userId: string, + scenarioId: string, + costSurfaceId: string, + ): Promise< + Either< + | typeof scenarioNotEditable + | typeof costSurfaceNotFound + | typeof scenarioNotFound + | LinkCostSurfaceToScenarioError, + true + > + > { + const scenario = await this.scenarioRepository.findOne({ + where: { id: scenarioId }, + }); + if (!scenario) { + return left(scenarioNotFound); + } + + if (!(await this.scenarioAclService.canEditScenario(userId, scenarioId))) { + return left(scenarioNotEditable); + } + + const costSurface = await this.costSurfaceRepository.findOne({ + where: { id: costSurfaceId }, + }); + if (!costSurface) { + return left(costSurfaceNotFound); + } + + if (scenario.projectId !== costSurface.projectId) { + return left(costSurfaceNotFound); + } + if (scenario.costSurfaceId === costSurface.id) { + return right(true); + } + + return this.commandBus.execute( + new LinkCostSurfaceToScenarioCommand(scenarioId, costSurfaceId, 'update'), + ); + } + async update( userId: string, projectId: string, @@ -244,6 +297,7 @@ export class CostSurfaceService { return right(costSurface); } + async getCostSurface( userId: string, projectId: string, diff --git a/api/apps/api/src/modules/cost-surface/infra/scenario/scenario-cost-surface-events.handler.ts b/api/apps/api/src/modules/cost-surface/infra/scenario/scenario-cost-surface-events.handler.ts new file mode 100644 index 0000000000..977990df18 --- /dev/null +++ b/api/apps/api/src/modules/cost-surface/infra/scenario/scenario-cost-surface-events.handler.ts @@ -0,0 +1,85 @@ +import { CreateApiEventDTO } from '@marxan-api/modules/api-events/dto/create.api-event.dto'; +import { + CreateWithEventFactory, + EventData, + EventFactory, + QueueEventsAdapter, +} from '@marxan-api/modules/queue-api-events'; +import { API_EVENT_KINDS } from '@marxan/api-events'; +import { Inject, Injectable } from '@nestjs/common'; +import { CommandBus } from '@nestjs/cqrs'; +import { ScenarioCostSurfaceJobInput } from '@marxan/artifact-cache/surface-cost-job-input'; +import { ScenarioCostSurfaceFactoryToken } from '@marxan-api/modules/cost-surface/infra/scenario/scenario-cost-surface-queue.provider'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Scenario } from '@marxan-api/modules/scenarios/scenario.api.entity'; +import { Repository } from 'typeorm'; +import { DeleteScenario } from '@marxan-api/modules/scenarios/delete-scenario/delete-scenario.command'; + +type EventKind = { event: API_EVENT_KINDS }; + +@Injectable() +export class ScenarioCostSurfaceEventsHandler + implements EventFactory { + private queueEvents: QueueEventsAdapter; + + private failEventsMapper: EventKind = { + event: API_EVENT_KINDS.scenario__costSurface__link__failed__v1_alpha1, + }; + + private successEventsMapper: EventKind = { + event: API_EVENT_KINDS.scenario__costSurface__link__finished__v1_alpha1, + }; + + constructor( + @Inject(ScenarioCostSurfaceFactoryToken) + queueEventsFactory: CreateWithEventFactory< + ScenarioCostSurfaceJobInput, + true + >, + @InjectRepository(Scenario) + private readonly scenarioRepo: Repository, + private readonly commandBus: CommandBus, + ) { + this.queueEvents = queueEventsFactory(this); + this.queueEvents.on('failed', (data) => this.failed(data)); + } + + async createCompletedEvent( + eventData: EventData, + ): Promise { + const data = await eventData.data; + const kind = this.successEventsMapper.event; + + return { + topic: data.scenarioId, + kind, + data, + }; + } + + async createFailedEvent( + eventData: EventData, + ): Promise { + const data = await eventData.data; + const kind = this.failEventsMapper.event; + + return { + topic: data.scenarioId, + kind, + data, + }; + } + + async failed( + eventData: EventData, + ): Promise { + const jobInput = await eventData.data; + if (jobInput.mode === 'update') { + await this.scenarioRepo.update(jobInput.scenarioId, { + costSurfaceId: jobInput.originalCostSurfaceId, + }); + } else if (jobInput.mode === 'creation') { + await this.commandBus.execute(new DeleteScenario(jobInput.scenarioId)); + } + } +} diff --git a/api/apps/api/src/modules/cost-surface/infra/scenario/scenario-cost-surface-infra.module.ts b/api/apps/api/src/modules/cost-surface/infra/scenario/scenario-cost-surface-infra.module.ts new file mode 100644 index 0000000000..e56c9aedc8 --- /dev/null +++ b/api/apps/api/src/modules/cost-surface/infra/scenario/scenario-cost-surface-infra.module.ts @@ -0,0 +1,31 @@ +import { Module } from '@nestjs/common'; +import { CqrsModule } from '@nestjs/cqrs'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { QueueApiEventsModule } from '../../../queue-api-events'; +import { + scenarioCostSurfaceEventsFactoryProvider, + scenarioCostSurfaceQueueEventsProvider, + scenarioCostSurfaceQueueProvider, +} from '@marxan-api/modules/cost-surface/infra/scenario/scenario-cost-surface-queue.provider'; +import { ScenarioCostSurfaceEventsHandler } from '@marxan-api/modules/cost-surface/infra/scenario/scenario-cost-surface-events.handler'; +import { Scenario } from '@marxan-api/modules/scenarios/scenario.api.entity'; + +@Module({ + imports: [ + CqrsModule, + QueueApiEventsModule, + TypeOrmModule.forFeature([Scenario]), + ], + providers: [ + scenarioCostSurfaceQueueProvider, + scenarioCostSurfaceQueueEventsProvider, + scenarioCostSurfaceEventsFactoryProvider, + ScenarioCostSurfaceEventsHandler, + ], + exports: [ + scenarioCostSurfaceQueueProvider, + scenarioCostSurfaceQueueEventsProvider, + scenarioCostSurfaceEventsFactoryProvider, + ], +}) +export class ScenarioCostSurfaceInfraModule {} diff --git a/api/apps/api/src/modules/cost-surface/infra/scenario/scenario-cost-surface-queue.provider.ts b/api/apps/api/src/modules/cost-surface/infra/scenario/scenario-cost-surface-queue.provider.ts new file mode 100644 index 0000000000..b4e1fd0712 --- /dev/null +++ b/api/apps/api/src/modules/cost-surface/infra/scenario/scenario-cost-surface-queue.provider.ts @@ -0,0 +1,52 @@ +import { QueueBuilder, QueueEventsBuilder } from '@marxan-api/modules/queue'; +import { + CreateWithEventFactory, + QueueEventsAdapterFactory, +} from '@marxan-api/modules/queue-api-events'; +import { FactoryProvider } from '@nestjs/common'; +import { Queue, QueueEvents } from 'bullmq'; +import { ScenarioCostSurfaceJobInput } from '@marxan/artifact-cache/surface-cost-job-input'; +import { scenarioCostSurfaceQueueName } from '@marxan/artifact-cache/cost-surface-queue-name'; + +export const scenarioCostSurfaceQueueToken = Symbol( + 'scenarioCostSurfaceQueueToken', +); +export const scenarioCostSurfaceEventsToken = Symbol( + 'scenarioCostSurfaceEventsToken', +); +export const ScenarioCostSurfaceFactoryToken = Symbol( + 'scenarioCostSurfaceEventsToken' + ' factory token', +); + +export const scenarioCostSurfaceQueueProvider: FactoryProvider< + Queue +> = { + provide: scenarioCostSurfaceQueueToken, + useFactory: (queueBuilder: QueueBuilder) => { + return queueBuilder.buildQueue(scenarioCostSurfaceQueueName); + }, + inject: [QueueBuilder], +}; +export const scenarioCostSurfaceQueueEventsProvider: FactoryProvider = { + provide: scenarioCostSurfaceEventsToken, + useFactory: (eventsBuilder: QueueEventsBuilder) => { + return eventsBuilder.buildQueueEvents(scenarioCostSurfaceQueueName); + }, + inject: [QueueEventsBuilder], +}; + +export const scenarioCostSurfaceEventsFactoryProvider: FactoryProvider< + CreateWithEventFactory +> = { + provide: ScenarioCostSurfaceFactoryToken, + useFactory: ( + factory: QueueEventsAdapterFactory, + queue: Queue, + queueEvents: QueueEvents, + ) => factory.create(queue, queueEvents), + inject: [ + QueueEventsAdapterFactory, + scenarioCostSurfaceQueueToken, + scenarioCostSurfaceEventsToken, + ], +}; diff --git a/api/apps/api/src/modules/cost-surface/ports/scenario/scenario-cost-surface-events.port.ts b/api/apps/api/src/modules/cost-surface/ports/scenario/scenario-cost-surface-events.port.ts new file mode 100644 index 0000000000..1f6c49cb4d --- /dev/null +++ b/api/apps/api/src/modules/cost-surface/ports/scenario/scenario-cost-surface-events.port.ts @@ -0,0 +1,13 @@ +export enum ScenarioCostSurfaceState { + LinkToScenarioFailed = 'link-scenario-failed', + LinkToScenarioFinished = 'link-scenario-finished', + LinkToScenarioSubmitted = 'link-scenario-submitted', +} + +export abstract class ScenarioCostSurfaceEventsPort { + abstract event( + scenarioId: string, + state: ScenarioCostSurfaceState, + context?: Record | Error, + ): Promise; +} 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 5af41760bf..e1a6735ddf 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 @@ -91,6 +91,12 @@ const eventToJobStatusMapping: Record< ApiEventJobStatus.running, [API_EVENT_KINDS.scenario__costSurface__submitted__v1_alpha1]: ApiEventJobStatus.running, + [API_EVENT_KINDS.scenario__costSurface__link__submitted__v1_alpha1]: + ApiEventJobStatus.running, + [API_EVENT_KINDS.scenario__costSurface__link__finished__v1_alpha1]: + ApiEventJobStatus.done, + [API_EVENT_KINDS.scenario__costSurface__link__failed__v1_alpha1]: + ApiEventJobStatus.failure, [API_EVENT_KINDS.scenario__run__progress__v1__alpha1]: ApiEventJobStatus.running, [API_EVENT_KINDS.scenario__planningUnitsInclusion__failed__v1__alpha1]: @@ -187,4 +193,6 @@ 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 6611bc12af..16ef6bb6ba 100644 --- a/api/apps/api/src/modules/scenarios/scenarios.controller.ts +++ b/api/apps/api/src/modules/scenarios/scenarios.controller.ts @@ -111,6 +111,7 @@ import { ClearLockStatusParams } from '@marxan-api/modules/scenarios/dto/clear-l 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 { CostSurfaceService } from '@marxan-api/modules/cost-surface/cost-surface.service'; const basePath = `${apiGlobalPrefixes.v1}/scenarios`; const solutionsSubPath = `:id/marxan/solutions`; @@ -139,6 +140,7 @@ export class ScenariosController { private readonly planningUnitsSerializer: ScenarioPlanningUnitSerializer, private readonly scenarioAclService: ScenarioAccessControl, private readonly projectsService: ProjectsService, + private readonly costSurfaceService: CostSurfaceService, ) {} @ApiOperation({ @@ -427,6 +429,77 @@ export class ScenariosController { return AsyncJobDto.forScenario().asJsonApiMetadata(); } + @ApiOperation({ + description: + 'Links a Cost Surface to a Scenario, and applies costs on the Geoprocessing DB', + }) + @ApiParam({ + name: 'scenarioId', + description: 'Id of the Scenario that the Cost Surface will be applied', + required: true, + }) + @ApiParam({ + name: 'costSurfaceId', + description: + 'Id of the Cost Surface that will be applied to the given Scenario', + required: true, + }) + @ApiTags(asyncJobTag) + @Post(`:scenarioId/link-cost-surface/:costSurfaceId`) + async linkCostSurfaceToScenario( + @Param('scenarioId') scenarioId: string, + @Param('costSurfaceId') costSurfaceId: string, + @Req() req: RequestWithAuthenticatedUser, + ): Promise { + const result = await this.costSurfaceService.linkCostSurfaceToScenario( + req.user.id, + scenarioId, + costSurfaceId, + ); + + if (isLeft(result)) { + throw mapAclDomainToHttpError(result.left, { + scenarioId, + costSurfaceId, + userId: req.user.id, + resourceType: scenarioResource.name.plural, + }); + } + return true; + } + + @ApiOperation({ + description: + 'To be removed soon to POST /projects/:projectId/cost-surface/shapefile', + }) + @ApiParam({ + name: 'scenarioId', + description: 'Id of the Scenario that the Cost Surface will be applied', + required: true, + }) + @ApiTags(asyncJobTag) + @Post(`:scenarioId/unlink-cost-surface/`) + async unlinkCostSurfaceToScenario( + @Param('scenarioId') scenarioId: string, + @Param('costSurfaceId') costSurfaceId: string, + @Req() req: RequestWithAuthenticatedUser, + ): Promise { + const result = await this.costSurfaceService.linkCostSurfaceToScenario( + req.user.id, + scenarioId, + costSurfaceId, + ); + + if (isLeft(result)) { + throw mapAclDomainToHttpError(result.left, { + scenarioId, + userId: req.user.id, + resourceType: scenarioResource.name.plural, + }); + } + return AsyncJobDto.forScenario().asJsonApiMetadata(); + } + @ApiOperation({ deprecated: true, description: diff --git a/api/apps/api/src/modules/scenarios/scenarios.service.ts b/api/apps/api/src/modules/scenarios/scenarios.service.ts index 120dd6a6a0..7f399dfcbe 100644 --- a/api/apps/api/src/modules/scenarios/scenarios.service.ts +++ b/api/apps/api/src/modules/scenarios/scenarios.service.ts @@ -90,7 +90,6 @@ import { import { ExportScenario } from '../clone/export/application/export-scenario.command'; import { - SetInitialCostSurface, SetInitialCostSurfaceError, } from '@marxan-api/modules/cost-surface/application/set-initial-cost-surface.command'; import { UpdateCostSurface } from '@marxan-api/modules/cost-surface/application/update-cost-surface.command'; @@ -122,7 +121,13 @@ import { lastValueFrom } from 'rxjs'; import { AdjustPlanningUnitsInput } from '@marxan-api/modules/analysis/entry-points/adjust-planning-units-input'; import { submissionFailed } from '@marxan-api/modules/projects/protected-area/add-protected-area.service'; import { CostSurface } from '@marxan-api/modules/cost-surface/cost-surface.api.entity'; -import { costSurfaceNotFound } from '@marxan-api/modules/cost-surface/cost-surface.service'; +import { + costSurfaceNotFound, +} from '@marxan-api/modules/cost-surface/cost-surface.service'; +import { + LinkCostSurfaceToScenarioCommand, + linkCostSurfaceToScenarioFailed, +} from '@marxan-api/modules/cost-surface/application/scenario/link-cost-surface-to-scenario.command'; /** @debt move to own module */ const EmptyGeoFeaturesSpecification: GeoFeatureSetSpecification = { @@ -133,10 +138,9 @@ const EmptyGeoFeaturesSpecification: GeoFeatureSetSpecification = { export const projectNotReady = Symbol('project not ready'); export type ProjectNotReady = typeof projectNotReady; export const scenarioNotCreated = Symbol('scenario not created'); - export const bestSolutionNotFound = Symbol('best solution not found'); - export const projectDoesntExist = Symbol(`project doesn't exist`); +export const scenarioNotEditable = Symbol(`scenario not editable`); export const lockedSolutions = Symbol( `solutions from this scenario are locked`, ); @@ -270,7 +274,8 @@ export class ScenariosService { | ProjectDoesntExist | SetInitialCostSurfaceError | typeof scenarioNotCreated - | typeof costSurfaceNotFound, + | typeof costSurfaceNotFound + | typeof linkCostSurfaceToScenarioFailed, Scenario > > { @@ -335,13 +340,16 @@ export class ScenariosService { await this.planningUnitsLinkerService.link(scenario); - const costSurfaceInitializationResult = await this.commandBus.execute( - new SetInitialCostSurface(scenario.id), + const linkResult = await this.commandBus.execute( + new LinkCostSurfaceToScenarioCommand( + scenario.id, + costSurfaceDefault.id, + 'creation', + ), ); - - if (isLeft(costSurfaceInitializationResult)) { + if (isLeft(linkResult)) { await this.commandBus.execute(new DeleteScenario(scenario.id)); - return costSurfaceInitializationResult; + return linkResult; } await this.crudService.assignCreatorRole( diff --git a/api/apps/api/src/utils/acl.utils.ts b/api/apps/api/src/utils/acl.utils.ts index d85d027638..40a2669208 100644 --- a/api/apps/api/src/utils/acl.utils.ts +++ b/api/apps/api/src/utils/acl.utils.ts @@ -52,6 +52,7 @@ import { projectDoesntExist, projectNotReady, scenarioNotCreated, + scenarioNotEditable, } from '@marxan-api/modules/scenarios/scenarios.service'; import { internalError } from '@marxan-api/modules/specification/application/submit-specification.command'; import { notFound as protectedAreaProjectNotFound } from '@marxan/projects'; @@ -102,12 +103,12 @@ import { } from '@marxan-api/modules/geo-feature-tags/geo-feature-tags.service'; import { outputProjectSummaryNotFound } from '@marxan-api/modules/projects/output-project-summaries/output-project-summaries.service'; import { + customProtectedAreaIsUsedInOneOrMoreScenarios, + customProtectedAreaNotDeletableByUser, customProtectedAreaNotEditableByUser, + globalProtectedAreaNotDeletable, globalProtectedAreaNotEditable, - customProtectedAreaNotDeletableByUser, protectedAreaNotFound, - customProtectedAreaIsUsedInOneOrMoreScenarios, - globalProtectedAreaNotDeletable, } from '@marxan-api/modules/protected-areas/protected-areas-crud.service'; import { cannotDeleteDefaultCostSurface, @@ -117,6 +118,7 @@ import { costSurfaceStillInUse, } from '@marxan-api/modules/cost-surface/cost-surface.service'; import { deleteCostSurfaceFailed } from '@marxan-api/modules/cost-surface/delete-cost-surface/delete-cost-surface.command'; +import { linkCostSurfaceToScenarioFailed } from '@marxan-api/modules/cost-surface/application/scenario/link-cost-surface-to-scenario.command'; interface ErrorHandlerOptions { projectId?: string; @@ -205,7 +207,9 @@ export const mapAclDomainToHttpError = ( | typeof costSurfaceNotFound | typeof costSurfaceStillInUse | typeof cannotDeleteDefaultCostSurface - | typeof deleteCostSurfaceFailed, + | typeof deleteCostSurfaceFailed + | typeof scenarioNotEditable + | typeof linkCostSurfaceToScenarioFailed, options?: ErrorHandlerOptions, ) => { switch (errorToCheck) { @@ -395,7 +399,7 @@ export const mapAclDomainToHttpError = ( ); case costSurfaceNotFound: throw new NotFoundException( - `Cost Surface not found for Project with id ${options?.projectId}`, + `Cost Surface ${options?.costSurfaceId} not found`, ); case costSurfaceNameAlreadyExistsForProject: throw new ForbiddenException( @@ -463,6 +467,14 @@ export const mapAclDomainToHttpError = ( throw new ForbiddenException( `Custom protected area is used in one or more scenarios cannot be deleted.`, ); + case scenarioNotEditable: + throw new ForbiddenException( + `Scenario with id ${options?.scenarioId} is not editable by the given user`, + ); + case linkCostSurfaceToScenarioFailed: + throw new BadRequestException( + `Linking Cost Surface to Scenario ${options?.scenarioId} failed`, + ); default: const _exhaustiveCheck: never = errorToCheck; diff --git a/api/apps/api/test/project-scenarios.e2e-spec.ts b/api/apps/api/test/project-scenarios.e2e-spec.ts index 784b0033ae..e650ddc00e 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(); -}, 12_000); +}, 1000000); 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 = @@ -476,7 +476,7 @@ async function getFixtures() { ThenCostSurfaceNotFoundMessageIsReturned: (response: request.Response) => { expect(response.body.errors[0].title).toEqual( - `Cost Surface not found for Project with id ${projectId}`, + `Cost Surface for Project with id ${projectId} not found`, ); }, diff --git a/api/apps/api/test/projects/cost-surfaces/project-cost-surface.controller.acl.e2e-spec.ts b/api/apps/api/test/projects/cost-surfaces/project-cost-surface.controller.acl.e2e-spec.ts index 74dac2a352..620922b175 100644 --- a/api/apps/api/test/projects/cost-surfaces/project-cost-surface.controller.acl.e2e-spec.ts +++ b/api/apps/api/test/projects/cost-surfaces/project-cost-surface.controller.acl.e2e-spec.ts @@ -1,6 +1,7 @@ import { FixtureType } from '@marxan/utils/tests/fixture-type'; import { ProjectRoles } from '@marxan-api/modules/access-control/projects-acl/dto/user-role-project.dto'; import { getProjectCostSurfaceFixtures } from './project-cost-surface.fixtures'; +import { ScenarioRoles } from '@marxan-api/modules/access-control/scenarios-acl/dto/user-role-scenario.dto'; let fixtures: FixtureType; @@ -45,8 +46,9 @@ describe('Get Project Cost Surface', () => { await fixtures.GivenScenario(projectId, costSurface2.id); await fixtures.GivenScenario(projectId, costSurface2.id); // ACT - const response = - await fixtures.WhenGettingCostSurfacesForProject(projectId); + const response = await fixtures.WhenGettingCostSurfacesForProject( + projectId, + ); // ASSERT await fixtures.ThenProjectNotViewableErrorWasReturned(response); @@ -79,6 +81,46 @@ describe('Upload Cost Surface Shapefile', () => { }); }); +describe('Link Cost Surface To Scenario', () => { + beforeEach(async () => { + fixtures = await getProjectCostSurfaceFixtures(); + }); + + it(`should not update CostSurface API entity if the user doesn't have permissions to edit the project`, async () => { + // ARRANGE + const projectId = await fixtures.GivenProject('someProject'); + const defaultCostSurface = await fixtures.GivenDefaultCostSurfaceForProject( + projectId, + ); + const costSurface = await fixtures.GivenCostSurfaceMetadataForProject( + projectId, + 'someName', + ); + + const scenario = await fixtures.GivenScenario( + projectId, + defaultCostSurface.id, + 'someScenario', + [ScenarioRoles.scenario_viewer], + ); + + // ACT + const response = await fixtures.WhenLinkingCostSurfaceToScenario( + scenario.id, + costSurface.id, + ); + + // ASSERT + await fixtures.ThenScenarioNotEditableErrorWasReturned( + response, + scenario.id, + ); + await fixtures.ThenCostSurfaceIsLinkedToScenario( + scenario.id, + defaultCostSurface.id, + ); + }); +}); describe('Upload Cost Surface Shapefile', () => { beforeEach(async () => { fixtures = await getProjectCostSurfaceFixtures(); 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 8281f62e84..6fd857839d 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 @@ -15,8 +15,9 @@ describe('Cost Surface', () => { describe('Default Cost Surface', () => { it(`should create a default Cost Surface`, async () => { - const { projectId } = - await fixtures.WhenCreatingAProject('my awesome project'); + const { projectId } = await fixtures.WhenCreatingAProject( + 'my awesome project', + ); await fixtures.ThenADefaultCostSurfaceWasCreated(projectId); }); }); @@ -25,8 +26,7 @@ describe('Cost Surface', () => { // ARRANGE const projectId1 = await fixtures.GivenProject('someProject 1'); const projectId2 = await fixtures.GivenProject('the REAL project'); - const default2 = - await fixtures.GivenDefaultCostSurfaceForProject(projectId1); + await fixtures.GivenDefaultCostSurfaceForProject(projectId1); const costSurface11 = await fixtures.GivenCostSurfaceMetadataForProject( projectId1, 'costSurface 1 1', @@ -73,10 +73,12 @@ describe('Cost Surface', () => { // ARRANGE const projectId1 = await fixtures.GivenProject('someProject 1'); const projectId2 = await fixtures.GivenProject('the REAL project'); - const default1 = - await fixtures.GivenDefaultCostSurfaceForProject(projectId1); - const default2 = - await fixtures.GivenDefaultCostSurfaceForProject(projectId2); + const default1 = await fixtures.GivenDefaultCostSurfaceForProject( + projectId1, + ); + const default2 = await fixtures.GivenDefaultCostSurfaceForProject( + projectId2, + ); const costSurface11 = await fixtures.GivenCostSurfaceMetadataForProject( projectId1, 'costSurface 1 1', @@ -138,10 +140,12 @@ describe('Cost Surface', () => { ]; // ACT - const response1 = - await fixtures.WhenGettingCostSurfacesForProject(projectId1); - const response2 = - await fixtures.WhenGettingCostSurfacesForProject(projectId2); + const response1 = await fixtures.WhenGettingCostSurfacesForProject( + projectId1, + ); + const response2 = await fixtures.WhenGettingCostSurfacesForProject( + projectId2, + ); // ASSERT await fixtures.ThenReponseHasCostSurfaceList( @@ -178,12 +182,11 @@ describe('Cost Surface', () => { const shapefilePath = fixtures.GivenMockCostSurfaceShapefile(); // ACT - const response = - await fixtures.WhenUploadingCostSurfaceShapefileForProject( - projectId, - '', - shapefilePath, - ); + const response = await fixtures.WhenUploadingCostSurfaceShapefileForProject( + projectId, + '', + shapefilePath, + ); // ASSERT await fixtures.ThenEmptyErrorWasReturned(response); @@ -274,6 +277,192 @@ describe('Cost Surface', () => { }); }); + describe('Link Cost Surface To Scenario', () => { + it(`should link properly`, async () => { + // ARRANGE + const projectId = await fixtures.GivenProject('someProject'); + const defaultCostSurface = await fixtures.GivenDefaultCostSurfaceForProject( + projectId, + ); + const scenario = await fixtures.GivenScenario( + projectId, + defaultCostSurface.id, + 'someName', + ); + const costSurface = await fixtures.GivenCostSurfaceMetadataForProject( + projectId, + 'someCostSurface', + ); + fixtures.GivenNoJobsOnScenarioCostSurfaceQueue(); + + // ACT + await fixtures.WhenLinkingCostSurfaceToScenario( + scenario.id, + costSurface.id, + ); + + // ASSERT + await fixtures.ThenCostSurfaceIsLinkedToScenario( + scenario.id, + costSurface.id, + ); + await fixtures.ThenLinkCostSurfaceToScenarioJobWasSent( + scenario.id, + costSurface.id, + defaultCostSurface.id, + ); + await fixtures.ThenLinkCostSurfaceToScenarioSubmittedApiEventWasSaved( + scenario.id, + ); + }); + + it(`should return error when the Scenario was not found`, async () => { + // ARRANGE + const projectId = await fixtures.GivenProject('someProject'); + const defaultCostSurface = await fixtures.GivenDefaultCostSurfaceForProject( + projectId, + ); + const nonExistentScenarioId = v4(); + + // ACT + const response = await fixtures.WhenLinkingCostSurfaceToScenario( + nonExistentScenarioId, + defaultCostSurface.id, + ); + + // ASSERT + await fixtures.ThenScenarioNotFoundErrorWasReturned( + response, + nonExistentScenarioId, + ); + }); + + it(`should return error when the Cost Surface was not found`, async () => { + // ARRANGE + const projectId = await fixtures.GivenProject('someProject'); + const defaultCostSurface = await fixtures.GivenDefaultCostSurfaceForProject( + projectId, + ); + const scenario = await fixtures.GivenScenario( + projectId, + defaultCostSurface.id, + 'someName', + ); + const nonExistentCostSurfaceId = v4(); + + // ACT + const response = await fixtures.WhenLinkingCostSurfaceToScenario( + scenario.id, + nonExistentCostSurfaceId, + ); + + // ASSERT + await fixtures.ThenCostSurfaceNotFoundErrorWasReturned( + response, + nonExistentCostSurfaceId, + ); + }); + + it(`should return error when the Cost Surface being linked is from a different Project from the Scenario's`, async () => { + // ARRANGE + const projectId = await fixtures.GivenProject('someProject'); + const defaultCostSurface = await fixtures.GivenDefaultCostSurfaceForProject( + projectId, + ); + const scenario = await fixtures.GivenScenario( + projectId, + defaultCostSurface.id, + 'someName', + ); + const projectId2 = await fixtures.GivenProject('someProject2'); + const otherProjectCostSurface = await fixtures.GivenCostSurfaceMetadataForProject( + projectId2, + 'someCostSurface', + ); + + // ACT + const response = await fixtures.WhenLinkingCostSurfaceToScenario( + scenario.id, + otherProjectCostSurface.id, + ); + + // ASSERT + await fixtures.ThenCostSurfaceNotFoundErrorWasReturned( + response, + otherProjectCostSurface.id, + ); + }); + + it(`should return true and do nothing when the Cost Surface is already linked to the Scenario`, async () => { + // ARRANGE + const projectId = await fixtures.GivenProject('someProject'); + const defaultCostSurface = await fixtures.GivenDefaultCostSurfaceForProject( + projectId, + ); + const scenario = await fixtures.GivenScenario( + projectId, + defaultCostSurface.id, + 'someName', + ); + fixtures.GivenNoJobsOnScenarioCostSurfaceQueue(); + + // ACT + await fixtures.WhenLinkingCostSurfaceToScenario( + scenario.id, + defaultCostSurface.id, + ); + + // ASSERT + await fixtures.ThenNoJobWasSent(); + await fixtures.ThenCostSurfaceIsLinkedToScenario( + scenario.id, + defaultCostSurface.id, + ); + await fixtures.ThenNoLinkCostSurfaceToScenarioSubmittedApiEventWasSaved( + scenario.id, + ); + }); + + it(`should return error when the Cost Surface could not be linked (error at Command handler) `, async () => { + // ARRANGE + const projectId = await fixtures.GivenProject('someProject'); + const defaultCostSurface = await fixtures.GivenDefaultCostSurfaceForProject( + projectId, + ); + const scenario = await fixtures.GivenScenario( + projectId, + defaultCostSurface.id, + 'someName', + ); + const costSurface = await fixtures.GivenCostSurfaceMetadataForProject( + projectId, + 'someCostSurface', + ); + fixtures.GivenFailureWhenAddingJob(); + fixtures.GivenNoJobsOnScenarioCostSurfaceQueue(); + + // ACT + const response = await fixtures.WhenLinkingCostSurfaceToScenario( + scenario.id, + costSurface.id, + ); + + // ASSERT + await fixtures.ThenCostSurfaceCouldNotBeLinkedErrorWasReturned( + response, + scenario.id, + ); + await fixtures.ThenCostSurfaceIsLinkedToScenario( + scenario.id, + defaultCostSurface.id, + ); + await fixtures.ThenNoLinkCostSurfaceToScenarioSubmittedApiEventWasSaved( + scenario.id, + ); + await fixtures.ThenNoJobWasSent(); + }); + }); + describe('Delete Cost Surface', () => { it(`should delete the CostSurface properly and emit event`, async () => { // ARRANGE @@ -329,8 +518,9 @@ describe('Cost Surface', () => { it(`should return error if the CostSurface is the Project's default`, async () => { // ARRANGE const projectId = await fixtures.GivenProject('someProject'); - const costSurface = - await fixtures.GivenDefaultCostSurfaceForProject(projectId); + const costSurface = await fixtures.GivenDefaultCostSurfaceForProject( + projectId, + ); // ACT const response = await fixtures.WhenDeletingCostSurface( 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 9b65723f73..83fd48aa78 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 @@ -27,13 +27,19 @@ import { ProjectRoles } from '@marxan-api/modules/access-control/projects-acl/dt import { GivenProjectExists } from '../../steps/given-project'; import { GivenProjectsPu } from '../../../../geoprocessing/test/steps/given-projects-pu-exists'; import * as request from 'supertest'; -import { HttpStatus } from '@nestjs/common'; +import { HttpStatus, NotFoundException } from '@nestjs/common'; import { Scenario } from '@marxan-api/modules/scenarios/scenario.api.entity'; import { CqrsModule } from '@nestjs/cqrs'; import { EventBusTestUtils } from '../../utils/event-bus.test.utils'; import { CostSurfaceDeleted } from '@marxan-api/modules/cost-surface/events/cost-surface-deleted.event'; import { FakeQueue } from '../../utils/queues'; import { unusedResourcesCleanupQueueName } from '@marxan/unused-resources-cleanup'; +import { scenarioCostSurfaceQueueName } from '@marxan/artifact-cache/cost-surface-queue-name'; +import { ApiEventsService } from '@marxan-api/modules/api-events'; +import { API_EVENT_KINDS } from '@marxan/api-events'; +import { ScenarioRoles } from '@marxan-api/modules/access-control/scenarios-acl/dto/user-role-scenario.dto'; +import { UsersScenariosApiEntity } from '@marxan-api/modules/access-control/scenarios-acl/entity/users-scenarios.api.entity'; +import { ApiEvent } from '@marxan-api/modules/api-events/api-event.api.entity'; export const getProjectCostSurfaceFixtures = async () => { const app = await bootstrapApplication( @@ -51,6 +57,11 @@ export const getProjectCostSurfaceFixtures = async () => { const unusedResourceCleanupQueue = FakeQueue.getByName( unusedResourcesCleanupQueueName, ); + const scenarioCostSurfaceQueue = FakeQueue.getByName( + scenarioCostSurfaceQueueName, + ); + + const apiEventService = app.get(ApiEventsService); const token = await GivenUserIsLoggedIn(app, 'aa'); const userId = await GivenUserExists(app, 'aa'); @@ -76,6 +87,12 @@ export const getProjectCostSurfaceFixtures = async () => { const usersProjectsApiRepo: Repository = app.get( getRepositoryToken(UsersProjectsApiEntity), ); + const usersScenarioRolesRepo: Repository = app.get( + getRepositoryToken(UsersScenariosApiEntity), + ); + const apiEventsRepo: Repository = app.get( + getRepositoryToken(ApiEvent), + ); return { cleanup: async () => { @@ -83,6 +100,7 @@ export const getProjectCostSurfaceFixtures = async () => { await projectsPuRepo.delete({}); await organizationRepo.delete({}); await costSurfaceRepo.delete({}); + await apiEventsRepo.delete({}); eventBusTestUtils.stopInspectingEvents(); await app.close(); }, @@ -134,12 +152,25 @@ export const getProjectCostSurfaceFixtures = async () => { projectId: string, costSurfaceId: string, name?: string, + roles?: ScenarioRoles[], ) => { - return scenarioRepo.save({ + const scenario = await scenarioRepo.save({ projectId, costSurfaceId, name: name || `Scenario for project ${projectId}`, }); + + roles = roles ? roles : [ScenarioRoles.scenario_owner]; + + await usersScenarioRolesRepo.save( + roles.map((roleName) => ({ + scenarioId: scenario.id, + userId, + roleName, + })), + ); + + return scenario; }, GivenProjectPuData: async (projectId: string) => { @@ -163,6 +194,14 @@ export const getProjectCostSurfaceFixtures = async () => { GivenMockCostSurfaceShapefile: () => { return __dirname + `/../../upload-feature/import-files/wetlands.zip`; }, + GivenNoJobsOnScenarioCostSurfaceQueue: () => { + scenarioCostSurfaceQueue.disposeFakeJobs(); + }, + GivenFailureWhenAddingJob: () => { + scenarioCostSurfaceQueue.add.mockRejectedValueOnce( + new Error('Something happened!'), + ); + }, WhenGettingCostSurfacesForProject: async (projectId: string) => { return request(app.getHttpServer()) @@ -205,6 +244,17 @@ export const getProjectCostSurfaceFixtures = async () => { .delete(`/api/v1/projects/${projectId}/cost-surfaces/${costSurfaceId}`) .set('Authorization', `Bearer ${token}`); }, + WhenLinkingCostSurfaceToScenario: async ( + scenarioId: string, + costSurfaceId: string, + ) => { + return request(app.getHttpServer()) + .post( + `/api/v1/scenarios/${scenarioId}/link-cost-surface/${costSurfaceId}`, + ) + .set('Authorization', `Bearer ${token}`) + .send(); + }, WhenGettingCostSurfaceRange: async ( costSurfaceId: string, @@ -225,6 +275,53 @@ export const getProjectCostSurfaceFixtures = async () => { expect(savedCostSurface).toBeDefined(); expect(savedCostSurface?.name).toEqual(name); }, + ThenCostSurfaceIsLinkedToScenario: async ( + scenarioId: string, + linkedCostSurfaceId: string, + ) => { + const scenario = await scenarioRepo.findOneOrFail({ + where: { id: scenarioId }, + }); + + expect(scenario.costSurfaceId).toEqual(linkedCostSurfaceId); + }, + ThenLinkCostSurfaceToScenarioJobWasSent: async ( + scenarioId: string, + costSurfaceId: string, + originalCostSurfaceId: string, + ) => { + //expect(Object.values(scenarioCostSurfaceQueue.jobs).length).toBe(1); + const job = Object.values(scenarioCostSurfaceQueue.jobs).at(-1); + if (!job) throw new Error(); + expect(job.data.type).toEqual('LinkCostSurfaceToScenarioJobInput'); + expect(job.data.scenarioId).toEqual(scenarioId); + expect(job.data.costSurfaceId).toEqual(costSurfaceId); + expect(job.data.originalCostSurfaceId).toEqual(originalCostSurfaceId); + expect(job.data.mode).toEqual('update'); + }, + ThenLinkCostSurfaceToScenarioSubmittedApiEventWasSaved: async ( + scenarioId: string, + ) => { + await apiEventService.getLatestEventForTopic({ + topic: scenarioId, + kind: API_EVENT_KINDS.scenario__costSurface__link__submitted__v1_alpha1, + }); + }, + ThenNoJobWasSent: async () => { + expect(Object.values(scenarioCostSurfaceQueue.jobs).length).toBe(0); + }, + ThenNoLinkCostSurfaceToScenarioSubmittedApiEventWasSaved: async ( + scenarioId: string, + ) => { + await expect( + async () => + await apiEventService.getLatestEventForTopic({ + topic: scenarioId, + kind: + API_EVENT_KINDS.scenario__costSurface__link__submitted__v1_alpha1, + }), + ).rejects.toThrow(NotFoundException); + }, ThenResponseHasCostSurface: async ( response: request.Response, @@ -309,6 +406,16 @@ export const getProjectCostSurfaceFixtures = async () => { `Project with id ${projectId} is not editable by user ${userId}`, ); }, + ThenScenarioNotEditableErrorWasReturned: ( + response: request.Response, + scenarioId: string, + ) => { + const error: any = response.body.errors[0].title; + expect(response.status).toBe(HttpStatus.FORBIDDEN); + expect(error).toContain( + `Scenario with id ${scenarioId} is not editable by the given user`, + ); + }, ThenCostSurfaceWasNotCreated: async (costSurfaceName: string) => { const costSurface = await costSurfaceRepo.findOne({ where: { name: costSurfaceName }, @@ -329,8 +436,9 @@ export const getProjectCostSurfaceFixtures = async () => { expect(costSurface).not.toBeNull(); }, ThenCostSurfaceDeletedEventWasEmitted: async (costSurfaceId: string) => { - const event = - await eventBusTestUtils.waitUntilEventIsPublished(CostSurfaceDeleted); + const event = await eventBusTestUtils.waitUntilEventIsPublished( + CostSurfaceDeleted, + ); expect(event).toMatchObject({ costSurfaceId }); }, @@ -398,5 +506,29 @@ export const getProjectCostSurfaceFixtures = async () => { expect(response.status).toBe(HttpStatus.OK); expect(response.body).toEqual({ min: 0, max: 0 }); }, + + ThenScenarioNotFoundErrorWasReturned: ( + response: request.Response, + scenarioId: string, + ) => { + const error: any = response.body.errors[0].meta.rawError.response.message; + expect(error).toContain(`Scenario ${scenarioId} could not be found.`); + }, + ThenCostSurfaceNotFoundErrorWasReturned: ( + response: request.Response, + costSurfaceId: string, + ) => { + const error: any = response.body.errors[0].meta.rawError.response.message; + expect(error).toContain(`Cost Surface ${costSurfaceId} not found`); + }, + ThenCostSurfaceCouldNotBeLinkedErrorWasReturned: ( + response: request.Response, + scenarioId: string, + ) => { + const error: any = response.body.errors[0].meta.rawError.response.message; + expect(error).toContain( + `Linking Cost Surface to Scenario ${scenarioId} failed`, + ); + }, }; }; diff --git a/api/apps/geoprocessing/src/app.module.ts b/api/apps/geoprocessing/src/app.module.ts index 1e50c0fb1d..cf4db40b62 100644 --- a/api/apps/geoprocessing/src/app.module.ts +++ b/api/apps/geoprocessing/src/app.module.ts @@ -24,6 +24,7 @@ import { LegacyProjectImportModule } from './legacy-project-import/legacy-projec import { UnusedResourcesCleanUpModule } from './modules/unused-resources-cleanup/unused-resources-cleanup.module'; import { CleanupTasksModule } from './modules/cleanup-tasks/cleanup-tasks.module'; import { ProjectCostSurfaceModule } from '@marxan-geoprocessing/modules/cost-surface/project/project-cost-surface.module'; +import { ScenarioCostSurfaceModule } from '@marxan-geoprocessing/modules/cost-surface/scenario/scenario-cost-surface.module'; @Module({ imports: [ @@ -45,6 +46,7 @@ import { ProjectCostSurfaceModule } from '@marxan-geoprocessing/modules/cost-sur ApiEventsModule, CostSurfaceModule, ProjectCostSurfaceModule, + ScenarioCostSurfaceModule, ScenarioPlanningUnitsInclusionModule, ScenarioProtectedAreaCalculationModule, PlanningAreaModule, 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 new file mode 100644 index 0000000000..c20cccd3c5 --- /dev/null +++ b/api/apps/geoprocessing/src/modules/cost-surface/adapters/scenario/typeorm-scenario-cost-surface.ts @@ -0,0 +1,66 @@ +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'; + +@Injectable() +export class TypeormScenarioCostSurface + implements ScenarioCostSurfacePersistencePort { + constructor( + @InjectEntityManager(geoprocessingConnections.default) + private readonly geoprocessingEntityManager: EntityManager, + ) {} + + async linkScenarioToCostSurface( + scenarioId: string, + costSurfaceId: string, + ): 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, + })), + ); + }, + ), + ); + }); + } +} 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 new file mode 100644 index 0000000000..5789d1f30c --- /dev/null +++ b/api/apps/geoprocessing/src/modules/cost-surface/application/scenario-cost-surface-processor.service.ts @@ -0,0 +1,34 @@ +import { WorkerProcessor } from '@marxan-geoprocessing/modules/worker'; +import { Injectable } from '@nestjs/common'; +import { Job } from 'bullmq'; +import { + LinkCostSurfaceToScenarioJobInput, + ScenarioCostSurfaceJobInput, +} from '@marxan/artifact-cache/surface-cost-job-input'; +import { ScenarioCostSurfacePersistencePort } from '@marxan-geoprocessing/modules/cost-surface/ports/persistence/scenario-cost-surface-persistence.port'; + +@Injectable() +export class ScenarioCostSurfaceProcessor + implements WorkerProcessor { + constructor(private readonly repo: ScenarioCostSurfacePersistencePort) {} + + private async linkCostSurfaceToScenario({ + data, + }: Job): Promise { + await this.repo.linkScenarioToCostSurface( + data.scenarioId, + data.costSurfaceId, + ); + + return true; + } + + async process(job: Job): Promise { + if (job.data.type === 'LinkCostSurfaceToScenarioJobInput') { + return this.linkCostSurfaceToScenario( + job as Job, + ); + } + return true; + } +} diff --git a/api/apps/geoprocessing/src/modules/cost-surface/application/scenario-cost-surface-worker.service.ts b/api/apps/geoprocessing/src/modules/cost-surface/application/scenario-cost-surface-worker.service.ts new file mode 100644 index 0000000000..e589c14c7b --- /dev/null +++ b/api/apps/geoprocessing/src/modules/cost-surface/application/scenario-cost-surface-worker.service.ts @@ -0,0 +1,18 @@ +import { Injectable } from '@nestjs/common'; +import { EventBus } from '@nestjs/cqrs'; +import { Worker } from 'bullmq'; +import { WorkerBuilder } from '@marxan-geoprocessing/modules/worker'; +import { scenarioCostSurfaceQueueName } from '@marxan/artifact-cache/cost-surface-queue-name'; +import { ScenarioCostSurfaceProcessor } from '@marxan-geoprocessing/modules/cost-surface/application/scenario-cost-surface-processor.service'; + +@Injectable() +export class ScenarioCostSurfaceWorker { + #worker: Worker; + + constructor( + private readonly wrapper: WorkerBuilder, + private readonly processor: ScenarioCostSurfaceProcessor, + ) { + this.#worker = wrapper.build(scenarioCostSurfaceQueueName, processor); + } +} 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 new file mode 100644 index 0000000000..1842ad7274 --- /dev/null +++ b/api/apps/geoprocessing/src/modules/cost-surface/ports/persistence/scenario-cost-surface-persistence.port.ts @@ -0,0 +1,6 @@ +export abstract class ScenarioCostSurfacePersistencePort { + abstract linkScenarioToCostSurface( + scenarioId: string, + costSurface: string, + ): Promise; +} diff --git a/api/apps/geoprocessing/src/modules/cost-surface/scenario/scenario-cost-surface.module.ts b/api/apps/geoprocessing/src/modules/cost-surface/scenario/scenario-cost-surface.module.ts new file mode 100644 index 0000000000..7b0499ab36 --- /dev/null +++ b/api/apps/geoprocessing/src/modules/cost-surface/scenario/scenario-cost-surface.module.ts @@ -0,0 +1,34 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { CqrsModule } from '@nestjs/cqrs'; +import { WorkerModule } from '@marxan-geoprocessing/modules/worker'; +import { CostSurfacePuDataEntity } from '@marxan/cost-surfaces'; +import { ScenarioCostSurfaceWorker } from '@marxan-geoprocessing/modules/cost-surface/application/scenario-cost-surface-worker.service'; +import { ScenarioCostSurfaceProcessor } from '@marxan-geoprocessing/modules/cost-surface/application/scenario-cost-surface-processor.service'; +import { ScenarioCostSurfacePersistencePort } from '@marxan-geoprocessing/modules/cost-surface/ports/persistence/scenario-cost-surface-persistence.port'; +import { TypeormScenarioCostSurface } from '@marxan-geoprocessing/modules/cost-surface/adapters/scenario/typeorm-scenario-cost-surface'; +import { + ScenariosPuCostDataGeo, + ScenariosPuPaDataGeo, +} from '@marxan/scenarios-planning-unit'; + +@Module({ + imports: [ + WorkerModule, + CqrsModule, + TypeOrmModule.forFeature([ + ScenariosPuCostDataGeo, + ScenariosPuPaDataGeo, + CostSurfacePuDataEntity, + ]), + ], + providers: [ + ScenarioCostSurfaceWorker, + ScenarioCostSurfaceProcessor, + { + provide: ScenarioCostSurfacePersistencePort, + useClass: TypeormScenarioCostSurface, + }, + ], +}) +export class ScenarioCostSurfaceModule {} 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 new file mode 100644 index 0000000000..5c6ba995a4 --- /dev/null +++ b/api/apps/geoprocessing/test/integration/cost-surface/link-cost-surface.e2e-spec.ts @@ -0,0 +1,26 @@ +import { bootstrapApplication } from '../../utils'; +import { v4 } from 'uuid'; +import { createWorld } from './steps/world'; + +describe('should process cost surface', () => { + let world: any; + beforeAll(async () => { + const app = await bootstrapApplication(); + world = await createWorld(app); + }); + it('should link the cost surface data to the scenario data', async () => { + const scenarioId = v4(); + const projectId = v4(); + await world.GivenScenarioPuDataExists(projectId, scenarioId); + await world.GivenPuCostDataExists(); + const costSurfaceId = v4(); + await world.GivenCostSurfacePuDataExists(costSurfaceId); + + const linkCostSurfaceJob = world.getLinkCostSurfaceToScenarioJob( + scenarioId, + costSurfaceId, + ); + await world.WhenTheCostSurfaceLinkingJobIsProcessed(linkCostSurfaceJob); + await world.ThenTheScenarioPuCostDataIsUpdated(costSurfaceId, 42); + }); +}); diff --git a/api/apps/geoprocessing/test/integration/cost-surface/planning-unit-fixtures.ts b/api/apps/geoprocessing/test/integration/cost-surface/planning-unit-fixtures.ts index 558c23932d..d609b1e36f 100644 --- a/api/apps/geoprocessing/test/integration/cost-surface/planning-unit-fixtures.ts +++ b/api/apps/geoprocessing/test/integration/cost-surface/planning-unit-fixtures.ts @@ -11,13 +11,15 @@ import { v4 } from 'uuid'; import { GivenScenarioPuDataExists } from '../../steps/given-scenario-pu-data-exists'; import { ProjectCostSurfaceProcessor } from '@marxan-geoprocessing/modules/cost-surface/application/project-cost-surface.processor'; import { CostSurfacePuDataEntity } from '@marxan/cost-surfaces'; +import { ScenarioCostSurfaceProcessor } from '@marxan-geoprocessing/modules/cost-surface/application/scenario-cost-surface-processor.service'; export const getFixtures = async (app: INestApplication) => { const projectId = v4(); const scenarioId = v4(); const entityManager = app.get(getEntityManagerToken()); - const projectsPuRepo: Repository = - entityManager.getRepository(ProjectsPuEntity); + const projectsPuRepo: Repository = entityManager.getRepository( + ProjectsPuEntity, + ); const planningUnitsGeomRepo: Repository = app.get( getRepositoryToken(PlanningUnitsGeom), ); @@ -31,8 +33,11 @@ export const getFixtures = async (app: INestApplication) => { const projectCostSurfaceProcessor: ProjectCostSurfaceProcessor = app.get( ProjectCostSurfaceProcessor, ); + const scenarioCostSurfaceProcessor: ScenarioCostSurfaceProcessor = app.get( + ScenarioCostSurfaceProcessor, + ); - const scenarioPuData = await GivenScenarioPuDataExists( + let scenarioPuData = await GivenScenarioPuDataExists( entityManager, projectId, scenarioId, @@ -43,6 +48,7 @@ export const getFixtures = async (app: INestApplication) => { return { projectCostSurfaceProcessor, + scenarioCostSurfaceProcessor, costSurfacePuDataRepo, planningUnitDataRepo: scenarioPuData, planningUnitCostDataRepo: puCostDataRepo, @@ -75,6 +81,27 @@ export const getFixtures = async (app: INestApplication) => { .then((scenarioPlanningUnits) => scenarioPlanningUnits.map((spu) => spu?.id).filter(isDefined), ), + GivenScenarioPuDataExists: async ( + projectId: string, + scenarioId: string, + ) => { + scenarioPuData = await GivenScenarioPuDataExists( + entityManager, + projectId, + scenarioId, + ); + }, + GivenCostSurfacePuDataExists: async (costSurfaceId: string) => { + await costSurfacePuDataRepo.save( + scenarioPuData.map((scenarioPu) => + costSurfacePuDataRepo.create({ + costSurfaceId, + projectsPuId: scenarioPu.projectPuId, + cost: 42, + }), + ), + ); + }, cleanup: async () => { const projectsPu = await projectsPuRepo.find({ where: { 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 7e1df971ec..9afae224b4 100644 --- a/api/apps/geoprocessing/test/integration/cost-surface/steps/world.ts +++ b/api/apps/geoprocessing/test/integration/cost-surface/steps/world.ts @@ -14,9 +14,11 @@ import { getFixtures } from '../planning-unit-fixtures'; import { CostSurfaceShapefileRecord } from '@marxan-geoprocessing/modules/cost-surface/ports/cost-surface-shapefile-record'; import { FromProjectShapefileJobInput, + LinkCostSurfaceToScenarioJobInput, ProjectCostSurfaceJobInput, } from '@marxan/artifact-cache/surface-cost-job-input'; import { CostSurfacePuDataEntity } from '@marxan/cost-surfaces'; +import { v4 } from 'uuid'; export const createWorld = async (app: INestApplication) => { const newCost = [199.99, 300, 1]; @@ -33,27 +35,47 @@ export const createWorld = async (app: INestApplication) => { }, WhenTheJobIsProcessed: async (job: Job) => fixtures.projectCostSurfaceProcessor.process(job), + WhenTheCostSurfaceLinkingJobIsProcessed: async ( + job: Job, + ) => await fixtures.scenarioCostSurfaceProcessor.process(job), GivenPuCostDataExists: fixtures.GivenPuCostDataExists, + GivenScenarioPuDataExists: fixtures.GivenScenarioPuDataExists, + GivenCostSurfacePuDataExists: fixtures.GivenCostSurfacePuDataExists, getShapefileForScenarioWithCost: () => - ({ + (({ data: { scenarioId: fixtures.scenarioId, shapefile, }, id: 'test-job', - }) as unknown as Job, + } as unknown) as Job), getShapefileForProjectWithCost: ( projectId: string, costSurfaceId: string, ) => - ({ + (({ data: { projectId, costSurfaceId, shapefile, }, id: 'test-job', - }) as unknown as Job, + } as unknown) as Job), + getLinkCostSurfaceToScenarioJob: ( + scenarioId: string, + costSurfaceId: string, + ) => + (({ + data: { + type: 'LinkCostSurfaceToScenarioJobInput', + projectId: v4(), + scenarioId, + costSurfaceId, + originalCostSurfaceId: v4(), + mode: 'creation', + }, + id: 'test-job', + } as unknown) as Job), ThenCostIsUpdated: async () => { const newCost = await fixtures.GetPuCostsData(fixtures.scenarioId); expect(newCost).toEqual( @@ -76,6 +98,21 @@ export const createWorld = async (app: INestApplication) => { .then((cost: CostSurfacePuDataEntity[]) => expect(cost[0].cost).toEqual(199.99), ), + ThenTheScenarioPuCostDataIsUpdated: async ( + scenarioId: string, + cost: number, + ) => { + const puCosts = await fixtures.planningUnitCostDataRepo + .createQueryBuilder('spcd') + .select('cost') + .leftJoin( + 'scenarios_pu_data', + 'spd', + 'spcd.scenarios_pu_data_id = spd.id', + ) + .getRawMany(); + expect(puCosts.every((puCost) => puCost.cost === cost)).toBeTruthy(); + }, }; }; diff --git a/api/libs/api-events/src/api-event-kinds.enum.ts b/api/libs/api-events/src/api-event-kinds.enum.ts index bdedc2921e..57007d39af 100644 --- a/api/libs/api-events/src/api-event-kinds.enum.ts +++ b/api/libs/api-events/src/api-event-kinds.enum.ts @@ -11,6 +11,10 @@ export enum API_EVENT_KINDS { project__costSurface_shapefile_submitted__v1alpha1 = 'project.costSurface.shapefile.submitted/v1alpha1', project__costSurface_shapefile_finished__v1alpha1 = 'project.costSurface.shapefile.finished/v1alpha1', project__costSurface_shapeConverted__v1alpha1 = 'project.costSurface.shapeConverted/v1alpha1', + scenario__costSurface__link__submitted__v1_alpha1 = 'scenario.costSurface.link.submitted/v1/alpha1', + scenario__costSurface__link__finished__v1_alpha1 = 'scenario.costSurface.link.finished/v1/alpha1', + scenario__costSurface__link__failed__v1_alpha1 = 'scenario.costSurface.link.failed/v1/alpha1', + project__costSurface_shapeConversionFailed__v1alpha1 = 'project.costSurface.shapeConversionFailed/v1alpha1', project__costSurface_shapefile_failed__v1alpha1 = 'project.costSurface.shapefile.failed/v1alpha1', scenario__costSurface__submitted__v1_alpha1 = 'scenario.costSurface.submitted/v1alpha1', @@ -120,5 +124,4 @@ export type ScenarioGeofeatureEvents = Pick< typeof API_EVENT_KINDS, ScenarioGeoFeatureEventKeys >; -export type ScenarioGeofeatureEventValues = - ValuesType; +export type ScenarioGeofeatureEventValues = ValuesType; diff --git a/api/libs/artifact-cache/src/cost-surface-queue-name.ts b/api/libs/artifact-cache/src/cost-surface-queue-name.ts index a223d5f7c7..57c1f916f9 100644 --- a/api/libs/artifact-cache/src/cost-surface-queue-name.ts +++ b/api/libs/artifact-cache/src/cost-surface-queue-name.ts @@ -5,4 +5,6 @@ */ export const costSurfaceQueueName = 'cost-surface'; +export const scenarioCostSurfaceQueueName = 'scenario-cost-surface'; + export const projectCostSurfaceQueueName = 'project-cost-surface'; 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 173310acca..abe1d4c866 100644 --- a/api/libs/artifact-cache/src/surface-cost-job-input.ts +++ b/api/libs/artifact-cache/src/surface-cost-job-input.ts @@ -14,6 +14,18 @@ export type InitialProjectCostInput = { export type ProjectCostSurfaceJobInput = | FromProjectShapefileJobInput | InitialProjectCostInput; + +export type LinkCostSurfaceToScenarioJobInput = { + type: 'LinkCostSurfaceToScenarioJobInput'; + scenarioId: string; + costSurfaceId: string; + originalCostSurfaceId: string; + + mode: 'creation' | 'update'; +}; + +export type ScenarioCostSurfaceJobInput = LinkCostSurfaceToScenarioJobInput; + /** * @note: we should deprecate all of the below */