diff --git a/api/apps/api/src/migrations/api/1649329520250-AddOwnerIdColumnToImportsTable.ts b/api/apps/api/src/migrations/api/1649329520250-AddOwnerIdColumnToImportsTable.ts new file mode 100644 index 0000000000..645c2f55b3 --- /dev/null +++ b/api/apps/api/src/migrations/api/1649329520250-AddOwnerIdColumnToImportsTable.ts @@ -0,0 +1,21 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddOwnerIdColumnToImportsTable1649329520250 + implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE imports ADD COLUMN owner_id uuid NOT NULL; + `); + + await queryRunner.query(` + ALTER TABLE imports + ADD CONSTRAINT imports_owner_id_fkey FOREIGN KEY (owner_id) REFERENCES users(id); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE imports DROP COLUMN owner_id; + `); + } +} diff --git a/api/apps/api/src/modules/clone/import/adapters/entities/imports.api.entity.ts b/api/apps/api/src/modules/clone/import/adapters/entities/imports.api.entity.ts index d86d80e874..d398dcc9a1 100644 --- a/api/apps/api/src/modules/clone/import/adapters/entities/imports.api.entity.ts +++ b/api/apps/api/src/modules/clone/import/adapters/entities/imports.api.entity.ts @@ -1,5 +1,13 @@ import { ResourceKind } from '@marxan/cloning/domain'; -import { Column, Entity, OneToMany, PrimaryColumn } from 'typeorm'; +import { + Column, + Entity, + JoinColumn, + ManyToOne, + OneToMany, + PrimaryColumn, +} from 'typeorm'; +import { User } from '../../../../users/user.api.entity'; import { Import } from '../../domain'; import { ImportComponentEntity } from './import-components.api.entity'; @@ -14,6 +22,9 @@ export class ImportEntity { @Column({ type: 'uuid', name: 'project_id' }) projectId!: string; + @Column({ type: 'uuid', name: 'owner_id' }) + ownerId!: string; + @Column({ type: 'enum', name: 'resource_kind', @@ -32,6 +43,15 @@ export class ImportEntity { }) components!: ImportComponentEntity[]; + @ManyToOne(() => User, (user) => user.id, { + onDelete: 'CASCADE', + }) + @JoinColumn({ + name: 'owner_id', + referencedColumnName: 'id', + }) + owner!: User; + static fromAggregate(importAggregate: Import): ImportEntity { const snapshot = importAggregate.toSnapshot(); @@ -45,6 +65,7 @@ export class ImportEntity { entity.components = snapshot.importPieces.map( ImportComponentEntity.fromSnapshot, ); + entity.ownerId = snapshot.ownerId; return entity; } @@ -57,6 +78,7 @@ export class ImportEntity { resourceKind: this.resourceKind, archiveLocation: this.archiveLocation, importPieces: this.components.map((component) => component.toSnapshot()), + ownerId: this.ownerId, }); } } diff --git a/api/apps/api/src/modules/clone/import/application/complete-import-piece.handler.spec.ts b/api/apps/api/src/modules/clone/import/application/complete-import-piece.handler.spec.ts index 44ac804543..415e2ea41b 100644 --- a/api/apps/api/src/modules/clone/import/application/complete-import-piece.handler.spec.ts +++ b/api/apps/api/src/modules/clone/import/application/complete-import-piece.handler.spec.ts @@ -6,6 +6,7 @@ import { ResourceId, ResourceKind, } from '@marxan/cloning/domain'; +import { UserId } from '@marxan/domain-ids'; import { FixtureType } from '@marxan/utils/tests/fixture-type'; import { Logger } from '@nestjs/common'; import { @@ -131,6 +132,8 @@ const getFixtures = async () => { }).compile(); await sandbox.init(); + const ownerId = UserId.create(); + const events: IEvent[] = []; const commands: ICommand[] = []; @@ -156,6 +159,7 @@ const getFixtures = async () => { importResourceId, ResourceKind.Project, projectId, + ownerId, new ArchiveLocation('/tmp/location.zip'), pieces, ); diff --git a/api/apps/api/src/modules/clone/import/application/import-project.command.ts b/api/apps/api/src/modules/clone/import/application/import-project.command.ts index 2b5b9d20ef..c14b851776 100644 --- a/api/apps/api/src/modules/clone/import/application/import-project.command.ts +++ b/api/apps/api/src/modules/clone/import/application/import-project.command.ts @@ -1,5 +1,6 @@ import { ArchiveLocation } from '@marxan/cloning/domain'; import { Failure as ArchiveReadError } from '@marxan/cloning/infrastructure/archive-reader.port'; +import { UserId } from '@marxan/domain-ids'; import { Command } from '@nestjs-architects/typed-cqrs'; import { Either } from 'fp-ts/lib/Either'; import { SaveError } from './import.repository.port'; @@ -14,7 +15,10 @@ export type ImportProjectCommandResult = { export class ImportProject extends Command< Either > { - constructor(public readonly archiveLocation: ArchiveLocation) { + constructor( + public readonly archiveLocation: ArchiveLocation, + public readonly ownerId: UserId, + ) { super(); } } diff --git a/api/apps/api/src/modules/clone/import/application/import-project.handler.spec.ts b/api/apps/api/src/modules/clone/import/application/import-project.handler.spec.ts index d183dd173c..1d64f92ea3 100644 --- a/api/apps/api/src/modules/clone/import/application/import-project.handler.spec.ts +++ b/api/apps/api/src/modules/clone/import/application/import-project.handler.spec.ts @@ -5,16 +5,20 @@ import { ResourceId, ResourceKind, } from '@marxan/cloning/domain'; +import { + Failure as ArchiveFailure, + invalidFiles, +} from '@marxan/cloning/infrastructure/archive-reader.port'; import { ExportConfigContent, ProjectExportConfigContent, } from '@marxan/cloning/infrastructure/clone-piece-data/export-config'; +import { UserId } from '@marxan/domain-ids'; import { FixtureType } from '@marxan/utils/tests/fixture-type'; import { CqrsModule, EventBus, IEvent } from '@nestjs/cqrs'; import { Test } from '@nestjs/testing'; import { Either, isLeft, isRight, left, Right, right } from 'fp-ts/Either'; import { PromiseType } from 'utility-types'; -import { ExportConfigReader } from './export-config-reader'; import { MemoryImportRepository } from '../adapters/memory-import.repository.adapter'; import { ImportComponent, @@ -22,18 +26,15 @@ import { ImportRequested, PieceImportRequested, } from '../domain'; -import { - Failure as ArchiveFailure, - invalidFiles, -} from '@marxan/cloning/infrastructure/archive-reader.port'; -import { ImportResourcePieces } from './import-resource-pieces.port'; -import { ImportRepository } from './import.repository.port'; -import { ImportProjectHandler } from './import-project.handler'; +import { ImportComponentStatuses } from '../domain/import/import-component-status'; +import { ExportConfigReader } from './export-config-reader'; import { ImportProject, ImportProjectCommandResult, } from './import-project.command'; -import { ImportComponentStatuses } from '../domain/import/import-component-status'; +import { ImportProjectHandler } from './import-project.handler'; +import { ImportResourcePieces } from './import-resource-pieces.port'; +import { ImportRepository } from './import.repository.port'; let fixtures: FixtureType; @@ -83,6 +84,7 @@ const getFixtures = async () => { await sandbox.init(); let resourceId: ResourceId; + const ownerId = UserId.create(); const events: IEvent[] = []; sandbox.get(EventBus).subscribe((event) => events.push(event)); @@ -110,7 +112,7 @@ const getFixtures = async () => { }, WhenRequestingImport: async () => { const importResult = await sut.execute( - new ImportProject(new ArchiveLocation(`whatever`)), + new ImportProject(new ArchiveLocation(`whatever`), ownerId), ); if (isRight(importResult)) resourceId = new ResourceId( diff --git a/api/apps/api/src/modules/clone/import/application/import-project.handler.ts b/api/apps/api/src/modules/clone/import/application/import-project.handler.ts index 15336a47b2..e5caf13682 100644 --- a/api/apps/api/src/modules/clone/import/application/import-project.handler.ts +++ b/api/apps/api/src/modules/clone/import/application/import-project.handler.ts @@ -28,6 +28,7 @@ export class ImportProjectHandler async execute({ archiveLocation, + ownerId, }: ImportProject): Promise< Either > { @@ -51,6 +52,7 @@ export class ImportProjectHandler importResourceId, ResourceKind.Project, projectId, + ownerId, archiveLocation, pieces, ), diff --git a/api/apps/api/src/modules/clone/import/application/import-scenario.command.ts b/api/apps/api/src/modules/clone/import/application/import-scenario.command.ts index 8c7cb7a6ec..aa6a2961d7 100644 --- a/api/apps/api/src/modules/clone/import/application/import-scenario.command.ts +++ b/api/apps/api/src/modules/clone/import/application/import-scenario.command.ts @@ -1,5 +1,6 @@ import { ArchiveLocation } from '@marxan/cloning/domain'; import { Failure as ArchiveReadError } from '@marxan/cloning/infrastructure/archive-reader.port'; +import { UserId } from '@marxan/domain-ids'; import { Command } from '@nestjs-architects/typed-cqrs'; import { Either } from 'fp-ts/lib/Either'; import { SaveError } from './import.repository.port'; @@ -14,7 +15,10 @@ export type ImportScenarioCommandResult = { export class ImportScenario extends Command< Either > { - constructor(public readonly archiveLocation: ArchiveLocation) { + constructor( + public readonly archiveLocation: ArchiveLocation, + public readonly ownerId: UserId, + ) { super(); } } diff --git a/api/apps/api/src/modules/clone/import/application/import-scenario.handler.spec.ts b/api/apps/api/src/modules/clone/import/application/import-scenario.handler.spec.ts index 2e90bab460..aed0531c7b 100644 --- a/api/apps/api/src/modules/clone/import/application/import-scenario.handler.spec.ts +++ b/api/apps/api/src/modules/clone/import/application/import-scenario.handler.spec.ts @@ -14,6 +14,7 @@ import { ProjectExportConfigContent, ScenarioExportConfigContent, } from '@marxan/cloning/infrastructure/clone-piece-data/export-config'; +import { UserId } from '@marxan/domain-ids'; import { FixtureType } from '@marxan/utils/tests/fixture-type'; import { CqrsModule, EventBus, IEvent } from '@nestjs/cqrs'; import { Test } from '@nestjs/testing'; @@ -85,6 +86,7 @@ const getFixtures = async () => { await sandbox.init(); let resourceId: ResourceId; + const ownerId = UserId.create(); const events: IEvent[] = []; sandbox.get(EventBus).subscribe((event) => events.push(event)); @@ -112,7 +114,7 @@ const getFixtures = async () => { }, WhenRequestingImport: async () => { const importResult = await sut.execute( - new ImportScenario(new ArchiveLocation(`whatever`)), + new ImportScenario(new ArchiveLocation(`whatever`), ownerId), ); if (isRight(importResult)) resourceId = new ResourceId( diff --git a/api/apps/api/src/modules/clone/import/application/import-scenario.handler.ts b/api/apps/api/src/modules/clone/import/application/import-scenario.handler.ts index 1f1c3fa01a..4449b8b6ef 100644 --- a/api/apps/api/src/modules/clone/import/application/import-scenario.handler.ts +++ b/api/apps/api/src/modules/clone/import/application/import-scenario.handler.ts @@ -28,6 +28,7 @@ export class ImportScenarioHandler async execute({ archiveLocation, + ownerId, }: ImportScenario): Promise< Either > { @@ -52,6 +53,7 @@ export class ImportScenarioHandler importResourceId, ResourceKind.Scenario, new ResourceId(exportConfig.projectId), + ownerId, archiveLocation, pieces, ), diff --git a/api/apps/api/src/modules/clone/import/domain/import/import.snapshot.ts b/api/apps/api/src/modules/clone/import/domain/import/import.snapshot.ts index 6a0eec7a8b..b8663db133 100644 --- a/api/apps/api/src/modules/clone/import/domain/import/import.snapshot.ts +++ b/api/apps/api/src/modules/clone/import/domain/import/import.snapshot.ts @@ -8,4 +8,5 @@ export interface ImportSnapshot { archiveLocation: string; importPieces: ImportComponentSnapshot[]; projectId: string; + ownerId: string; } diff --git a/api/apps/api/src/modules/clone/import/domain/import/import.ts b/api/apps/api/src/modules/clone/import/domain/import/import.ts index a3f56eddc4..0fd61fe751 100644 --- a/api/apps/api/src/modules/clone/import/domain/import/import.ts +++ b/api/apps/api/src/modules/clone/import/domain/import/import.ts @@ -6,6 +6,7 @@ import { } from '@marxan/cloning/domain'; import { AggregateRoot } from '@nestjs/cqrs'; import { Either, left, right } from 'fp-ts/Either'; +import { UserId } from '@marxan/domain-ids'; import { AllPiecesImported } from '../events/all-pieces-imported.event'; import { ImportBatchFailed } from '../events/import-batch-failed.event'; import { ImportRequested } from '../events/import-requested.event'; @@ -33,6 +34,7 @@ export class Import extends AggregateRoot { private readonly resourceId: ResourceId, private readonly resourceKind: ResourceKind, private readonly projectId: ResourceId, + private readonly ownerId: UserId, private readonly archiveLocation: ArchiveLocation, private readonly pieces: ImportComponent[], ) { @@ -60,6 +62,7 @@ export class Import extends AggregateRoot { new ResourceId(snapshot.resourceId), snapshot.resourceKind, new ResourceId(snapshot.projectId), + new UserId(snapshot.ownerId), new ArchiveLocation(snapshot.archiveLocation), snapshot.importPieces.map(ImportComponent.fromSnapshot), ); @@ -69,6 +72,7 @@ export class Import extends AggregateRoot { resourceId: ResourceId, kind: ResourceKind, projectId: ResourceId, + ownerId: UserId, archiveLocation: ArchiveLocation, pieces: ImportComponent[], ): Import { @@ -78,6 +82,7 @@ export class Import extends AggregateRoot { resourceId, kind, projectId, + ownerId, archiveLocation, pieces, ); @@ -184,6 +189,7 @@ export class Import extends AggregateRoot { importPieces: this.pieces.map((piece) => piece.toSnapshot()), archiveLocation: this.archiveLocation.value, projectId: this.projectId.value, + ownerId: this.ownerId.value, }; } } diff --git a/api/apps/api/src/modules/clone/infra/import/import-piece.events-handler.spec.ts b/api/apps/api/src/modules/clone/infra/import/import-piece.events-handler.spec.ts index 289f98abfe..637d9ebfd6 100644 --- a/api/apps/api/src/modules/clone/infra/import/import-piece.events-handler.spec.ts +++ b/api/apps/api/src/modules/clone/infra/import/import-piece.events-handler.spec.ts @@ -87,6 +87,7 @@ const getFixtures = async () => { projectId: v4(), resourceKind: ResourceKind.Project, uris: [], + ownerId: v4(), }; }, WhenJobFinishes: async (input: ImportJobInput) => { diff --git a/api/apps/api/src/modules/clone/infra/import/schedule-piece-import.handler.spec.ts b/api/apps/api/src/modules/clone/infra/import/schedule-piece-import.handler.spec.ts index 490fb7d675..dd05072744 100644 --- a/api/apps/api/src/modules/clone/infra/import/schedule-piece-import.handler.spec.ts +++ b/api/apps/api/src/modules/clone/infra/import/schedule-piece-import.handler.spec.ts @@ -6,6 +6,7 @@ import { ResourceId, ResourceKind, } from '@marxan/cloning/domain'; +import { UserId } from '@marxan/domain-ids'; import { FixtureType } from '@marxan/utils/tests/fixture-type'; import { Logger } from '@nestjs/common'; import { CommandBus, CommandHandler, CqrsModule, ICommand } from '@nestjs/cqrs'; @@ -113,6 +114,7 @@ const getFixtures = async () => { }).compile(); await sandbox.init(); + const ownerId = UserId.create(); const commands: ICommand[] = []; sandbox.get(CommandBus).subscribe((command) => { commands.push(command); @@ -136,6 +138,7 @@ const getFixtures = async () => { importResourceId, ResourceKind.Project, projectId, + ownerId, new ArchiveLocation('/tmp/foo.zip'), [importComponent], ); diff --git a/api/apps/api/src/modules/clone/infra/import/schedule-piece-import.handler.ts b/api/apps/api/src/modules/clone/infra/import/schedule-piece-import.handler.ts index 8a7cb8045a..4f7133d1fe 100644 --- a/api/apps/api/src/modules/clone/infra/import/schedule-piece-import.handler.ts +++ b/api/apps/api/src/modules/clone/infra/import/schedule-piece-import.handler.ts @@ -54,6 +54,7 @@ export class SchedulePieceImportHandler resourceKind, projectId, importPieces, + ownerId, } = importInstance.toSnapshot(); const component = importPieces.find( @@ -74,6 +75,7 @@ export class SchedulePieceImportHandler componentId: componentId.value, pieceResourceId: resourceId, projectId, + ownerId, resourceKind, uris, }); diff --git a/api/apps/api/src/modules/projects/projects.controller.ts b/api/apps/api/src/modules/projects/projects.controller.ts index 093d5ab94a..1724c19ac5 100644 --- a/api/apps/api/src/modules/projects/projects.controller.ts +++ b/api/apps/api/src/modules/projects/projects.controller.ts @@ -746,11 +746,17 @@ export class ProjectsController { @UseInterceptors(FileInterceptor('file')) async importProject( @UploadedFile() file: Express.Multer.File, + @Req() req: RequestWithAuthenticatedUser, ): Promise { - const idsOrError = await this.projectsService.importProject(file); + const idsOrError = await this.projectsService.importProject( + file, + req.user.id, + ); if (isLeft(idsOrError)) { switch (idsOrError.left) { + case forbiddenError: + throw new ForbiddenException(); case archiveCorrupted: throw new BadRequestException('Missing export config file'); case invalidFiles: diff --git a/api/apps/api/src/modules/projects/projects.service.ts b/api/apps/api/src/modules/projects/projects.service.ts index 2ff2445691..4f68b57d05 100644 --- a/api/apps/api/src/modules/projects/projects.service.ts +++ b/api/apps/api/src/modules/projects/projects.service.ts @@ -59,6 +59,7 @@ import { ImportProjectError, } from '../clone/import/application/import-project.command'; import { PlanningUnitGridShape } from '@marxan/scenarios-planning-unit'; +import { UserId } from '@marxan/domain-ids'; export { validationFailed } from '../planning-areas'; @@ -466,19 +467,27 @@ export class ProjectsService { async importProject( exportFile: Express.Multer.File, + userId: string, ): Promise< - Either + Either< + typeof unknownError | ImportProjectError | typeof forbiddenError, + ImportProjectCommandResult + > > { const archiveLocationOrError = await this.commandBus.execute( new UploadExportFile(exportFile), ); + if (!(await this.projectAclService.canCreateProject(userId))) { + return left(forbiddenError); + } + if (isLeft(archiveLocationOrError)) { return archiveLocationOrError; } const idsOrError = await this.commandBus.execute( - new ImportProject(archiveLocationOrError.right), + new ImportProject(archiveLocationOrError.right, new UserId(userId)), ); if (isLeft(idsOrError)) { diff --git a/api/apps/api/test/integration/typeorm-import.repository.integration.e2e-spec.ts b/api/apps/api/test/integration/typeorm-import.repository.integration.e2e-spec.ts index d1dd1e4a45..35adb6c3a4 100644 --- a/api/apps/api/test/integration/typeorm-import.repository.integration.e2e-spec.ts +++ b/api/apps/api/test/integration/typeorm-import.repository.integration.e2e-spec.ts @@ -1,11 +1,11 @@ +import { ImportEntity } from '@marxan-api/modules/clone/import/adapters/entities/imports.api.entity'; +import { ImportAdaptersModule } from '@marxan-api/modules/clone/import/adapters/import-adapters.module'; +import { ImportRepository } from '@marxan-api/modules/clone/import/application/import.repository.port'; import { Import, ImportComponent, ImportId, } from '@marxan-api/modules/clone/import/domain'; -import { ImportEntity } from '@marxan-api/modules/clone/import/adapters/entities/imports.api.entity'; -import { ImportAdaptersModule } from '@marxan-api/modules/clone/import/adapters/import-adapters.module'; -import { ImportRepository } from '@marxan-api/modules/clone/import/application/import.repository.port'; import { ArchiveLocation, ClonePiece, @@ -14,12 +14,15 @@ import { ResourceId, ResourceKind, } from '@marxan/cloning/domain'; +import { UserId } from '@marxan/domain-ids'; import { FixtureType } from '@marxan/utils/tests/fixture-type'; import { Test } from '@nestjs/testing'; -import { TypeOrmModule } from '@nestjs/typeorm'; -import { Connection } from 'typeorm'; -import { apiConnections } from '../../src/ormconfig'; +import { getRepositoryToken, TypeOrmModule } from '@nestjs/typeorm'; +import { hash } from 'bcrypt'; +import { Connection, Repository } from 'typeorm'; import { ImportComponentStatuses } from '../../src/modules/clone/import/domain/import/import-component-status'; +import { User } from '../../src/modules/users/user.api.entity'; +import { apiConnections } from '../../src/ormconfig'; describe('Typeorm import repository', () => { let fixtures: FixtureType; @@ -66,6 +69,7 @@ const getFixtures = async () => { let importResourceId: ResourceId; let componentId: ComponentId; let archiveLocation: ArchiveLocation; + const ownerId = UserId.create(); const testingModule = await Test.createTestingModule({ imports: [ @@ -73,17 +77,30 @@ const getFixtures = async () => { ...apiConnections.default, keepConnectionAlive: true, }), + TypeOrmModule.forFeature([User]), ImportAdaptersModule, ], }).compile(); const repo = testingModule.get(ImportRepository); + const userRepo = testingModule.get>( + getRepositoryToken(User), + ); + + const passwordHash = await hash('supersecretpassword', 10); + + await userRepo.save({ + id: ownerId.value, + email: `${ownerId.value}@test.com`, + passwordHash, + }); return { cleanup: async () => { const connection = testingModule.get(Connection); const importRepo = connection.getRepository(ImportEntity); await importRepo.delete({}); + await userRepo.delete({ id: ownerId.value }); await testingModule.close(); }, GivenImportWasRequested: async () => { @@ -96,6 +113,7 @@ const getFixtures = async () => { importResourceId, ResourceKind.Project, projectId, + ownerId, archiveLocation, [ ImportComponent.fromSnapshot({ @@ -136,6 +154,7 @@ const getFixtures = async () => { importResourceId, ResourceKind.Project, projectId, + ownerId, archiveLocation, components, ); diff --git a/api/apps/api/test/jest-e2e.json b/api/apps/api/test/jest-e2e.json index c4410aa564..861885272f 100644 --- a/api/apps/api/test/jest-e2e.json +++ b/api/apps/api/test/jest-e2e.json @@ -70,6 +70,8 @@ "@marxan/webshot/(.*)": "../../../libs/webshot/src/$1", "@marxan/webshot": "../../../libs/webshot/src", "@marxan/geofeatures/(.*)": "/../../../libs/geofeatures/src/$1", - "@marxan/geofeatures": "/../../../libs/geofeatures/src" + "@marxan/geofeatures": "/../../../libs/geofeatures/src", + "@marxan/domain-ids/(.*)": "/../../../libs/domain-ids/src/$1", + "@marxan/domain-ids": "/../../../libs/domain-ids/src" } } diff --git a/api/apps/geoprocessing/src/import/pieces-importers/project-metadata.piece-importer.ts b/api/apps/geoprocessing/src/import/pieces-importers/project-metadata.piece-importer.ts index 9de85ecfa9..d7615b5271 100644 --- a/api/apps/geoprocessing/src/import/pieces-importers/project-metadata.piece-importer.ts +++ b/api/apps/geoprocessing/src/import/pieces-importers/project-metadata.piece-importer.ts @@ -39,7 +39,7 @@ export class ProjectMetadataPieceImporter implements ImportPieceProcessor { } async run(input: ImportJobInput): Promise { - const { uris, pieceResourceId, projectId, piece } = input; + const { uris, pieceResourceId, projectId, piece, ownerId } = input; if (uris.length !== 1) { const errorMessage = `uris array has an unexpected amount of elements: ${uris.length}`; @@ -77,18 +77,38 @@ export class ProjectMetadataPieceImporter implements ImportPieceProcessor { stringProjectMetadataOrError.right, ); - await this.entityManager - .createQueryBuilder() - .insert() - .into(`projects`) - .values({ - id: projectId, - name: projectMetadata.name, - description: projectMetadata.description, - organization_id: organizationId, - planning_unit_grid_shape: projectMetadata.planningUnitGridShape, - }) - .execute(); + await this.entityManager.transaction(async (em) => { + await em + .createQueryBuilder() + .insert() + .into(`projects`) + .values({ + id: projectId, + name: projectMetadata.name, + description: projectMetadata.description, + organization_id: organizationId, + planning_unit_grid_shape: projectMetadata.planningUnitGridShape, + }) + .execute(); + + await em + .createQueryBuilder() + .insert() + .into(`users_projects`) + .values({ + user_id: ownerId, + project_id: projectId, + // It would be great to use ProjectRoles enum instead of having + // the role hardcoded. The thing is that Geoprocessing code shouldn't depend + // directly on elements of Api code, so there were two options: + // - Move ProjectRoles enum to libs package + // - Harcode the rol + // We took the second approach because we are only referencing values from that enum + // here + role_id: 'project_owner', + }) + .execute(); + }); return { importId: input.importId, diff --git a/api/apps/geoprocessing/src/import/pieces-importers/scenario-metadata.piece-importer.ts b/api/apps/geoprocessing/src/import/pieces-importers/scenario-metadata.piece-importer.ts index 823f975ed8..60829ec076 100644 --- a/api/apps/geoprocessing/src/import/pieces-importers/scenario-metadata.piece-importer.ts +++ b/api/apps/geoprocessing/src/import/pieces-importers/scenario-metadata.piece-importer.ts @@ -1,5 +1,6 @@ import { geoprocessingConnections } from '@marxan-geoprocessing/ormconfig'; import { ClonePiece, ImportJobInput, ImportJobOutput } from '@marxan/cloning'; +import { ResourceKind } from '@marxan/cloning/domain'; import { ScenarioMetadataContent } from '@marxan/cloning/infrastructure/clone-piece-data/scenario-metadata'; import { FileRepository } from '@marxan/files-repository'; import { extractFile } from '@marxan/utils'; @@ -29,7 +30,14 @@ export class ScenarioMetadataPieceImporter implements ImportPieceProcessor { } async run(input: ImportJobInput): Promise { - const { pieceResourceId: scenarioId, projectId, uris, piece } = input; + const { + pieceResourceId: scenarioId, + projectId, + uris, + piece, + resourceKind, + ownerId, + } = input; if (uris.length !== 1) { const errorMessage = `uris array has an unexpected amount of elements: ${uris.length}`; @@ -68,20 +76,42 @@ export class ScenarioMetadataPieceImporter implements ImportPieceProcessor { stringScenarioMetadataOrError.right, ); - await this.entityManager - .createQueryBuilder() - .insert() - .into('scenarios') - .values({ - id: scenarioId, - name, - description, - blm, - number_of_runs: numberOfRuns, - metadata, - project_id: projectId, - }) - .execute(); + await this.entityManager.transaction(async (em) => { + await em + .createQueryBuilder() + .insert() + .into('scenarios') + .values({ + id: scenarioId, + name, + description, + blm, + number_of_runs: numberOfRuns, + metadata, + project_id: projectId, + }) + .execute(); + + if (resourceKind === ResourceKind.Scenario) { + await em + .createQueryBuilder() + .insert() + .into(`users_scenarios`) + .values({ + user_id: ownerId, + scenario_id: scenarioId, + // It would be great to use ScenarioRoles enum instead of having + // the role hardcoded. The thing is that Geoprocessing code shouldn't depend + // directly on elements of Api code, so there were two options: + // - Move ScenarioRoles enum to libs package + // - Harcode the rol + // We took the second approach because we are only referencing values from that enum + // here + role_id: 'scenario_owner', + }) + .execute(); + } + }); return { importId: input.importId, diff --git a/api/apps/geoprocessing/test/integration/clonning/fixtures.ts b/api/apps/geoprocessing/test/integration/clonning/fixtures.ts index f5efb85b59..76bf634cb0 100644 --- a/api/apps/geoprocessing/test/integration/clonning/fixtures.ts +++ b/api/apps/geoprocessing/test/integration/clonning/fixtures.ts @@ -19,6 +19,7 @@ import { ScenariosPuPaDataGeo, } from '@marxan/scenarios-planning-unit'; import * as archiver from 'archiver'; +import { hash } from 'bcrypt'; import { isLeft } from 'fp-ts/lib/Either'; import { Readable, Transform } from 'stream'; import { DeepPartial, EntityManager, In } from 'typeorm'; @@ -79,6 +80,31 @@ export async function PrepareZipFile( return new ArchiveLocation(uriOrError.right); } +export function GivenUserExists( + em: EntityManager, + userId: string, + projectId: string, +) { + return em + .createQueryBuilder() + .insert() + .into(`users`) + .values({ + id: userId, + email: `${userId}@${projectId}.com`, + password_hash: hash('supersecretpassword', 10), + }) + .execute(); +} + +export function DeleteUser(em: EntityManager, userId: string) { + return em + .createQueryBuilder() + .delete() + .from('user') + .where('id = :userId', { userId }); +} + export function GivenOrganizationExists( em: EntityManager, organizationId: string, @@ -408,6 +434,17 @@ export async function GivenFeatures( }; } +export async function DeleteFeatures(em: EntityManager, featureIds: string[]) { + if (featureIds.length === 0) return; + + await em + .createQueryBuilder() + .delete() + .from('features') + .where('id IN (:...featureIds)', { featureIds }) + .execute(); +} + export async function GivenFeaturesData( em: EntityManager, amountOfRecordsForEachFeature: number, diff --git a/api/apps/geoprocessing/test/integration/clonning/planning-area-custom.piece-importer.e2e-spec.ts b/api/apps/geoprocessing/test/integration/clonning/planning-area-custom.piece-importer.e2e-spec.ts index bde90b8289..532d6cf5e5 100644 --- a/api/apps/geoprocessing/test/integration/clonning/planning-area-custom.piece-importer.e2e-spec.ts +++ b/api/apps/geoprocessing/test/integration/clonning/planning-area-custom.piece-importer.e2e-spec.ts @@ -92,6 +92,7 @@ const getFixtures = async () => { await sandbox.init(); const organizationId = v4(); const projectId = v4(); + const userId = v4(); const entityManager = sandbox.get( getEntityManagerToken(geoprocessingConnections.apiDB.name), @@ -111,9 +112,8 @@ const getFixtures = async () => { ); await planningAreaRepo.delete({ projectId }); }, - GivenProject: () => { - return GivenProjectExists(entityManager, projectId, organizationId); - }, + GivenProject: () => + GivenProjectExists(entityManager, projectId, organizationId), GivenJobInput: (archiveLocation: ArchiveLocation): ImportJobInput => { const [uri] = ClonePieceUrisResolver.resolveFor( ClonePiece.PlanningAreaCustom, @@ -127,6 +127,7 @@ const getFixtures = async () => { piece: ClonePiece.PlanningAreaCustom, resourceKind: ResourceKind.Project, uris: [uri.toSnapshot()], + ownerId: userId, }; }, GivenJobInputWithoutUris: (): ImportJobInput => { @@ -138,6 +139,7 @@ const getFixtures = async () => { piece: ClonePiece.PlanningAreaCustom, resourceKind: ResourceKind.Project, uris: [], + ownerId: userId, }; }, GivenNoCustomPlanningAreaFileIsAvailable: () => { diff --git a/api/apps/geoprocessing/test/integration/clonning/planning-area-gadm.piece-importer.e2e-spec.ts b/api/apps/geoprocessing/test/integration/clonning/planning-area-gadm.piece-importer.e2e-spec.ts index d162bbcb1e..bd137e3767 100644 --- a/api/apps/geoprocessing/test/integration/clonning/planning-area-gadm.piece-importer.e2e-spec.ts +++ b/api/apps/geoprocessing/test/integration/clonning/planning-area-gadm.piece-importer.e2e-spec.ts @@ -85,6 +85,7 @@ const getFixtures = async () => { await sandbox.init(); const organizationId = v4(); const projectId = v4(); + const userId = v4(); const entityManager = sandbox.get( getEntityManagerToken(geoprocessingConnections.apiDB.name), @@ -124,6 +125,7 @@ const getFixtures = async () => { piece: ClonePiece.PlanningAreaGAdm, resourceKind: ResourceKind.Project, uris: [uri.toSnapshot()], + ownerId: userId, }; }, GivenJobInputWithoutUris: (): ImportJobInput => { @@ -135,6 +137,7 @@ const getFixtures = async () => { piece: ClonePiece.PlanningAreaGAdm, resourceKind: ResourceKind.Project, uris: [], + ownerId: userId, }; }, GivenNoGadmPlanningAreaFileIsAvailable: () => { diff --git a/api/apps/geoprocessing/test/integration/clonning/planning-units-grid.piece-importer.e2e-spec.ts b/api/apps/geoprocessing/test/integration/clonning/planning-units-grid.piece-importer.e2e-spec.ts index a1b49c9042..64769f7e0e 100644 --- a/api/apps/geoprocessing/test/integration/clonning/planning-units-grid.piece-importer.e2e-spec.ts +++ b/api/apps/geoprocessing/test/integration/clonning/planning-units-grid.piece-importer.e2e-spec.ts @@ -99,6 +99,7 @@ const getFixtures = async () => { await sandbox.init(); const projectId = v4(); + const userId = v4(); const sut = sandbox.get(PlanningUnitsGridPieceImporter); const fileRepository = sandbox.get(FileRepository); const entityManager = sandbox.get(getEntityManagerToken()); @@ -170,6 +171,7 @@ const getFixtures = async () => { piece: ClonePiece.PlanningUnitsGrid, resourceKind: ResourceKind.Project, uris: [uri.toSnapshot()], + ownerId: userId, }; }, GivenJobInputWithoutUris: (): ImportJobInput => { @@ -181,6 +183,7 @@ const getFixtures = async () => { piece: ClonePiece.PlanningUnitsGrid, resourceKind: ResourceKind.Project, uris: [], + ownerId: userId, }; }, WhenPieceImporterIsInvoked: (input: ImportJobInput) => { diff --git a/api/apps/geoprocessing/test/integration/clonning/project-custom-features.piece-importer.e2e-spec.ts b/api/apps/geoprocessing/test/integration/clonning/project-custom-features.piece-importer.e2e-spec.ts index 0a6092043f..6552fbb170 100644 --- a/api/apps/geoprocessing/test/integration/clonning/project-custom-features.piece-importer.e2e-spec.ts +++ b/api/apps/geoprocessing/test/integration/clonning/project-custom-features.piece-importer.e2e-spec.ts @@ -93,6 +93,7 @@ const getFixtures = async () => { await sandbox.init(); const projectId = v4(); const organizationId = v4(); + const userId = v4(); const geoEntityManager = sandbox.get(getEntityManagerToken()); const apiEntityManager = sandbox.get( @@ -149,6 +150,7 @@ const getFixtures = async () => { piece: ClonePiece.ProjectCustomFeatures, resourceKind: ResourceKind.Project, uris: [uri.toSnapshot()], + ownerId: userId, }; }, GivenJobInputWithoutUris: (): ImportJobInput => { @@ -160,6 +162,7 @@ const getFixtures = async () => { piece: ClonePiece.ProjectCustomFeatures, resourceKind: ResourceKind.Project, uris: [], + ownerId: userId, }; }, GivenNoProjectCustomFeaturesFileIsAvailable: () => { diff --git a/api/apps/geoprocessing/test/integration/clonning/project-custom-protected-areas.piece-importer.e2e-spec.ts b/api/apps/geoprocessing/test/integration/clonning/project-custom-protected-areas.piece-importer.e2e-spec.ts index 766810653f..e86f554d81 100644 --- a/api/apps/geoprocessing/test/integration/clonning/project-custom-protected-areas.piece-importer.e2e-spec.ts +++ b/api/apps/geoprocessing/test/integration/clonning/project-custom-protected-areas.piece-importer.e2e-spec.ts @@ -76,6 +76,7 @@ const getFixtures = async () => { await sandbox.init(); const projectId = v4(); + const userId = v4(); const entityManager = sandbox.get(getEntityManagerToken()); const protectedAreasRepo = sandbox.get>( @@ -104,6 +105,7 @@ const getFixtures = async () => { piece: ClonePiece.ProjectCustomProtectedAreas, resourceKind: ResourceKind.Project, uris: [uri.toSnapshot()], + ownerId: userId, }; }, GivenJobInputWithoutUris: (): ImportJobInput => { @@ -115,6 +117,7 @@ const getFixtures = async () => { piece: ClonePiece.ProjectCustomProtectedAreas, resourceKind: ResourceKind.Project, uris: [], + ownerId: userId, }; }, GivenNoProjectCustomProtectedAreasFileIsAvailable: () => { diff --git a/api/apps/geoprocessing/test/integration/clonning/project-metadata.piece-importer.e2e-spec.ts b/api/apps/geoprocessing/test/integration/clonning/project-metadata.piece-importer.e2e-spec.ts index 3bf12adc5c..3cb619b302 100644 --- a/api/apps/geoprocessing/test/integration/clonning/project-metadata.piece-importer.e2e-spec.ts +++ b/api/apps/geoprocessing/test/integration/clonning/project-metadata.piece-importer.e2e-spec.ts @@ -19,6 +19,7 @@ import { v4 } from 'uuid'; import { DeleteProjectAndOrganization, GivenOrganizationExists, + GivenUserExists, PrepareZipFile, } from './fixtures'; @@ -57,6 +58,7 @@ describe(ProjectMetadataPieceImporter, () => { it('imports project metadata', async () => { // Piece importer picks a random organization await fixtures.GivenOrganization(); + await fixtures.GivenUser(); const archiveLocation = await fixtures.GivenValidProjectMetadataFile(); const input = fixtures.GivenJobInput(archiveLocation); @@ -85,6 +87,7 @@ const getFixtures = async () => { await sandbox.init(); const projectId = v4(); const organizationId = v4(); + const userId = v4(); const sut = sandbox.get(ProjectMetadataPieceImporter); const fileRepository = sandbox.get(FileRepository); @@ -120,8 +123,10 @@ const getFixtures = async () => { piece: ClonePiece.ProjectMetadata, resourceKind: ResourceKind.Project, uris: [uri.toSnapshot()], + ownerId: userId, }; }, + GivenUser: () => GivenUserExists(entityManager, userId, projectId), GivenOrganization: () => { return GivenOrganizationExists(entityManager, organizationId); }, @@ -134,6 +139,7 @@ const getFixtures = async () => { piece: ClonePiece.ProjectMetadata, resourceKind: ResourceKind.Project, uris: [], + ownerId: userId, }; }, GivenNoProjectMetadataFileIsAvailable: () => { diff --git a/api/apps/geoprocessing/test/integration/clonning/scenario-features-data.piece-exporter.e2e-spec.ts b/api/apps/geoprocessing/test/integration/clonning/scenario-features-data.piece-exporter.e2e-spec.ts index 98e9bfab7b..ab91f57eb5 100644 --- a/api/apps/geoprocessing/test/integration/clonning/scenario-features-data.piece-exporter.e2e-spec.ts +++ b/api/apps/geoprocessing/test/integration/clonning/scenario-features-data.piece-exporter.e2e-spec.ts @@ -14,6 +14,7 @@ import { Readable } from 'stream'; import { EntityManager, In } from 'typeorm'; import { v4 } from 'uuid'; import { + DeleteFeatures, DeleteProjectAndOrganization, GivenFeatures, GivenOutputScenarioFeaturesData, @@ -104,6 +105,7 @@ const getFixtures = async () => { return { cleanUp: async () => { + await DeleteFeatures(apiEntityManager, featureIds); await DeleteProjectAndOrganization( apiEntityManager, projectId, diff --git a/api/apps/geoprocessing/test/integration/clonning/scenario-features-data.piece-importer.e2e-spec.ts b/api/apps/geoprocessing/test/integration/clonning/scenario-features-data.piece-importer.e2e-spec.ts index 0a7d6c7493..7b1b02d3f4 100644 --- a/api/apps/geoprocessing/test/integration/clonning/scenario-features-data.piece-importer.e2e-spec.ts +++ b/api/apps/geoprocessing/test/integration/clonning/scenario-features-data.piece-importer.e2e-spec.ts @@ -19,6 +19,7 @@ import { getEntityManagerToken, TypeOrmModule } from '@nestjs/typeorm'; import { EntityManager, In } from 'typeorm'; import { v4 } from 'uuid'; import { + DeleteFeatures, DeleteProjectAndOrganization, GivenFeatures, GivenFeaturesData, @@ -111,6 +112,7 @@ const getFixtures = async () => { const scenarioId = v4(); const projectId = v4(); const organizationId = v4(); + const userId = v4(); const geoEntityManager = sandbox.get(getEntityManagerToken()); const apiEntityManager = sandbox.get( @@ -135,6 +137,7 @@ const getFixtures = async () => { return { cleanUp: async () => { + await DeleteFeatures(apiEntityManager, featureIds); await DeleteProjectAndOrganization( apiEntityManager, projectId, @@ -165,6 +168,7 @@ const getFixtures = async () => { piece: ClonePiece.ScenarioFeaturesData, resourceKind, uris: [uri.toSnapshot()], + ownerId: userId, }; }, GivenJobInputWithoutUris: (): ImportJobInput => { @@ -176,6 +180,7 @@ const getFixtures = async () => { piece: ClonePiece.ScenarioFeaturesData, resourceKind, uris: [], + ownerId: userId, }; }, GivenNoScenarioFeaturesDataFileIsAvailable: () => { diff --git a/api/apps/geoprocessing/test/integration/clonning/scenario-metadata.piece-importer.e2e-spec.ts b/api/apps/geoprocessing/test/integration/clonning/scenario-metadata.piece-importer.e2e-spec.ts index b53c23ded5..ed050cf190 100644 --- a/api/apps/geoprocessing/test/integration/clonning/scenario-metadata.piece-importer.e2e-spec.ts +++ b/api/apps/geoprocessing/test/integration/clonning/scenario-metadata.piece-importer.e2e-spec.ts @@ -18,6 +18,7 @@ import { v4 } from 'uuid'; import { DeleteProjectAndOrganization, GivenProjectExists, + GivenUserExists, PrepareZipFile, } from './fixtures'; @@ -56,6 +57,7 @@ describe(ScenarioMetadataPieceImporter, () => { it('imports scenario metadata', async () => { await fixtures.GivenProject(); + await fixtures.GivenUser(); const archiveLocation = await fixtures.GivenValidScenarioMetadataFile(); const input = fixtures.GivenJobInput(archiveLocation); await fixtures @@ -86,6 +88,7 @@ const getFixtures = async () => { const organizationId = v4(); const resourceKind = ResourceKind.Project; const oldScenarioId = v4(); + const userId = v4(); const sut = sandbox.get(ScenarioMetadataPieceImporter); const fileRepository = sandbox.get(FileRepository); @@ -109,6 +112,7 @@ const getFixtures = async () => { organizationId, ); }, + GivenUser: () => GivenUserExists(entityManager, userId, projectId), GivenProject: () => { return GivenProjectExists(entityManager, projectId, organizationId); }, @@ -128,6 +132,7 @@ const getFixtures = async () => { piece: ClonePiece.ScenarioMetadata, resourceKind, uris: [uri.toSnapshot()], + ownerId: userId, }; }, GivenJobInputWithoutUris: (): ImportJobInput => { @@ -139,6 +144,7 @@ const getFixtures = async () => { piece: ClonePiece.ScenarioMetadata, resourceKind, uris: [], + ownerId: userId, }; }, GivenNoScenarioMetadataFileIsAvailable: () => { diff --git a/api/apps/geoprocessing/test/integration/clonning/scenario-planning-units-data.piece-importer.e2e-spec.ts b/api/apps/geoprocessing/test/integration/clonning/scenario-planning-units-data.piece-importer.e2e-spec.ts index 5f3982e6b4..94ca51e44b 100644 --- a/api/apps/geoprocessing/test/integration/clonning/scenario-planning-units-data.piece-importer.e2e-spec.ts +++ b/api/apps/geoprocessing/test/integration/clonning/scenario-planning-units-data.piece-importer.e2e-spec.ts @@ -92,6 +92,7 @@ const getFixtures = async () => { const projectId = v4(); const resourceKind = ResourceKind.Project; const oldScenarioId = v4(); + const userId = v4(); const sut = sandbox.get(ScenarioPlanningUnitsDataPieceImporter); const fileRepository = sandbox.get(FileRepository); @@ -149,6 +150,7 @@ const getFixtures = async () => { piece: ClonePiece.ScenarioPlanningUnitsData, resourceKind, uris: [uri.toSnapshot()], + ownerId: userId, }; }, GivenJobInputWithoutUris: (): ImportJobInput => { @@ -160,6 +162,7 @@ const getFixtures = async () => { piece: ClonePiece.ScenarioPlanningUnitsData, resourceKind, uris: [], + ownerId: userId, }; }, GivenNoScenarioPlanningUnitsDataFileIsAvailable: () => { diff --git a/api/apps/geoprocessing/test/integration/clonning/scenario-protected-areas.piece-importer.e2e-spec.ts b/api/apps/geoprocessing/test/integration/clonning/scenario-protected-areas.piece-importer.e2e-spec.ts index a2e6182687..ab022fc9f0 100644 --- a/api/apps/geoprocessing/test/integration/clonning/scenario-protected-areas.piece-importer.e2e-spec.ts +++ b/api/apps/geoprocessing/test/integration/clonning/scenario-protected-areas.piece-importer.e2e-spec.ts @@ -125,6 +125,7 @@ const getFixtures = async () => { const organizationId = v4(); const resourceKind = ResourceKind.Project; const oldScenarioId = v4(); + const userId = v4(); const sut = sandbox.get(ScenarioProtectedAreasPieceImporter); const fileRepository = sandbox.get(FileRepository); @@ -204,6 +205,7 @@ const getFixtures = async () => { piece: ClonePiece.ScenarioProtectedAreas, resourceKind, uris: [uri.toSnapshot()], + ownerId: userId, }; }, GivenJobInputWithoutUris: (): ImportJobInput => { @@ -215,6 +217,7 @@ const getFixtures = async () => { piece: ClonePiece.ScenarioProtectedAreas, resourceKind, uris: [], + ownerId: userId, }; }, GivenNoScenarioProtectedAreasFileIsAvailable: () => { diff --git a/api/apps/geoprocessing/test/integration/clonning/scenario-run-results.piece-importer.e2e-spec.ts b/api/apps/geoprocessing/test/integration/clonning/scenario-run-results.piece-importer.e2e-spec.ts index 2477e2a6bf..e10cf27000 100644 --- a/api/apps/geoprocessing/test/integration/clonning/scenario-run-results.piece-importer.e2e-spec.ts +++ b/api/apps/geoprocessing/test/integration/clonning/scenario-run-results.piece-importer.e2e-spec.ts @@ -105,6 +105,7 @@ const getFixtures = async () => { const projectId = v4(); const resourceKind = ResourceKind.Project; const oldScenarioId = v4(); + const userId = v4(); const sut = sandbox.get(ScenarioRunResultsPieceImporter); const fileRepository = sandbox.get(FileRepository); @@ -172,6 +173,7 @@ const getFixtures = async () => { piece: ClonePiece.ScenarioRunResults, resourceKind, uris: [uri.toSnapshot()], + ownerId: userId, }; }, GivenJobInputWithoutUris: (): ImportJobInput => { @@ -183,6 +185,7 @@ const getFixtures = async () => { piece: ClonePiece.ScenarioRunResults, resourceKind, uris: [], + ownerId: userId, }; }, GivenNoScenarioRunResultsFileIsAvailable: () => { diff --git a/api/apps/geoprocessing/test/jest-e2e.json b/api/apps/geoprocessing/test/jest-e2e.json index c4b7e5c29c..fcd7388007 100644 --- a/api/apps/geoprocessing/test/jest-e2e.json +++ b/api/apps/geoprocessing/test/jest-e2e.json @@ -74,6 +74,8 @@ "@marxan/geofeatures/(.*)": "/../../libs/geofeatures/src/$1", "@marxan/geofeatures": "/../../libs/geofeatures/src", "@marxan/webshot/(.*)": "../../libs/webshot/src/$1", - "@marxan/webshot": "../../libs/webshot/src" + "@marxan/webshot": "../../libs/webshot/src", + "@marxan/domain-ids/(.*)": "../../libs/domain-ids/src/$1", + "@marxan/domain-ids": "../../libs/domain-ids/src" } } diff --git a/api/libs/cloning/src/job-input.ts b/api/libs/cloning/src/job-input.ts index ebc171f98b..31af7ac554 100644 --- a/api/libs/cloning/src/job-input.ts +++ b/api/libs/cloning/src/job-input.ts @@ -15,6 +15,7 @@ export interface ImportJobInput { readonly componentId: string; readonly pieceResourceId: string; readonly projectId: string; + readonly ownerId: string; readonly resourceKind: ResourceKind; readonly piece: ClonePiece; readonly uris: ComponentLocationSnapshot[]; diff --git a/api/libs/domain-ids/src/index.ts b/api/libs/domain-ids/src/index.ts new file mode 100644 index 0000000000..351693dbac --- /dev/null +++ b/api/libs/domain-ids/src/index.ts @@ -0,0 +1 @@ +export { UserId } from './user-id'; diff --git a/api/libs/domain-ids/src/user-id.ts b/api/libs/domain-ids/src/user-id.ts new file mode 100644 index 0000000000..7c73671aa0 --- /dev/null +++ b/api/libs/domain-ids/src/user-id.ts @@ -0,0 +1,14 @@ +import { v4, version, validate } from 'uuid'; + +export class UserId { + private readonly _token = 'user-id'; + constructor(public readonly value: string) { + if (!validate(value) || version(value) !== 4) { + throw new Error(); + } + } + + static create(): UserId { + return new UserId(v4()); + } +} diff --git a/api/libs/domain-ids/tsconfig.lib.json b/api/libs/domain-ids/tsconfig.lib.json new file mode 100644 index 0000000000..5fa1cabb53 --- /dev/null +++ b/api/libs/domain-ids/tsconfig.lib.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "declaration": true, + "outDir": "../../dist/libs/domain-ids" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "test", "**/*spec.ts"] +} diff --git a/api/package.json b/api/package.json index c1cdbe9be7..4b19bb6294 100644 --- a/api/package.json +++ b/api/package.json @@ -238,7 +238,9 @@ "@marxan/webshot/(.*)": "/libs/webshot/src/$1", "@marxan/webshot": "/libs/webshot/src", "@marxan/geofeatures/(.*)": "/libs/geofeatures/src/$1", - "@marxan/geofeatures": "/libs/geofeatures/src" + "@marxan/geofeatures": "/libs/geofeatures/src", + "@marxan/domain-ids/(.*)": "/libs/domain-ids/src/$1", + "@marxan/domain-ids": "/libs/domain-ids/src" } } } diff --git a/api/tsconfig.json b/api/tsconfig.json index bdca9a7d31..4addd9a907 100644 --- a/api/tsconfig.json +++ b/api/tsconfig.json @@ -180,6 +180,12 @@ ], "@marxan/geofeatures/*": [ "libs/geofeatures/src/*" + ], + "@marxan/domain-ids": [ + "libs/domain-ids/src" + ], + "@marxan/domain-ids/*": [ + "libs/domain-ids/src/*" ] } },