diff --git a/api/apps/api/src/modules/clone/export/adapters/export-adapters.module.ts b/api/apps/api/src/modules/clone/export/adapters/export-adapters.module.ts index 35d2881714..52b5342a02 100644 --- a/api/apps/api/src/modules/clone/export/adapters/export-adapters.module.ts +++ b/api/apps/api/src/modules/clone/export/adapters/export-adapters.module.ts @@ -1,24 +1,20 @@ -import { DiscoveryModule } from '@golevelup/nestjs-discovery'; import { FileRepositoryModule } from '@marxan/files-repository'; import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { Scenario } from '../../../scenarios/scenario.api.entity'; import { ArchiveCreator } from '../application/archive-creator.port'; import { ExportRepository } from '../application/export-repository.port'; -import { ResourcePieces } from '../application/resource-pieces.port'; +import { ExportResourcePieces } from '../application/export-resource-pieces.port'; import { ExportComponentLocationEntity } from './entities/export-component-locations.api.entity'; import { ExportComponentEntity } from './entities/export-components.api.entity'; import { ExportEntity } from './entities/exports.api.entity'; +import { ExportResourcePiecesAdapter } from './export-resource-pieces.adapter'; import { NodeArchiveCreator } from './node-archive-creator'; -import { ResourcePiecesAdapter } from './resource-pieces.adapter'; -import { ProjectResourcePiecesAdapter } from './resource-pieces/project-resource-pieces.adapter'; -import { ScenarioResourcePiecesAdapter } from './resource-pieces/scenario-resource-pieces.adapter'; import { TypeormExportRepository } from './typeorm-export.repository'; @Module({ imports: [ FileRepositoryModule, - DiscoveryModule, TypeOrmModule.forFeature([ Scenario, ExportEntity, @@ -32,16 +28,14 @@ import { TypeormExportRepository } from './typeorm-export.repository'; useClass: TypeormExportRepository, }, { - provide: ResourcePieces, - useClass: ResourcePiecesAdapter, + provide: ExportResourcePieces, + useClass: ExportResourcePiecesAdapter, }, { provide: ArchiveCreator, useClass: NodeArchiveCreator, }, - ProjectResourcePiecesAdapter, - ScenarioResourcePiecesAdapter, ], - exports: [ExportRepository, ResourcePieces, ArchiveCreator], + exports: [ExportRepository, ExportResourcePieces, ArchiveCreator], }) export class ExportAdaptersModule {} 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 new file mode 100644 index 0000000000..ec68f831b2 --- /dev/null +++ b/api/apps/api/src/modules/clone/export/adapters/export-resource-pieces.adapter.ts @@ -0,0 +1,66 @@ +import { ClonePiece, ResourceId, ResourceKind } from '@marxan/cloning/domain'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Scenario } from '../../../scenarios/scenario.api.entity'; +import { ExportResourcePieces } from '../application/export-resource-pieces.port'; +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(Scenario) + private readonly scenarioRepository: Repository, + ) {} + + resolveFor(id: ResourceId, kind: ResourceKind): Promise { + return this.resolverMapping[kind](id, kind); + } + + private async resolveForProject( + id: ResourceId, + kind: ResourceKind, + ): Promise { + const scenarios = await this.scenarioRepository.find({ + where: { projectId: id.value }, + }); + + const scenarioPieces = await Promise.all( + scenarios.map((scenario) => + this.resolveForScenario(new ResourceId(scenario.id), kind), + ), + ); + + return [ + ExportComponent.newOne(id, ClonePiece.ProjectMetadata), + ExportComponent.newOne(id, ClonePiece.ExportConfig), + ExportComponent.newOne(id, ClonePiece.PlanningAreaCustom), + ExportComponent.newOne(id, ClonePiece.PlanningAreaGAdm), + ExportComponent.newOne(id, ClonePiece.PlanningAreaGridCustom), + ...scenarioPieces.flat(), + ]; + } + + private async resolveForScenario( + id: ResourceId, + kind: ResourceKind, + ): Promise { + const pieces: ExportComponent[] = [ + ExportComponent.newOne(id, ClonePiece.ScenarioMetadata), + ]; + + if (kind === ResourceKind.Scenario) { + pieces.push(ExportComponent.newOne(id, ClonePiece.ExportConfig)); + } + + return pieces; + } +} diff --git a/api/apps/api/src/modules/clone/export/adapters/resource-pieces.adapter.ts b/api/apps/api/src/modules/clone/export/adapters/resource-pieces.adapter.ts deleted file mode 100644 index a8153999f1..0000000000 --- a/api/apps/api/src/modules/clone/export/adapters/resource-pieces.adapter.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { DiscoveryService } from '@golevelup/nestjs-discovery'; -import { ResourceId, ResourceKind } from '@marxan/cloning/domain'; -import { Injectable, SetMetadata } from '@nestjs/common'; -import { ResourcePieces } from '../application/resource-pieces.port'; -import { ExportComponent } from '../domain'; - -export const ResourcePiecesProviderMetadata = Symbol( - `Resource pieces provider`, -); - -export const ResourcePiecesProvider = (kind: ResourceKind) => - SetMetadata(ResourcePiecesProviderMetadata, kind); - -@Injectable() -export class ResourcePiecesAdapter implements ResourcePieces { - constructor(private readonly discovery: DiscoveryService) {} - - private async getAdapterFor(kind: ResourceKind): Promise { - const resourcePiecesProviders = await this.discovery.providersWithMetaAtKey( - ResourcePiecesProviderMetadata, - ); - - const resourcePiecesProvider = resourcePiecesProviders.find( - (provider) => provider.meta === kind, - ); - - if (!resourcePiecesProvider) { - throw new Error( - `ResourcePieces adapter not found for ${kind} resource kind. This is probably caused by a missing provider in export-adapters module`, - ); - } - - return resourcePiecesProvider.discoveredClass.instance as ResourcePieces; - } - - async resolveFor( - id: ResourceId, - kind: ResourceKind, - ): Promise { - const adapter = await this.getAdapterFor(kind); - - return adapter.resolveFor(id, kind); - } -} diff --git a/api/apps/api/src/modules/clone/export/adapters/resource-pieces/project-resource-pieces.adapter.ts b/api/apps/api/src/modules/clone/export/adapters/resource-pieces/project-resource-pieces.adapter.ts deleted file mode 100644 index 2042a73f55..0000000000 --- a/api/apps/api/src/modules/clone/export/adapters/resource-pieces/project-resource-pieces.adapter.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { ClonePiece, ResourceId, ResourceKind } from '@marxan/cloning/domain'; -import { Injectable } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; -import { Scenario } from '../../../../scenarios/scenario.api.entity'; -import { ResourcePieces } from '../../application/resource-pieces.port'; -import { ExportComponent } from '../../domain'; -import { ResourcePiecesProvider } from '../resource-pieces.adapter'; -import { ScenarioResourcePiecesAdapter } from './scenario-resource-pieces.adapter'; - -@Injectable() -@ResourcePiecesProvider(ResourceKind.Project) -export class ProjectResourcePiecesAdapter implements ResourcePieces { - constructor( - @InjectRepository(Scenario) - private readonly scenarioRepository: Repository, - private readonly scenarioResourcePieces: ScenarioResourcePiecesAdapter, - ) {} - - async resolveFor( - id: ResourceId, - kind: ResourceKind, - ): Promise { - const scenarios = await this.scenarioRepository.find({ - where: { projectId: id.value }, - }); - - const scenarioPieces = await Promise.all( - scenarios.map((scenario) => - this.scenarioResourcePieces.resolveFor( - new ResourceId(scenario.id), - kind, - ), - ), - ); - - return [ - ExportComponent.newOne(id, ClonePiece.ProjectMetadata), - ExportComponent.newOne(id, ClonePiece.ExportConfig), - ExportComponent.newOne(id, ClonePiece.PlanningAreaCustom), - ExportComponent.newOne(id, ClonePiece.PlanningAreaGAdm), - ExportComponent.newOne(id, ClonePiece.PlanningAreaGridCustom), - ...scenarioPieces.flat(), - ]; - } -} diff --git a/api/apps/api/src/modules/clone/export/adapters/resource-pieces/scenario-resource-pieces.adapter.ts b/api/apps/api/src/modules/clone/export/adapters/resource-pieces/scenario-resource-pieces.adapter.ts deleted file mode 100644 index 427d1d9e63..0000000000 --- a/api/apps/api/src/modules/clone/export/adapters/resource-pieces/scenario-resource-pieces.adapter.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { ClonePiece, ResourceId, ResourceKind } from '@marxan/cloning/domain'; -import { Injectable } from '@nestjs/common'; -import { ResourcePieces } from '../../application/resource-pieces.port'; -import { ExportComponent } from '../../domain'; -import { ResourcePiecesProvider } from '../resource-pieces.adapter'; - -@Injectable() -@ResourcePiecesProvider(ResourceKind.Scenario) -export class ScenarioResourcePiecesAdapter implements ResourcePieces { - async resolveFor( - id: ResourceId, - kind: ResourceKind, - ): Promise { - const pieces: ExportComponent[] = [ - ExportComponent.newOne(id, ClonePiece.ScenarioMetadata), - ]; - - if (kind === ResourceKind.Scenario) { - pieces.push(ExportComponent.newOne(id, ClonePiece.ExportConfig)); - } - - return pieces; - } -} 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 cf419fa333..1fb3eef3a7 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 @@ -14,7 +14,7 @@ import { } from '../domain'; import { ExportProjectHandler } from './export-project.handler'; -import { ResourcePieces } from './resource-pieces.port'; +import { ExportResourcePieces } from './export-resource-pieces.port'; import { ExportRepository } from './export-repository.port'; import { ExportProject } from './export-project.command'; import { InMemoryExportRepo } from '../adapters/in-memory-export.repository'; @@ -38,7 +38,7 @@ const getFixtures = async () => { imports: [CqrsModule], providers: [ { - provide: ResourcePieces, + provide: ExportResourcePieces, useClass: FakePiecesProvider, }, { @@ -54,7 +54,7 @@ const getFixtures = async () => { const sut = sandbox.get(ExportProjectHandler); const repo: InMemoryExportRepo = sandbox.get(ExportRepository); - const piecesResolver: FakePiecesProvider = sandbox.get(ResourcePieces); + const piecesResolver: FakePiecesProvider = sandbox.get(ExportResourcePieces); sandbox.get(EventBus).subscribe((event) => { events.push(event); }); @@ -136,8 +136,10 @@ const getFixtures = async () => { }; @Injectable() -class FakePiecesProvider implements ResourcePieces { - resolveMock: jest.MockedFunction = jest.fn(); +class FakePiecesProvider implements ExportResourcePieces { + resolveMock: jest.MockedFunction< + ExportResourcePieces['resolveFor'] + > = jest.fn(); async resolveFor( id: ResourceId, 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 5b613fa831..01eccf288d 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 @@ -8,14 +8,14 @@ import { ResourceKind } from '@marxan/cloning/domain'; import { Export, ExportId } from '../domain'; import { ExportProject } from './export-project.command'; -import { ResourcePieces } from './resource-pieces.port'; +import { ExportResourcePieces } from './export-resource-pieces.port'; import { ExportRepository } from './export-repository.port'; @CommandHandler(ExportProject) export class ExportProjectHandler implements IInferredCommandHandler { constructor( - private readonly resourcePieces: ResourcePieces, + private readonly resourcePieces: ExportResourcePieces, private readonly exportRepository: ExportRepository, private readonly eventPublisher: EventPublisher, ) {} diff --git a/api/apps/api/src/modules/clone/export/application/resource-pieces.port.ts b/api/apps/api/src/modules/clone/export/application/export-resource-pieces.port.ts similarity index 82% rename from api/apps/api/src/modules/clone/export/application/resource-pieces.port.ts rename to api/apps/api/src/modules/clone/export/application/export-resource-pieces.port.ts index 427334e0ce..09499f2391 100644 --- a/api/apps/api/src/modules/clone/export/application/resource-pieces.port.ts +++ b/api/apps/api/src/modules/clone/export/application/export-resource-pieces.port.ts @@ -1,7 +1,7 @@ import { ResourceId, ResourceKind } from '@marxan/cloning/domain'; import { ExportComponent } from '../domain'; -export abstract class ResourcePieces { +export abstract class ExportResourcePieces { abstract resolveFor( id: ResourceId, kind: ResourceKind, 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 dc6c27fcab..06172a0ad2 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 @@ -8,14 +8,14 @@ import { ResourceKind } from '@marxan/cloning/domain'; import { Export, ExportId } from '../domain'; import { ExportScenario } from './export-scenario.command'; -import { ResourcePieces } from './resource-pieces.port'; +import { ExportResourcePieces } from './export-resource-pieces.port'; import { ExportRepository } from './export-repository.port'; @CommandHandler(ExportScenario) export class ExportScenarioHandler implements IInferredCommandHandler { constructor( - private readonly resourcePieces: ResourcePieces, + private readonly resourcePieces: ExportResourcePieces, private readonly exportRepository: ExportRepository, private readonly eventPublisher: EventPublisher, ) {} diff --git a/api/apps/api/src/modules/clone/import/adapters/archive-reader.adapter.ts b/api/apps/api/src/modules/clone/import/adapters/archive-reader.adapter.ts new file mode 100644 index 0000000000..778cede9d0 --- /dev/null +++ b/api/apps/api/src/modules/clone/import/adapters/archive-reader.adapter.ts @@ -0,0 +1,56 @@ +import { + ArchiveLocation, + ClonePiece, + ResourceId, + ResourceKind, +} from '@marxan/cloning/domain'; +import { ClonePieceRelativePaths } from '@marxan/cloning/infrastructure/clone-piece-data'; +import { FileRepository } from '@marxan/files-repository'; +import { fileNotFound } from '@marxan/files-repository/file.repository'; +import { extractFile } from '@marxan/utils'; +import { Injectable } from '@nestjs/common'; +import { Either, isLeft, left, right } from 'fp-ts/lib/Either'; +import { + archiveCorrupted, + ArchiveReader, + Failure, + invalidFiles, +} from '../application/archive-reader.port'; +import { ImportResourcePieces } from '../application/import-resource-pieces.port'; +import { Import } from '../domain'; + +@Injectable() +export class ArchiveReaderAdapter implements ArchiveReader { + constructor( + private readonly fileRepository: FileRepository, + private readonly importResourcePieces: ImportResourcePieces, + ) {} + + async get(location: ArchiveLocation): Promise> { + const readableOrError = await this.fileRepository.get(location.value); + if (isLeft(readableOrError)) return left(fileNotFound); + + const exportConfigOrError = await extractFile( + readableOrError.right, + new RegExp(ClonePieceRelativePaths[ClonePiece.ExportConfig].config), + ); + if (isLeft(exportConfigOrError)) return left(archiveCorrupted); + const exportConfig = JSON.parse(exportConfigOrError.right); + + const resourceId = new ResourceId(exportConfig.resourceId); + const resourceKind = exportConfig.resourceKind; + + const validResourceKind = Object.values(ResourceKind).includes( + resourceKind, + ); + if (!validResourceKind) return left(invalidFiles); + + const pieces = await this.importResourcePieces.resolveFor( + resourceId, + resourceKind, + location, + ); + + return right(Import.newOne(resourceId, resourceKind, location, pieces)); + } +} diff --git a/api/apps/api/src/modules/clone/import/adapters/import-adapters.module.ts b/api/apps/api/src/modules/clone/import/adapters/import-adapters.module.ts index be4fe06d2e..eb8207765c 100644 --- a/api/apps/api/src/modules/clone/import/adapters/import-adapters.module.ts +++ b/api/apps/api/src/modules/clone/import/adapters/import-adapters.module.ts @@ -1,10 +1,15 @@ +import { FileRepositoryModule } from '@marxan/files-repository'; import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { ArchiveReader } from '../application/archive-reader.port'; +import { ImportResourcePieces } from '../application/import-resource-pieces.port'; import { ImportRepository } from '../application/import.repository.port'; -import { TypeormImportRepository } from './typeorm-import.repository.adapter'; +import { ArchiveReaderAdapter } from './archive-reader.adapter'; import { ImportComponentLocationEntity } from './entities/import-component-locations.api.entity'; import { ImportComponentEntity } from './entities/import-components.api.entity'; import { ImportEntity } from './entities/imports.api.entity'; +import { ImportResourcePiecesAdapter } from './import-resource-pieces.adapter'; +import { TypeormImportRepository } from './typeorm-import.repository.adapter'; @Module({ imports: [ @@ -13,13 +18,22 @@ import { ImportEntity } from './entities/imports.api.entity'; ImportComponentEntity, ImportComponentLocationEntity, ]), + FileRepositoryModule, ], providers: [ { provide: ImportRepository, useClass: TypeormImportRepository, }, + { + provide: ArchiveReader, + useClass: ArchiveReaderAdapter, + }, + { + provide: ImportResourcePieces, + useClass: ImportResourcePiecesAdapter, + }, ], - exports: [], + exports: [ArchiveReader, ImportRepository], }) export class ImportAdaptersModule {} diff --git a/api/apps/api/src/modules/clone/import/adapters/import-resource-pieces.adapter.ts b/api/apps/api/src/modules/clone/import/adapters/import-resource-pieces.adapter.ts new file mode 100644 index 0000000000..6647333997 --- /dev/null +++ b/api/apps/api/src/modules/clone/import/adapters/import-resource-pieces.adapter.ts @@ -0,0 +1,57 @@ +import { + ArchiveLocation, + ClonePiece, + ComponentLocation, + ResourceId, + ResourceKind, +} from '@marxan/cloning/domain'; +import { Injectable } from '@nestjs/common'; +import { ClonePieceRelativePaths } from '@marxan/cloning/infrastructure/clone-piece-data'; +import { ImportResourcePieces } from '../application/import-resource-pieces.port'; +import { ImportComponent } from '../domain'; + +@Injectable() +export class ImportResourcePiecesAdapter implements ImportResourcePieces { + private resolverMapping: Record< + ResourceKind, + ( + id: ResourceId, + kind: ResourceKind, + location: ArchiveLocation, + ) => Promise + > = { + project: this.resolveForProject.bind(this), + scenario: this.resolveForScenario.bind(this), + }; + + resolveFor( + id: ResourceId, + kind: ResourceKind, + location: ArchiveLocation, + ): Promise { + return this.resolverMapping[kind](id, kind, location); + } + + private async resolveForProject( + id: ResourceId, + kind: ResourceKind, + location: ArchiveLocation, + ): Promise { + return [ + ImportComponent.newOne(id, ClonePiece.ProjectMetadata, 0, [ + new ComponentLocation( + location.value, + ClonePieceRelativePaths[ClonePiece.ProjectMetadata].projectMetadata, + ), + ]), + ]; + } + + private async resolveForScenario( + id: ResourceId, + kind: ResourceKind, + location: ArchiveLocation, + ): Promise { + return []; + } +} diff --git a/api/apps/api/src/modules/clone/import/application/archive-reader.port.ts b/api/apps/api/src/modules/clone/import/application/archive-reader.port.ts index f21bb57356..b8254727a4 100644 --- a/api/apps/api/src/modules/clone/import/application/archive-reader.port.ts +++ b/api/apps/api/src/modules/clone/import/application/archive-reader.port.ts @@ -1,11 +1,15 @@ import { ArchiveLocation } from '@marxan/cloning/domain'; import { Either } from 'fp-ts/Either'; import { Import } from '../domain'; +import { fileNotFound } from '@marxan/files-repository/file.repository'; export const archiveCorrupted = Symbol(`archive couldn't be extracted`); export const invalidFiles = Symbol(`archive files structure is not recognized`); -export type Failure = typeof archiveCorrupted | typeof invalidFiles; +export type Failure = + | typeof archiveCorrupted + | typeof invalidFiles + | typeof fileNotFound; export type Success = Import; export abstract class ArchiveReader { diff --git a/api/apps/api/src/modules/clone/import/application/import-application.module.ts b/api/apps/api/src/modules/clone/import/application/import-application.module.ts index 3c286d0bd9..6945b29790 100644 --- a/api/apps/api/src/modules/clone/import/application/import-application.module.ts +++ b/api/apps/api/src/modules/clone/import/application/import-application.module.ts @@ -1,8 +1,12 @@ import { Module } from '@nestjs/common'; +import { CqrsModule } from '@nestjs/cqrs'; import { ImportAdaptersModule } from '../adapters/import-adapters.module'; +import { ImportArchive } from './import-archive'; @Module({ - imports: [ImportAdaptersModule], - providers: [], + imports: [CqrsModule, ImportAdaptersModule], + providers: [ImportArchive], + controllers: [], + exports: [], }) export class ImportApplicationModule {} diff --git a/api/apps/api/src/modules/clone/import/application/import-archive.spec.ts b/api/apps/api/src/modules/clone/import/application/import-archive.spec.ts index 20bd130f3a..b520c0f160 100644 --- a/api/apps/api/src/modules/clone/import/application/import-archive.spec.ts +++ b/api/apps/api/src/modules/clone/import/application/import-archive.spec.ts @@ -71,6 +71,9 @@ const getFixtures = async () => { }).compile(); await sandbox.init(); + const resourceId = ResourceId.create(); + const projectId = ResourceId.create(); + const events: IEvent[] = []; sandbox.get(EventBus).subscribe((event) => events.push(event)); @@ -88,13 +91,13 @@ const getFixtures = async () => { Import.fromSnapshot({ id: ImportId.create().value, archiveLocation: 'whatever', - resourceId: `resource-id`, + resourceId: resourceId.value, resourceKind: ResourceKind.Project, importPieces: [ { finished: false, order: 0, - resourceId: `project-id`, + resourceId: projectId.value, id: `import component unique id`, piece: ClonePiece.ProjectMetadata, uris: [ @@ -107,7 +110,7 @@ const getFixtures = async () => { { finished: false, order: 1, - resourceId: `project-id`, + resourceId: projectId.value, id: `some other piece`, piece: ClonePiece.PlanningAreaGAdm, uris: [ @@ -128,13 +131,13 @@ const getFixtures = async () => { Import.fromSnapshot({ id: ImportId.create().value, archiveLocation: 'whatever', - resourceId: `resource-id`, + resourceId: resourceId.value, resourceKind: ResourceKind.Project, importPieces: [ { finished: false, order: 2, - resourceId: `project-id`, + resourceId: projectId.value, id: `import component unique id`, piece: ClonePiece.ProjectMetadata, uris: [ @@ -147,7 +150,7 @@ const getFixtures = async () => { { finished: false, order: 2, - resourceId: `project-id`, + resourceId: projectId.value, id: `some other piece`, piece: ClonePiece.PlanningAreaGAdm, uris: [ @@ -183,7 +186,7 @@ const getFixtures = async () => { ).toEqual([ { id: expect.any(ImportId), - resourceId: new ResourceId(`resource-id`), + resourceId, resourceKind: `project`, }, ]); @@ -194,7 +197,7 @@ const getFixtures = async () => { ).toMatchObject([ { id: new ComponentId(`import component unique id`), - resourceId: new ResourceId(`project-id`), + resourceId: projectId, piece: `project-metadata`, uris: [ { @@ -211,7 +214,7 @@ const getFixtures = async () => { ).toMatchObject([ { id: new ComponentId(`import component unique id`), - resourceId: new ResourceId(`project-id`), + resourceId: projectId, piece: `project-metadata`, uris: [ { @@ -222,7 +225,7 @@ const getFixtures = async () => { }, { id: new ComponentId(`some other piece`), - resourceId: new ResourceId(`project-id`), + resourceId: projectId, piece: `planning-area-gadm`, uris: [ { diff --git a/api/apps/api/src/modules/clone/import/application/import-resource-pieces.port.ts b/api/apps/api/src/modules/clone/import/application/import-resource-pieces.port.ts new file mode 100644 index 0000000000..9ca1f8d49e --- /dev/null +++ b/api/apps/api/src/modules/clone/import/application/import-resource-pieces.port.ts @@ -0,0 +1,14 @@ +import { + ArchiveLocation, + ResourceId, + ResourceKind, +} from '@marxan/cloning/domain'; +import { ImportComponent } from '../domain'; + +export abstract class ImportResourcePieces { + abstract resolveFor( + id: ResourceId, + kind: ResourceKind, + archiveLocation: ArchiveLocation, + ): Promise; +} diff --git a/api/apps/geoprocessing/src/export/pieces-exporters/export-config.piece-exporter.ts b/api/apps/geoprocessing/src/export/pieces-exporters/export-config.piece-exporter.ts index 9ada61e286..da95fccd45 100644 --- a/api/apps/geoprocessing/src/export/pieces-exporters/export-config.piece-exporter.ts +++ b/api/apps/geoprocessing/src/export/pieces-exporters/export-config.piece-exporter.ts @@ -1,25 +1,25 @@ +import { geoprocessingConnections } from '@marxan-geoprocessing/ormconfig'; +import { ClonePiece, ExportJobInput, ExportJobOutput } from '@marxan/cloning'; +import { ResourceKind } from '@marxan/cloning/domain'; +import { FileRepository } from '@marxan/files-repository'; import { Injectable } from '@nestjs/common'; import { InjectEntityManager } from '@nestjs/typeorm'; -import { EntityManager } from 'typeorm'; -import { Readable } from 'stream'; import { isLeft } from 'fp-ts/Either'; - -import { ClonePiece, ExportJobInput, ExportJobOutput } from '@marxan/cloning'; -import { FileRepository } from '@marxan/files-repository'; - -import { geoprocessingConnections } from '@marxan-geoprocessing/ormconfig'; - +import { Readable } from 'stream'; +import { EntityManager } from 'typeorm'; import { - PieceExportProvider, ExportPieceProcessor, + PieceExportProvider, } from '../pieces/export-piece-processor'; -import { ResourceKind } from '@marxan/cloning/domain'; +import { + ProjectExportConfigContent, + ScenarioExportConfigContent, +} from '@marxan/cloning/infrastructure/clone-piece-data/export-config'; +import { ClonePieceRelativePaths } from '@marxan/cloning/infrastructure/clone-piece-data'; @Injectable() @PieceExportProvider() export class ExportConfigPieceExporter implements ExportPieceProcessor { - private readonly relativePath = 'config.json'; - constructor( private readonly fileRepository: FileRepository, @InjectEntityManager(geoprocessingConnections.apiDB) @@ -29,22 +29,38 @@ export class ExportConfigPieceExporter implements ExportPieceProcessor { private async projectExportConfig( input: ExportJobInput, ): Promise { - const scenarios: { name: string }[] = await this.entityManager.query( - ` - SELECT name FROM scenarios where project_id = $1 - `, + const [project]: { + name: string; + description: string; + }[] = await this.entityManager.query( + `SELECT name, description FROM projects where id = $1`, + [input.resourceId], + ); + + if (!project) { + throw new Error(`Project with ID ${input.resourceId} not found`); + } + + const scenarios: { + name: string; + id: string; + }[] = await this.entityManager.query( + `SELECT id, name FROM scenarios where project_id = $1`, [input.resourceId], ); - const metadata = JSON.stringify({ + const fileContent: ProjectExportConfigContent = { version: `0.1.0`, - scenarios: scenarios.map(({ name }) => name), - kind: input.resourceKind, + scenarios, + name: project.name, + description: project.description, + resourceKind: input.resourceKind, + resourceId: input.resourceId, pieces: input.allPieces, - }); + }; const outputFile = await this.fileRepository.save( - Readable.from(metadata), + Readable.from(JSON.stringify(fileContent)), `json`, ); @@ -59,7 +75,7 @@ export class ExportConfigPieceExporter implements ExportPieceProcessor { uris: [ { uri: outputFile.right, - relativePath: this.relativePath, + relativePath: ClonePieceRelativePaths[ClonePiece.ExportConfig].config, }, ], }; @@ -83,17 +99,18 @@ export class ExportConfigPieceExporter implements ExportPieceProcessor { throw new Error(`Scenario with ID ${input.resourceId} not found`); } - const metadata = JSON.stringify({ + const fileContent: ScenarioExportConfigContent = { version: `0.1.0`, name: scenario.name, description: scenario.description, projectId: scenario.project_id, - kind: input.resourceKind, + resourceKind: input.resourceKind, + resourceId: input.resourceId, pieces: input.allPieces, - }); + }; const outputFile = await this.fileRepository.save( - Readable.from(metadata), + Readable.from(JSON.stringify(fileContent)), `json`, ); @@ -108,7 +125,7 @@ export class ExportConfigPieceExporter implements ExportPieceProcessor { uris: [ { uri: outputFile.right, - relativePath: this.relativePath, + relativePath: ClonePieceRelativePaths[ClonePiece.ExportConfig].config, }, ], }; diff --git a/api/apps/geoprocessing/src/export/pieces-exporters/planning-area-custom-grid.piece-exporter.ts b/api/apps/geoprocessing/src/export/pieces-exporters/planning-area-custom-grid.piece-exporter.ts index 9d46b089b0..185b725b04 100644 --- a/api/apps/geoprocessing/src/export/pieces-exporters/planning-area-custom-grid.piece-exporter.ts +++ b/api/apps/geoprocessing/src/export/pieces-exporters/planning-area-custom-grid.piece-exporter.ts @@ -1,20 +1,18 @@ +import { geoprocessingConnections } from '@marxan-geoprocessing/ormconfig'; +import { ClonePiece, ExportJobInput, ExportJobOutput } from '@marxan/cloning'; +import { ResourceKind } from '@marxan/cloning/domain'; +import { ClonePieceRelativePaths } from '@marxan/cloning/infrastructure/clone-piece-data'; +import { FileRepository } from '@marxan/files-repository'; import { Injectable } from '@nestjs/common'; import { InjectEntityManager } from '@nestjs/typeorm'; -import { EntityManager } from 'typeorm'; -import { Readable } from 'stream'; import { isLeft } from 'fp-ts/Either'; - -import { ClonePiece, ExportJobInput, ExportJobOutput } from '@marxan/cloning'; -import { FileRepository } from '@marxan/files-repository'; - -import { geoprocessingConnections } from '@marxan-geoprocessing/ormconfig'; - +import { GeoJSON } from 'geojson'; +import { Readable } from 'stream'; +import { EntityManager } from 'typeorm'; import { - PieceExportProvider, ExportPieceProcessor, + PieceExportProvider, } from '../pieces/export-piece-processor'; -import { ResourceKind } from '@marxan/cloning/domain'; -import { GeoJSON } from 'geojson'; @Injectable() @PieceExportProvider() @@ -35,13 +33,14 @@ export class PlanningAreaCustomGridPieceExporter throw new Error(`Exporting scenario is not yet supported.`); } - const customProjectAreaFile = 'project-grid/custom-grid.geojson'; + const relativePaths = + ClonePieceRelativePaths[ClonePiece.PlanningAreaGridCustom]; const metadata = JSON.stringify({ shape: 'square', areaKm2: 4000, bbox: [], - file: customProjectAreaFile, + file: relativePaths.customGridGeoJson, }); const geoJson: GeoJSON = { @@ -76,11 +75,11 @@ export class PlanningAreaCustomGridPieceExporter uris: [ { uri: outputFile.right, - relativePath: `project-grid.json`, + relativePath: relativePaths.projectGrid, }, { uri: planningAreaGeoJson.right, - relativePath: customProjectAreaFile, + relativePath: relativePaths.customGridGeoJson, }, ], }; diff --git a/api/apps/geoprocessing/src/export/pieces-exporters/planning-area-custom.piece-exporter.ts b/api/apps/geoprocessing/src/export/pieces-exporters/planning-area-custom.piece-exporter.ts index 92683e81ec..501a85f765 100644 --- a/api/apps/geoprocessing/src/export/pieces-exporters/planning-area-custom.piece-exporter.ts +++ b/api/apps/geoprocessing/src/export/pieces-exporters/planning-area-custom.piece-exporter.ts @@ -1,20 +1,18 @@ +import { geoprocessingConnections } from '@marxan-geoprocessing/ormconfig'; +import { ClonePiece, ExportJobInput, ExportJobOutput } from '@marxan/cloning'; +import { ResourceKind } from '@marxan/cloning/domain'; +import { ClonePieceRelativePaths } from '@marxan/cloning/infrastructure/clone-piece-data'; +import { FileRepository } from '@marxan/files-repository'; import { Injectable } from '@nestjs/common'; import { InjectEntityManager } from '@nestjs/typeorm'; -import { EntityManager } from 'typeorm'; -import { Readable } from 'stream'; import { isLeft } from 'fp-ts/Either'; - -import { ClonePiece, ExportJobInput, ExportJobOutput } from '@marxan/cloning'; -import { FileRepository } from '@marxan/files-repository'; - -import { geoprocessingConnections } from '@marxan-geoprocessing/ormconfig'; - +import { GeoJSON } from 'geojson'; +import { Readable } from 'stream'; +import { EntityManager } from 'typeorm'; import { - PieceExportProvider, ExportPieceProcessor, + PieceExportProvider, } from '../pieces/export-piece-processor'; -import { ResourceKind } from '@marxan/cloning/domain'; -import { GeoJSON } from 'geojson'; @Injectable() @PieceExportProvider() @@ -34,15 +32,17 @@ export class PlanningAreaCustomPieceExporter implements ExportPieceProcessor { throw new Error(`Exporting scenario is not yet supported.`); } // TODO check files on fs - something broke the archive - const customProjectAreaFile = 'planning-area/project-pa.geojson'; await delay(); + const relativePaths = + ClonePieceRelativePaths[ClonePiece.PlanningAreaCustom]; + const metadata = JSON.stringify({ version: `0.1.0`, planningAreaGeometry: { uuid: `uuid`, - file: customProjectAreaFile, + file: relativePaths.customPaGeoJson, }, }); @@ -79,11 +79,11 @@ export class PlanningAreaCustomPieceExporter implements ExportPieceProcessor { uris: [ { uri: outputFile.right, - relativePath: `planning-area.json`, + relativePath: relativePaths.planningArea, }, { uri: planningAreaGeoJson.right, - relativePath: customProjectAreaFile, + relativePath: relativePaths.customPaGeoJson, }, ], }; diff --git a/api/apps/geoprocessing/src/export/pieces-exporters/planning-area-gadm.piece-exporter.spec.ts b/api/apps/geoprocessing/src/export/pieces-exporters/planning-area-gadm.piece-exporter.spec.ts index 509033a9e3..a7c0e510b3 100644 --- a/api/apps/geoprocessing/src/export/pieces-exporters/planning-area-gadm.piece-exporter.spec.ts +++ b/api/apps/geoprocessing/src/export/pieces-exporters/planning-area-gadm.piece-exporter.spec.ts @@ -1,5 +1,6 @@ import { ClonePiece, ExportJobInput } from '@marxan/cloning'; import { ResourceKind } from '@marxan/cloning/domain'; +import { PlanningAreaGadmContent } from '@marxan/cloning/infrastructure/clone-piece-data/planning-area-gadm'; import { FileRepository, GetFileError, @@ -15,10 +16,7 @@ import { Either, left, Right, right } from 'fp-ts/lib/Either'; import { Readable } from 'stream'; import { v4 } from 'uuid'; import { geoprocessingConnections } from '../../ormconfig'; -import { - Gadm, - PlanningAreaGadmPieceExporter, -} from './planning-area-gadm.piece-exporter'; +import { PlanningAreaGadmPieceExporter } from './planning-area-gadm.piece-exporter'; let fixtures: FixtureType; @@ -138,18 +136,16 @@ const getFixtures = async () => { }; class FakeEntityManager { - public data: Gadm[] = [ + public data: PlanningAreaGadmContent[] = [ { bbox: [], country: 'AGO', - l1: null, - l2: null, planningUnitAreakm2: 1000, puGridShape: PlanningUnitGridShape.hexagon, }, ]; - async query(): Promise { + async query(): Promise { return this.data; } } diff --git a/api/apps/geoprocessing/src/export/pieces-exporters/planning-area-gadm.piece-exporter.ts b/api/apps/geoprocessing/src/export/pieces-exporters/planning-area-gadm.piece-exporter.ts index c68873f9b0..7136bdd4dd 100644 --- a/api/apps/geoprocessing/src/export/pieces-exporters/planning-area-gadm.piece-exporter.ts +++ b/api/apps/geoprocessing/src/export/pieces-exporters/planning-area-gadm.piece-exporter.ts @@ -1,27 +1,26 @@ +import { geoprocessingConnections } from '@marxan-geoprocessing/ormconfig'; +import { ClonePiece, ExportJobInput, ExportJobOutput } from '@marxan/cloning'; +import { ResourceKind } from '@marxan/cloning/domain'; +import { ClonePieceRelativePaths } from '@marxan/cloning/infrastructure/clone-piece-data'; +import { PlanningAreaGadmContent } from '@marxan/cloning/infrastructure/clone-piece-data/planning-area-gadm'; +import { FileRepository } from '@marxan/files-repository'; +import { PlanningUnitGridShape } from '@marxan/scenarios-planning-unit'; import { Injectable } from '@nestjs/common'; import { InjectEntityManager } from '@nestjs/typeorm'; -import { EntityManager } from 'typeorm'; -import { Readable } from 'stream'; import { isLeft } from 'fp-ts/Either'; - -import { ClonePiece, ExportJobInput, ExportJobOutput } from '@marxan/cloning'; -import { FileRepository } from '@marxan/files-repository'; - -import { geoprocessingConnections } from '@marxan-geoprocessing/ormconfig'; - +import { Readable } from 'stream'; +import { EntityManager } from 'typeorm'; import { - PieceExportProvider, ExportPieceProcessor, + PieceExportProvider, } from '../pieces/export-piece-processor'; -import { ResourceKind } from '@marxan/cloning/domain'; -import { PlanningUnitGridShape } from '@marxan/scenarios-planning-unit'; -export interface Gadm { - country: string; - l1: string | null; - l2: string | null; - puGridShape: PlanningUnitGridShape; - planningUnitAreakm2: number; +interface QueryResult { + country_id: string; + admin_area_l1_id?: string; + admin_area_l2_id?: string; + planning_unit_grid_shape: PlanningUnitGridShape; + planning_unit_area_km2: number; bbox: number[]; } @@ -43,14 +42,14 @@ export class PlanningAreaGadmPieceExporter implements ExportPieceProcessor { throw new Error(`Exporting scenario is not yet supported.`); } - const result: [Gadm] = await this.entityManager.query( + const [gadm]: [QueryResult] = await this.entityManager.query( ` SELECT - country_id as country, - admin_area_l1_id as l1, - admin_area_l2_id as l2, - planning_unit_grid_shape as puGridShape, - planning_unit_area_km2 as planningUnitAreakm2, + country_id, + admin_area_l1_id, + admin_area_l2_id, + planning_unit_grid_shape, + planning_unit_area_km2, bbox FROM projects WHERE id = $1 @@ -58,20 +57,23 @@ export class PlanningAreaGadmPieceExporter implements ExportPieceProcessor { [input.resourceId], ); - if (result.length !== 1) { + if (!gadm) { throw new Error( `Gadm data not found for project with ID: ${input.resourceId}`, ); } - const [gadm] = result; - - const metadata = JSON.stringify({ - gadm, - }); + const fileContent: PlanningAreaGadmContent = { + bbox: gadm.bbox, + country: gadm.country_id, + planningUnitAreakm2: gadm.planning_unit_area_km2, + puGridShape: gadm.planning_unit_grid_shape, + l1: gadm.admin_area_l1_id, + l2: gadm.admin_area_l2_id, + }; const outputFile = await this.fileRepository.save( - Readable.from(metadata), + Readable.from(JSON.stringify(fileContent)), `json`, ); @@ -86,7 +88,8 @@ export class PlanningAreaGadmPieceExporter implements ExportPieceProcessor { uris: [ { uri: outputFile.right, - relativePath: `planning-area.json`, + relativePath: + ClonePieceRelativePaths[ClonePiece.PlanningAreaGAdm].paGadm, }, ], }; diff --git a/api/apps/geoprocessing/src/export/pieces-exporters/project-metadata.piece-exporter.ts b/api/apps/geoprocessing/src/export/pieces-exporters/project-metadata.piece-exporter.ts index b871f79c13..10510b5edd 100644 --- a/api/apps/geoprocessing/src/export/pieces-exporters/project-metadata.piece-exporter.ts +++ b/api/apps/geoprocessing/src/export/pieces-exporters/project-metadata.piece-exporter.ts @@ -1,17 +1,16 @@ +import { geoprocessingConnections } from '@marxan-geoprocessing/ormconfig'; +import { ClonePiece, ExportJobInput, ExportJobOutput } from '@marxan/cloning'; +import { ClonePieceRelativePaths } from '@marxan/cloning/infrastructure/clone-piece-data'; +import { ProjectMetadataContent } from '@marxan/cloning/infrastructure/clone-piece-data/project-metadata'; +import { FileRepository } from '@marxan/files-repository'; import { Injectable } from '@nestjs/common'; import { InjectEntityManager } from '@nestjs/typeorm'; -import { EntityManager } from 'typeorm'; -import { Readable } from 'stream'; import { isLeft } from 'fp-ts/Either'; - -import { ClonePiece, ExportJobInput, ExportJobOutput } from '@marxan/cloning'; -import { FileRepository } from '@marxan/files-repository'; - -import { geoprocessingConnections } from '@marxan-geoprocessing/ormconfig'; - +import { Readable } from 'stream'; +import { EntityManager } from 'typeorm'; import { - PieceExportProvider, ExportPieceProcessor, + PieceExportProvider, } from '../pieces/export-piece-processor'; @Injectable() @@ -28,29 +27,27 @@ export class ProjectMetadataPieceExporter implements ExportPieceProcessor { } async run(input: ExportJobInput): Promise { - const projectData: Array<{ + const [projectData]: { name: string; description: string; - }> = await this.entityManager.query( - ` - SELECT projects.name, projects.description FROM projects WHERE projects.id = $1 - `, + }[] = await this.entityManager.query( + `SELECT projects.name, projects.description FROM projects WHERE projects.id = $1`, [input.resourceId], ); - if (projectData.length !== 1) { + if (!projectData) { throw new Error( `${ProjectMetadataPieceExporter.name} - Project ${input.resourceId} does not exist.`, ); } - const metadata = JSON.stringify({ - name: projectData[0].name, - description: projectData[0].description ?? null, - }); + const fileContent: ProjectMetadataContent = { + name: projectData.name, + description: projectData.description, + }; const outputFile = await this.fileRepository.save( - Readable.from(metadata), + Readable.from(JSON.stringify(fileContent)), `json`, ); @@ -65,7 +62,8 @@ export class ProjectMetadataPieceExporter implements ExportPieceProcessor { uris: [ { uri: outputFile.right, - relativePath: `project-metadata.json`, + relativePath: + ClonePieceRelativePaths[ClonePiece.ProjectMetadata].projectMetadata, }, ], }; diff --git a/api/apps/geoprocessing/src/export/pieces-exporters/scenario-metadata.piece-exporter.ts b/api/apps/geoprocessing/src/export/pieces-exporters/scenario-metadata.piece-exporter.ts index a7b3208cec..257c8aec02 100644 --- a/api/apps/geoprocessing/src/export/pieces-exporters/scenario-metadata.piece-exporter.ts +++ b/api/apps/geoprocessing/src/export/pieces-exporters/scenario-metadata.piece-exporter.ts @@ -1,6 +1,7 @@ import { geoprocessingConnections } from '@marxan-geoprocessing/ormconfig'; import { ClonePiece, ExportJobInput, ExportJobOutput } from '@marxan/cloning'; -import { ResourceKind } from '@marxan/cloning/domain'; +import { ClonePieceRelativePaths } from '@marxan/cloning/infrastructure/clone-piece-data'; +import { ScenarioMetadataContent } from '@marxan/cloning/infrastructure/clone-piece-data/scenario-metadata'; import { FileRepository } from '@marxan/files-repository'; import { Injectable } from '@nestjs/common'; import { InjectEntityManager } from '@nestjs/typeorm'; @@ -8,8 +9,8 @@ import { isLeft } from 'fp-ts/Either'; import { Readable } from 'stream'; import { EntityManager } from 'typeorm'; import { - PieceExportProvider, ExportPieceProcessor, + PieceExportProvider, } from '../pieces/export-piece-processor'; @Injectable() @@ -26,27 +27,31 @@ export class ScenarioMetadataPieceExporter implements ExportPieceProcessor { } async run(input: ExportJobInput): Promise { - const scenarioData: Array<{ - name: string; - }> = await this.entityManager.query( + const [scenario]: [ + { + name: string; + description?: string; + }, + ] = await this.entityManager.query( ` - SELECT scenarios.name FROM scenarios WHERE scenarios.id = $1 + SELECT name, description FROM scenarios WHERE scenarios.id = $1 `, [input.resourceId], ); - if (scenarioData.length !== 1) { + if (!scenario) { throw new Error( `${ScenarioMetadataPieceExporter.name} - Scenario ${input.resourceId} does not exist.`, ); } - const metadata = JSON.stringify({ - name: scenarioData[0].name, - }); + const fileContent: ScenarioMetadataContent = { + description: scenario.description, + name: scenario.name, + }; const outputFile = await this.fileRepository.save( - Readable.from(metadata), + Readable.from(JSON.stringify(fileContent)), `json`, ); @@ -61,10 +66,12 @@ export class ScenarioMetadataPieceExporter implements ExportPieceProcessor { uris: [ { uri: outputFile.right, - relativePath: - input.resourceKind === ResourceKind.Scenario - ? `scenario-metadata.json` - : `scenarios/${input.resourceId}/scenario-metadata.json`, + relativePath: ClonePieceRelativePaths[ + ClonePiece.ScenarioMetadata + ].getScenarioMetadataRelativePath( + input.resourceKind, + input.resourceId, + ), }, ], }; diff --git a/api/libs/cloning/src/domain/resource.id.ts b/api/libs/cloning/src/domain/resource.id.ts index f987321e70..d7610e6ee2 100644 --- a/api/libs/cloning/src/domain/resource.id.ts +++ b/api/libs/cloning/src/domain/resource.id.ts @@ -1,8 +1,13 @@ -import { v4 } from 'uuid'; +import { v4, validate } from 'uuid'; export class ResourceId { private readonly _token = 'resource-id'; - constructor(public readonly value: string) {} + constructor(public readonly value: string) { + const validUuid = validate(value); + if (!validUuid) { + throw new Error('Invalid resource id'); + } + } static create(): ResourceId { return new ResourceId(v4()); diff --git a/api/libs/cloning/src/infrastructure/clone-piece-data/export-config.ts b/api/libs/cloning/src/infrastructure/clone-piece-data/export-config.ts new file mode 100644 index 0000000000..d8593f1465 --- /dev/null +++ b/api/libs/cloning/src/infrastructure/clone-piece-data/export-config.ts @@ -0,0 +1,31 @@ +import { ClonePiece } from '../../domain/clone-piece'; +import { ResourceKind } from '../../domain/resource.kind'; + +interface CommonFields { + version: string; + resourceKind: ResourceKind; + resourceId: string; + pieces: ClonePiece[]; + name: string; + description: string; +} + +export interface ProjectExportConfigContent extends CommonFields { + scenarios: { id: string; name: string }[]; +} + +export interface ScenarioExportConfigContent extends CommonFields { + projectId: string; +} + +export type ExportConfigContent = + | ProjectExportConfigContent + | ScenarioExportConfigContent; + +export interface ExportConfigRelativePathsType { + config: string; +} + +export const ExportConfigRelativePaths: ExportConfigRelativePathsType = { + config: 'config.json', +}; diff --git a/api/libs/cloning/src/infrastructure/clone-piece-data/index.ts b/api/libs/cloning/src/infrastructure/clone-piece-data/index.ts new file mode 100644 index 0000000000..573674520b --- /dev/null +++ b/api/libs/cloning/src/infrastructure/clone-piece-data/index.ts @@ -0,0 +1,47 @@ +import { ClonePiece } from '../../domain/clone-piece'; +import { + ExportConfigRelativePaths, + ExportConfigRelativePathsType, +} from './export-config'; +import { + PlanningAreaCustomRelativePaths, + PlanningAreaCustomRelativePathsType, +} from './planning-area-custom'; +import { + PlanningAreaGadmRelativePaths, + PlanningAreaGadmRelativePathsType, +} from './planning-area-gadm'; +import { + PlanningAreaCustomGridRelativePaths, + PlanningAreaCustomGridRelativePathsType, +} from './planning-area-grid-custom'; +import { + ProjectMetadataRelativePaths, + ProjectMetadataRelativePathsType, +} from './project-metadata'; +import { + ScenarioMetadataRelativePaths, + ScenarioMetadataRelativePathsType, +} from './scenario-metadata'; + +interface ClonePieceRelativePathsType { + [ClonePiece.ExportConfig]: ExportConfigRelativePathsType; + [ClonePiece.PlanningAreaGAdm]: PlanningAreaGadmRelativePathsType; + [ClonePiece.PlanningAreaCustom]: PlanningAreaCustomRelativePathsType; + [ClonePiece.PlanningAreaGridCustom]: PlanningAreaCustomGridRelativePathsType; + [ClonePiece.ProjectMetadata]: ProjectMetadataRelativePathsType; + [ClonePiece.ScenarioMetadata]: ScenarioMetadataRelativePathsType; +} + +type ClonePieceRelativePaths = { + [property in ClonePiece]: ClonePieceRelativePathsType[property]; +}; + +export const ClonePieceRelativePaths: ClonePieceRelativePaths = { + [ClonePiece.ExportConfig]: ExportConfigRelativePaths, + [ClonePiece.PlanningAreaGAdm]: PlanningAreaGadmRelativePaths, + [ClonePiece.PlanningAreaCustom]: PlanningAreaCustomRelativePaths, + [ClonePiece.PlanningAreaGridCustom]: PlanningAreaCustomGridRelativePaths, + [ClonePiece.ProjectMetadata]: ProjectMetadataRelativePaths, + [ClonePiece.ScenarioMetadata]: ScenarioMetadataRelativePaths, +}; diff --git a/api/libs/cloning/src/infrastructure/clone-piece-data/planning-area-custom.ts b/api/libs/cloning/src/infrastructure/clone-piece-data/planning-area-custom.ts new file mode 100644 index 0000000000..5fd5759f00 --- /dev/null +++ b/api/libs/cloning/src/infrastructure/clone-piece-data/planning-area-custom.ts @@ -0,0 +1,9 @@ +export interface PlanningAreaCustomRelativePathsType { + planningArea: string; + customPaGeoJson: string; +} + +export const PlanningAreaCustomRelativePaths: PlanningAreaCustomRelativePathsType = { + planningArea: 'planning-area.json', + customPaGeoJson: 'planning-area/project-pa.geojson', +}; diff --git a/api/libs/cloning/src/infrastructure/clone-piece-data/planning-area-gadm.ts b/api/libs/cloning/src/infrastructure/clone-piece-data/planning-area-gadm.ts new file mode 100644 index 0000000000..2e8c68bfd6 --- /dev/null +++ b/api/libs/cloning/src/infrastructure/clone-piece-data/planning-area-gadm.ts @@ -0,0 +1,18 @@ +import { PlanningUnitGridShape } from '../../../../scenarios-planning-unit/src'; + +export interface PlanningAreaGadmContent { + country: string; + l1?: string; + l2?: string; + puGridShape: PlanningUnitGridShape; + planningUnitAreakm2: number; + bbox: number[]; +} + +export interface PlanningAreaGadmRelativePathsType { + paGadm: string; +} + +export const PlanningAreaGadmRelativePaths: PlanningAreaGadmRelativePathsType = { + paGadm: 'planning-area.json', +}; diff --git a/api/libs/cloning/src/infrastructure/clone-piece-data/planning-area-grid-custom.ts b/api/libs/cloning/src/infrastructure/clone-piece-data/planning-area-grid-custom.ts new file mode 100644 index 0000000000..d4753213d0 --- /dev/null +++ b/api/libs/cloning/src/infrastructure/clone-piece-data/planning-area-grid-custom.ts @@ -0,0 +1,9 @@ +export interface PlanningAreaCustomGridRelativePathsType { + projectGrid: string; + customGridGeoJson: string; +} + +export const PlanningAreaCustomGridRelativePaths: PlanningAreaCustomGridRelativePathsType = { + projectGrid: 'project-grid.json', + customGridGeoJson: 'project-grid/custom-grid.geojson', +}; diff --git a/api/libs/cloning/src/infrastructure/clone-piece-data/project-metadata.ts b/api/libs/cloning/src/infrastructure/clone-piece-data/project-metadata.ts new file mode 100644 index 0000000000..30531699d2 --- /dev/null +++ b/api/libs/cloning/src/infrastructure/clone-piece-data/project-metadata.ts @@ -0,0 +1,12 @@ +export interface ProjectMetadataContent { + name: string; + description?: string; +} + +export interface ProjectMetadataRelativePathsType { + projectMetadata: string; +} + +export const ProjectMetadataRelativePaths: ProjectMetadataRelativePathsType = { + projectMetadata: `project-metadata.json`, +}; diff --git a/api/libs/cloning/src/infrastructure/clone-piece-data/scenario-metadata.ts b/api/libs/cloning/src/infrastructure/clone-piece-data/scenario-metadata.ts new file mode 100644 index 0000000000..dba650aeec --- /dev/null +++ b/api/libs/cloning/src/infrastructure/clone-piece-data/scenario-metadata.ts @@ -0,0 +1,23 @@ +import { ResourceKind } from '../../domain'; + +export interface ScenarioMetadataContent { + name: string; + description?: string; +} + +export interface ScenarioMetadataRelativePathsType { + getScenarioMetadataRelativePath: ( + exportResourceKind: ResourceKind, + exportResourceId: string, + ) => string; +} + +export const ScenarioMetadataRelativePaths: ScenarioMetadataRelativePathsType = { + getScenarioMetadataRelativePath: ( + exportResourceKind: ResourceKind, + exportResourceId: string, + ) => + exportResourceKind === ResourceKind.Scenario + ? `scenario-metadata.json` + : `scenarios/${exportResourceId}/scenario-metadata.json`, +}; diff --git a/api/libs/files-repository/src/file.repository.ts b/api/libs/files-repository/src/file.repository.ts index a6c1016a75..2e52b54573 100644 --- a/api/libs/files-repository/src/file.repository.ts +++ b/api/libs/files-repository/src/file.repository.ts @@ -4,7 +4,7 @@ import { Either, left } from 'fp-ts/Either'; export const unknownError = Symbol(`unknown error`); export const storageNotReachable = Symbol(`storage not reachable`); -export const notFound = Symbol(`not found`); +export const fileNotFound = Symbol(`file not found`); export const hackerFound = Symbol(`please hands off`); export type SaveFileError = @@ -14,7 +14,7 @@ export type SaveFileError = export type GetFileError = | typeof storageNotReachable - | typeof notFound + | typeof fileNotFound | typeof hackerFound; @Injectable() diff --git a/api/libs/utils/src/index.ts b/api/libs/utils/src/index.ts index 8654d3df75..7c4400b27b 100644 --- a/api/libs/utils/src/index.ts +++ b/api/libs/utils/src/index.ts @@ -1,5 +1,6 @@ export { isDefined } from './is-defined'; export { assertDefined } from './assert-defined'; +export { extractFile, extractFileFailed } from './zip-file-extractor'; export { FieldsOf } from './fields-of.type'; export * from './geo'; export { TimeUserEntityMetadata } from './time-user-entity-metadata'; diff --git a/api/libs/utils/src/zip-file-extractor.ts b/api/libs/utils/src/zip-file-extractor.ts new file mode 100644 index 0000000000..bd7be8cb6f --- /dev/null +++ b/api/libs/utils/src/zip-file-extractor.ts @@ -0,0 +1,22 @@ +import { Either, left, right } from 'fp-ts/lib/Either'; +import { Readable } from 'stream'; +import * as unzipper from 'unzipper'; + +export const extractFileFailed = Symbol('Extract file failed'); + +export async function extractFile( + readable: Readable, + fileName: RegExp, +): Promise> { + return new Promise>((resolve) => { + readable + .pipe(unzipper.ParseOne(fileName)) + .on('entry', async (entry: unzipper.Entry) => { + const buffer = await entry.buffer(); + resolve(right(buffer.toString())); + }) + .on('error', () => { + resolve(left(extractFileFailed)); + }); + }); +}