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 527183ed88..4aa285e5fb 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 @@ -14,20 +14,22 @@ beforeEach(async () => { fixtures = await getFixtures(); }); -it('should return project pieces when resolving for a project', async () => { +it('resolves project pieces', async () => { const projectId = fixtures.GivenAProjectExport(); const result = await fixtures.WhenRequestingExportPieces( projectId, ResourceKind.Project, + [], ); fixtures.ThenProjectPiecesShouldBeIncluded({ components: result }); }); -it(`should return ${ClonePiece.PlanningAreaCustom} when resolving for a project with a custom planning area`, async () => { +it(`resolves ${ClonePiece.PlanningAreaCustom} piece when invoked with a custom planning area project`, async () => { const projectId = fixtures.GivenAProjectExportWithCustomPlanningArea(); const result = await fixtures.WhenRequestingExportPieces( projectId, ResourceKind.Project, + [], ); fixtures.ThenProjectPiecesShouldBeIncluded({ components: result, @@ -35,28 +37,34 @@ it(`should return ${ClonePiece.PlanningAreaCustom} when resolving for a project }); }); -it('should return scenario pieces when resolving for a scenario', async () => { +it('resolves scenario pieces', async () => { const scenarioId = fixtures.GivenAScenarioExport(); const result = await fixtures.WhenRequestingExportPieces( scenarioId, ResourceKind.Scenario, + [], ); fixtures.ThenScenarioPiecesShouldBeIncluded({ components: result, }); }); -it('should return project and scenario pieces when resolving for a project if the project has scenarios', async () => { - const scenariosCount = 2; - const projectId = fixtures.GivenAProjectExportWithScenarios(scenariosCount); +it('resolves project and specific scenario pieces when invoked specifying an array of scenarios ids', async () => { + const projectScenariosCount = 5; + const [projectId, scenariosIds] = fixtures.GivenAProjectExportWithScenarios( + projectScenariosCount, + ); + const scenariosToBeExportedCount = 2; + const result = await fixtures.WhenRequestingExportPieces( projectId, ResourceKind.Project, + scenariosIds.slice(0, scenariosToBeExportedCount), ); fixtures.ThenProjectAndScenarioPiecesShouldBeIncluded({ components: result, projectWithCustomPlanningArea: false, - scenariosCount, + scenariosCount: scenariosToBeExportedCount, }); }); @@ -106,33 +114,50 @@ const getFixtures = async () => { return { GivenAProjectExport: () => { const projectId = ResourceId.create(); + repo.mockProject(projectId.value, {}); + return projectId; + }, + GivenAProjectExportWithARegularPlanningArea: () => { + const projectId = ResourceId.create(); repo.mockProject(projectId.value, {}); return projectId; }, GivenAProjectExportWithCustomPlanningArea: () => { const projectId = ResourceId.create(); - repo.mockProject(projectId.value, { planningAreaGeometryId: v4() }); return projectId; }, - GivenAProjectExportWithScenarios: (scenariosCount: number) => { + GivenAProjectExportWithScenarios: ( + scenariosCount: number, + ): [ResourceId, string[]] => { const projectId = ResourceId.create(); - const scenarios = Array(scenariosCount).fill({ id: v4() }); + const scenarios: { id: string }[] = Array(scenariosCount) + .fill(0) + .map(() => ({ + id: v4(), + })); repo.mockProject(projectId.value, { scenarios, }); - return projectId; + return [projectId, scenarios.map((scenario) => scenario.id)]; }, GivenAScenarioExport: () => { return ResourceId.create(); }, - WhenRequestingExportPieces: (id: ResourceId, kind: ResourceKind) => { - return sut.resolveFor(id, kind); + WhenRequestingExportPieces: ( + id: ResourceId, + kind: ResourceKind, + scenarioIds: string[], + ) => { + if (kind === ResourceKind.Project) + return sut.resolveForProject(id, scenarioIds); + + return sut.resolveForScenario(id, kind); }, ThenProjectPiecesShouldBeIncluded: ({ components, 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 6ec0e0fd07..48c1c8cea5 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 @@ -8,41 +8,34 @@ import { ExportComponent } from '../domain'; @Injectable() export class ExportResourcePiecesAdapter implements ExportResourcePieces { - private resolverMapping: Record< - ResourceKind, - (id: ResourceId, kind: ResourceKind) => Promise - > = { - project: this.resolveForProject.bind(this), - scenario: this.resolveForScenario.bind(this), - }; - constructor( @InjectRepository(Project) private readonly projectRepository: Repository, ) {} - resolveFor(id: ResourceId, kind: ResourceKind): Promise { - return this.resolverMapping[kind](id, kind); - } - - private async resolveForProject( + async resolveForProject( id: ResourceId, - kind: ResourceKind, + scenarioIds: string[], ): Promise { const project = await this.projectRepository.findOneOrFail(id.value, { relations: ['scenarios'], }); const { scenarios } = project; - const scenarioPieces: ExportComponent[][] = []; + const scenarioPieces: ExportComponent[] = []; if (scenarios) { scenarioPieces.push( - ...(await Promise.all( - scenarios.map((scenario) => - this.resolveForScenario(new ResourceId(scenario.id), kind), + ...scenarios + .filter( + (scenario) => !scenarioIds || scenarioIds.includes(scenario.id), + ) + .flatMap((scenario) => + this.resolveForScenario( + new ResourceId(scenario.id), + ResourceKind.Project, + ), ), - )), ); } @@ -62,16 +55,13 @@ export class ExportResourcePiecesAdapter implements ExportResourcePieces { ExportComponent.newOne(id, ClonePiece.PlanningUnitsGrid), ExportComponent.newOne(id, ClonePiece.PlanningUnitsGridGeojson), ExportComponent.newOne(id, ClonePiece.ProjectCustomProtectedAreas), - ...scenarioPieces.flat(), + ...scenarioPieces, ]; return components; } - private async resolveForScenario( - id: ResourceId, - kind: ResourceKind, - ): Promise { + resolveForScenario(id: ResourceId, kind: ResourceKind): ExportComponent[] { const pieces: ExportComponent[] = [ ExportComponent.newOne(id, ClonePiece.ScenarioMetadata), ExportComponent.newOne(id, ClonePiece.ScenarioProtectedAreas), diff --git a/api/apps/api/src/modules/clone/export/application/export-project.command.ts b/api/apps/api/src/modules/clone/export/application/export-project.command.ts index 4685577a84..189d131cd1 100644 --- a/api/apps/api/src/modules/clone/export/application/export-project.command.ts +++ b/api/apps/api/src/modules/clone/export/application/export-project.command.ts @@ -3,7 +3,10 @@ import { ResourceId } from '@marxan/cloning/domain'; import { ExportId } from '../domain'; export class ExportProject extends Command { - constructor(public readonly id: ResourceId) { + constructor( + public readonly id: ResourceId, + public readonly scenarioIds: string[], + ) { super(); } } diff --git a/api/apps/api/src/modules/clone/export/application/export-project.handler.spec.ts b/api/apps/api/src/modules/clone/export/application/export-project.handler.spec.ts index d60520f10a..24907f4568 100644 --- a/api/apps/api/src/modules/clone/export/application/export-project.handler.spec.ts +++ b/api/apps/api/src/modules/clone/export/application/export-project.handler.spec.ts @@ -71,7 +71,7 @@ const getFixtures = async () => { return { projectId }; }, WhenExportIsRequested: async (projectId: ResourceId) => - sut.execute(new ExportProject(projectId)), + sut.execute(new ExportProject(projectId, [])), ThenExportRequestIsSaved: async (exportId: ExportId) => { expect((await repo.find(exportId))?.toSnapshot()).toBeDefined(); }, @@ -115,13 +115,17 @@ const getFixtures = async () => { @Injectable() class FakePiecesProvider implements ExportResourcePieces { resolveMock: jest.MockedFunction< - ExportResourcePieces['resolveFor'] + ExportResourcePieces['resolveForProject'] > = jest.fn(); - async resolveFor( + async resolveForProject( id: ResourceId, - kind: ResourceKind, + scenarioIds: string[], ): Promise { - return this.resolveMock(id, kind); + return this.resolveMock(id, scenarioIds); + } + + resolveForScenario(id: ResourceId, kind: ResourceKind): ExportComponent[] { + return []; } } diff --git a/api/apps/api/src/modules/clone/export/application/export-project.handler.ts b/api/apps/api/src/modules/clone/export/application/export-project.handler.ts index 01eccf288d..560092b44b 100644 --- a/api/apps/api/src/modules/clone/export/application/export-project.handler.ts +++ b/api/apps/api/src/modules/clone/export/application/export-project.handler.ts @@ -20,9 +20,9 @@ export class ExportProjectHandler private readonly eventPublisher: EventPublisher, ) {} - async execute({ id }: ExportProject): Promise { + async execute({ id, scenarioIds }: ExportProject): Promise { const kind = ResourceKind.Project; - const pieces = await this.resourcePieces.resolveFor(id, kind); + const pieces = await this.resourcePieces.resolveForProject(id, scenarioIds); const exportRequest = this.eventPublisher.mergeObjectContext( Export.newOne(id, kind, pieces), ); diff --git a/api/apps/api/src/modules/clone/export/application/export-resource-pieces.port.ts b/api/apps/api/src/modules/clone/export/application/export-resource-pieces.port.ts index 09499f2391..4bba239d21 100644 --- a/api/apps/api/src/modules/clone/export/application/export-resource-pieces.port.ts +++ b/api/apps/api/src/modules/clone/export/application/export-resource-pieces.port.ts @@ -2,8 +2,13 @@ import { ResourceId, ResourceKind } from '@marxan/cloning/domain'; import { ExportComponent } from '../domain'; export abstract class ExportResourcePieces { - abstract resolveFor( + abstract resolveForProject( id: ResourceId, - kind: ResourceKind, + scenarioIds: string[], ): Promise; + + abstract resolveForScenario( + id: ResourceId, + kind: ResourceKind, + ): ExportComponent[]; } diff --git a/api/apps/api/src/modules/clone/export/application/export-scenario.handler.ts b/api/apps/api/src/modules/clone/export/application/export-scenario.handler.ts index b50098c6f2..f424c68c9d 100644 --- a/api/apps/api/src/modules/clone/export/application/export-scenario.handler.ts +++ b/api/apps/api/src/modules/clone/export/application/export-scenario.handler.ts @@ -22,7 +22,7 @@ export class ExportScenarioHandler async execute({ scenarioId, projectId }: ExportScenario): Promise { const kind = ResourceKind.Scenario; - const pieces = await this.resourcePieces.resolveFor(scenarioId, kind); + const pieces = this.resourcePieces.resolveForScenario(scenarioId, kind); const exportRequest = this.eventPublisher.mergeObjectContext( Export.newOne(projectId, kind, pieces), ); diff --git a/api/apps/api/src/modules/projects/dto/export.project.dto.ts b/api/apps/api/src/modules/projects/dto/export.project.dto.ts new file mode 100644 index 0000000000..8fba06f621 --- /dev/null +++ b/api/apps/api/src/modules/projects/dto/export.project.dto.ts @@ -0,0 +1,24 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsUUID } from 'class-validator'; + +export class RequestProjectExportBodyDto { + @ApiProperty({ + description: 'Array of ids of scenarios to be exported', + isArray: true, + type: 'string', + example: [ + 'c214c6b9-1683-4b95-9221-8e378932fad1', + '873c952c-86e7-4fee-b2d8-500c3f72ad80', + ], + }) + @IsUUID(4, { each: true }) + scenarioIds!: string[]; +} + +export class RequestProjectExportResponseDto { + @ApiProperty({ + description: 'ID of the project', + example: '6fbec34e-04a7-4131-be14-c245f2435a6c', + }) + id!: string; +} diff --git a/api/apps/api/src/modules/projects/dto/export.project.response.dto.ts b/api/apps/api/src/modules/projects/dto/export.project.response.dto.ts deleted file mode 100644 index 4edd89671c..0000000000 --- a/api/apps/api/src/modules/projects/dto/export.project.response.dto.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { ApiProperty } from '@nestjs/swagger'; - -export class RequestProjectExportResponseDto { - @ApiProperty({ - description: 'ID of the project', - example: '6fbec34e-04a7-4131-be14-c245f2435a6c', - }) - id!: string; -} diff --git a/api/apps/api/src/modules/projects/projects.controller.ts b/api/apps/api/src/modules/projects/projects.controller.ts index abab916e7d..c51451bbdd 100644 --- a/api/apps/api/src/modules/projects/projects.controller.ts +++ b/api/apps/api/src/modules/projects/projects.controller.ts @@ -96,7 +96,10 @@ import { IsMissingAclImplementation, } from '@marxan-api/decorators/acl.decorator'; import { locationNotFound } from '@marxan-api/modules/clone/export/application/get-archive.query'; -import { RequestProjectExportResponseDto } from './dto/export.project.response.dto'; +import { + RequestProjectExportResponseDto, + RequestProjectExportBodyDto, +} from './dto/export.project.dto'; import { ScenarioLockResultPlural } from '@marxan-api/modules/access-control/scenarios-acl/locks/dto/scenario.lock.dto'; import { RequestProjectImportResponseDto } from './dto/import.project.response.dto'; import { unknownError as fileRepositoryUnknownError } from '@marxan/files-repository'; @@ -417,7 +420,10 @@ export class ProjectsController { throw new ForbiddenException(); } req.url = req.url.replace(result.right.from, result.right.to); - req.originalUrl = req.originalUrl.replace(result.right.from, result.right.to); + req.originalUrl = req.originalUrl.replace( + result.right.from, + result.right.to, + ); return await this.proxyService.proxyTileRequest(req, response); } @@ -463,10 +469,11 @@ export class ProjectsController { throw new ForbiddenException(); } - - req.url = req.url.replace(result.right.from, result.right.to); - req.originalUrl = req.originalUrl.replace(result.right.from, result.right.to); + req.originalUrl = req.originalUrl.replace( + result.right.from, + result.right.to, + ); req.query = result.right.query; @@ -590,10 +597,12 @@ export class ProjectsController { async requestProjectExport( @Param('projectId') projectId: string, @Req() req: RequestWithAuthenticatedUser, + @Body() dto: RequestProjectExportBodyDto, ) { const result = await this.projectsService.requestExport( projectId, req.user.id, + dto.scenarioIds, ); if (isLeft(result)) { diff --git a/api/apps/api/src/modules/projects/projects.service.ts b/api/apps/api/src/modules/projects/projects.service.ts index 31d6514ab5..2056966383 100644 --- a/api/apps/api/src/modules/projects/projects.service.ts +++ b/api/apps/api/src/modules/projects/projects.service.ts @@ -195,10 +195,9 @@ export class ProjectsService { ): Promise< Either< GetProjectErrors | typeof forbiddenError | typeof projectNotFound, - { from: string; to: string, query: {} } + { from: string; to: string; query: {} } > > { - if (!(await this.projectAclService.canViewProject(userId, projectId))) { return left(forbiddenError); } @@ -218,15 +217,17 @@ export class ProjectsService { return right({ from: `/projects/${projectId}/grid/tiles`, to: `/projects/planning-area/${projectId}/grid/preview/tiles`, - query: {} + query: {}, }); } else { return right({ from: `/projects/${projectId}/grid/tiles/${z}/${x}/${y}.mvt`, - to: `/planning-units/preview/regular/${project.planningUnitGridShape}/${project.planningUnitAreakm2}/tiles/${z}/${x}/${y}.mvt?bbox=${JSON.stringify(project.bbox)}`, + to: `/planning-units/preview/regular/${project.planningUnitGridShape}/${ + project.planningUnitAreakm2 + }/tiles/${z}/${x}/${y}.mvt?bbox=${JSON.stringify(project.bbox)}`, query: { bbox: JSON.stringify(project.bbox), - } + }, }); } } @@ -312,6 +313,7 @@ export class ProjectsService { async requestExport( projectId: string, userId: string, + scenarioIds: string[], ): Promise> { await this.blockGuard.ensureThatProjectIsNotBlocked(projectId); @@ -323,7 +325,7 @@ export class ProjectsService { if (!canExportProject) return left(forbiddenError); const exportId = await this.commandBus.execute( - new ExportProject(new ResourceId(projectId)), + new ExportProject(new ResourceId(projectId), scenarioIds), ); return right(exportId.value); } diff --git a/api/apps/api/test/cloning/export-piece-failed.saga.e2e-spec.ts b/api/apps/api/test/cloning/export-piece-failed.saga.e2e-spec.ts index df2fe27f7e..d7a8c6b881 100644 --- a/api/apps/api/test/cloning/export-piece-failed.saga.e2e-spec.ts +++ b/api/apps/api/test/cloning/export-piece-failed.saga.e2e-spec.ts @@ -88,6 +88,7 @@ const getFixtures = async () => { await request(app.getHttpServer()) .post(`/api/v1/projects/${projectId}/export/`) .set('Authorization', `Bearer ${token}`) + .send({ scenarioIds: [] }) .expect(201) .then((response) => { exportId = new ExportId(response.body.id); diff --git a/api/apps/api/test/project/get-export-file.e2e-spec.ts b/api/apps/api/test/project/get-export-file.e2e-spec.ts index 7491f9da6a..01ab968231 100644 --- a/api/apps/api/test/project/get-export-file.e2e-spec.ts +++ b/api/apps/api/test/project/get-export-file.e2e-spec.ts @@ -175,6 +175,7 @@ export const getFixtures = async () => { const response = await request(app.getHttpServer()) .post(`/api/v1/projects/${projectId}/export`) .set('Authorization', `Bearer ${ownerToken}`) + .send({ scenarioIds: [] }) .expect(201); exportId = new ExportId(response.body.id); diff --git a/api/apps/api/test/project/get-latest-export.e2e-spec.ts b/api/apps/api/test/project/get-latest-export.e2e-spec.ts index 033a0abe28..8de082f23a 100644 --- a/api/apps/api/test/project/get-latest-export.e2e-spec.ts +++ b/api/apps/api/test/project/get-latest-export.e2e-spec.ts @@ -180,6 +180,7 @@ export const getFixtures = async () => { const response = await request(app.getHttpServer()) .post(`/api/v1/projects/${projectId}/export`) .set('Authorization', `Bearer ${ownerToken}`) + .send({ scenarioIds: [] }) .expect(201); exportId = new ExportId(response.body.id); @@ -204,19 +205,23 @@ export const getFixtures = async () => { WhenUnrelatedUserRequestLatestExportId: () => request(app.getHttpServer()) .get(`/api/v1/projects/${projectId}/export`) - .set('Authorization', `Bearer ${unrelatedUserToken}`), + .set('Authorization', `Bearer ${unrelatedUserToken}`) + .send({ scenarioIds: [] }), WhenOwnerUserRequestLatestExportId: () => request(app.getHttpServer()) .get(`/api/v1/projects/${projectId}/export`) - .set('Authorization', `Bearer ${ownerToken}`), + .set('Authorization', `Bearer ${ownerToken}`) + .send({ scenarioIds: [] }), WhenContributorUserRequestLatestExportId: () => request(app.getHttpServer()) .get(`/api/v1/projects/${projectId}/export`) - .set('Authorization', `Bearer ${contributorToken}`), + .set('Authorization', `Bearer ${contributorToken}`) + .send({ scenarioIds: [] }), WhenViewerUserRequestLatestExportId: () => request(app.getHttpServer()) .get(`/api/v1/projects/${projectId}/export`) - .set('Authorization', `Bearer ${viewerToken}`), + .set('Authorization', `Bearer ${viewerToken}`) + .send({ scenarioIds: [] }), WhenExportFileIsReady: async () => { await eventBusTestUtils.waitUntilEventIsPublished(ArchiveReady); }, diff --git a/api/apps/api/test/project/request-project-export.e2e-spec.ts b/api/apps/api/test/project/request-project-export.e2e-spec.ts index e6ed8c5eaf..2badb6a712 100644 --- a/api/apps/api/test/project/request-project-export.e2e-spec.ts +++ b/api/apps/api/test/project/request-project-export.e2e-spec.ts @@ -198,11 +198,13 @@ export const getFixtures = async () => { WhenUnrelatedUserRequestAnExport: () => request(app.getHttpServer()) .post(`/api/v1/projects/${projectId}/export`) - .set('Authorization', `Bearer ${unrelatedUserToken}`), + .set('Authorization', `Bearer ${unrelatedUserToken}`) + .send({ scenarioIds: [] }), WhenOwnerUserRequestAnExport: () => request(app.getHttpServer()) .post(`/api/v1/projects/${projectId}/export`) - .set('Authorization', `Bearer ${ownerToken}`), + .set('Authorization', `Bearer ${ownerToken}`) + .send({ scenarioIds: [] }), WhenContributorUserRequestAnExport: async () => { await userProjectsRepo.save({ projectId, @@ -212,7 +214,8 @@ export const getFixtures = async () => { return request(app.getHttpServer()) .post(`/api/v1/projects/${projectId}/export`) - .set('Authorization', `Bearer ${contributorToken}`); + .set('Authorization', `Bearer ${contributorToken}`) + .send({ scenarioIds: [] }); }, WhenViewerUserRequestAnExport: async () => { await userProjectsRepo.save({ @@ -223,7 +226,8 @@ export const getFixtures = async () => { return request(app.getHttpServer()) .post(`/api/v1/projects/${projectId}/export`) - .set('Authorization', `Bearer ${viewerToken}`); + .set('Authorization', `Bearer ${viewerToken}`) + .send({ scenarioIds: [] }); }, ThenExportIsLaunched: (response: request.Response) => { expect(response.status).toBe(201);