diff --git a/apps/api/src/app/app.module.ts b/apps/api/src/app/app.module.ts index 75e780cc..141380e4 100644 --- a/apps/api/src/app/app.module.ts +++ b/apps/api/src/app/app.module.ts @@ -8,6 +8,7 @@ import { MongooseModule } from '@nestjs/mongoose'; import * as path from 'path'; import { AuthModule } from '@kordis/api/auth'; +import { DeploymentModule } from '@kordis/api/deployment'; import { ObservabilityModule } from '@kordis/api/observability'; import { OrganizationModule } from '@kordis/api/organization'; import { @@ -36,6 +37,7 @@ const FEATURE_MODULES = [ UsersModule.forRoot(process.env.AUTH_PROVIDER === 'dev' ? 'dev' : 'aadb2c'), UnitModule, TetraModule, + DeploymentModule, ]; const SAGA_MODULES = [UnitsSagaModule]; const UTILITY_MODULES = [ diff --git a/libs/api/deployment/.eslintrc.json b/libs/api/deployment/.eslintrc.json new file mode 100644 index 00000000..79fd7c1d --- /dev/null +++ b/libs/api/deployment/.eslintrc.json @@ -0,0 +1,18 @@ +{ + "extends": ["../../../.eslintrc.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], + "rules": {} + }, + { + "files": ["*.ts", "*.tsx"], + "rules": {} + }, + { + "files": ["*.js", "*.jsx"], + "rules": {} + } + ] +} diff --git a/libs/api/deployment/README.md b/libs/api/deployment/README.md new file mode 100644 index 00000000..605d3280 --- /dev/null +++ b/libs/api/deployment/README.md @@ -0,0 +1,14 @@ +# API Deployment + +Currently we have two types of Deplyoments: a _Rescue Station_ and an +_Operation_. The Rescue Station data holder is this domain, as a Rescue Station +only lives in this domain and has no further purpose, whereas the Operation is a +unique domain. Rescue Stations are managed by the Rescue Station Manager, which +also throw events regarding the rescue stations, as signing in, signing off and +updating contains cross boundary process logic. The commands are exported and +can be called by the managers, as they act as an API. + +## Running unit tests + +Run `nx test api-deployment` to execute the unit tests via +[Jest](https://jestjs.io). diff --git a/libs/api/deployment/jest.config.ts b/libs/api/deployment/jest.config.ts new file mode 100644 index 00000000..b8caea1d --- /dev/null +++ b/libs/api/deployment/jest.config.ts @@ -0,0 +1,11 @@ +/* eslint-disable */ +export default { + displayName: 'api-deployment', + preset: '../../../jest.preset.js', + testEnvironment: 'node', + transform: { + '^.+\\.[tj]s$': ['ts-jest', { tsconfig: '/tsconfig.spec.json' }], + }, + moduleFileExtensions: ['ts', 'js', 'html'], + coverageDirectory: '../../../coverage/libs/api/deployment', +}; diff --git a/libs/api/deployment/project.json b/libs/api/deployment/project.json new file mode 100644 index 00000000..f5d7fe79 --- /dev/null +++ b/libs/api/deployment/project.json @@ -0,0 +1,20 @@ +{ + "name": "api-deployment", + "$schema": "../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/api/deployment/src", + "projectType": "library", + "targets": { + "lint": { + "executor": "@nx/eslint:lint", + "outputs": ["{options.outputFile}"] + }, + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "libs/api/deployment/jest.config.ts" + } + } + }, + "tags": [] +} diff --git a/libs/api/deployment/src/index.ts b/libs/api/deployment/src/index.ts new file mode 100644 index 00000000..9bd4a04d --- /dev/null +++ b/libs/api/deployment/src/index.ts @@ -0,0 +1,8 @@ +export * from './lib/infra/deployment.module'; +export * from './lib/core/exception/deployment-not-found.exception'; +export * from './lib/infra/rescue-station.view-model'; +export * from './lib/core/query/get-rescue-station-deployment.query'; +export * from './lib/infra/rescue-station.view-model'; +export * from './lib/core/command/sign-in-rescue-station.command'; +export * from './lib/core/command/sign-off-rescue-station.command'; +export * from './lib/core/command/update-signed-in-rescue-station.command'; diff --git a/libs/api/deployment/src/lib/core/command/sign-in-rescue-station.command.spec.ts b/libs/api/deployment/src/lib/core/command/sign-in-rescue-station.command.spec.ts new file mode 100644 index 00000000..b23e91fd --- /dev/null +++ b/libs/api/deployment/src/lib/core/command/sign-in-rescue-station.command.spec.ts @@ -0,0 +1,98 @@ +import { createMock } from '@golevelup/ts-jest'; +import { Test, TestingModule } from '@nestjs/testing'; + +import { ValidationException } from '@kordis/api/shared'; +import { uowMockProvider } from '@kordis/api/test-helpers'; + +import { + RESCUE_STATION_DEPLOYMENT_REPOSITORY, + RescueStationDeploymentRepository, +} from '../repository/rescue-station-deployment.repository'; +import { DeploymentAssignmentService } from '../service/deployment-assignment.service'; +import { StrengthFromCommandFactory } from '../service/strength-from-command.factory'; +import { + SignInRescueStationCommand, + SignInRescueStationHandler, +} from './sign-in-rescue-station.command'; + +describe('SignInRescueStationHandler', () => { + let handler: SignInRescueStationHandler; + const mockRescueStationDeploymentRepository = + createMock(); + const mockDeploymentAssignmentService = + createMock(); + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + SignInRescueStationHandler, + StrengthFromCommandFactory, + { + provide: RESCUE_STATION_DEPLOYMENT_REPOSITORY, + useValue: mockRescueStationDeploymentRepository, + }, + { + provide: DeploymentAssignmentService, + useValue: mockDeploymentAssignmentService, + }, + uowMockProvider(), + ], + }).compile(); + + handler = module.get( + SignInRescueStationHandler, + ); + }); + + it('should call assignment and update', async () => { + const orgId = 'orgId'; + const rescueStationId = 'rescueStationId'; + const strength = { + leaders: 1, + subLeaders: 1, + helpers: 1, + }; + const note = 'note'; + const assignedUnitIds = ['unitId']; + const assignedAlertGroups = [ + { alertGroupId: 'alertGroupId', unitIds: ['unitId'] }, + ]; + const command = new SignInRescueStationCommand( + orgId, + rescueStationId, + strength, + note, + assignedUnitIds, + assignedAlertGroups, + ); + await handler.execute(command); + + expect( + mockDeploymentAssignmentService.setAssignmentsOfDeployment, + ).toHaveBeenCalledWith( + orgId, + rescueStationId, + assignedUnitIds, + assignedAlertGroups, + expect.anything(), + ); + expect(mockRescueStationDeploymentRepository.updateOne).toHaveBeenCalled(); + }); + + it('should throw validation exception when strength is invalid', async () => { + const command = new SignInRescueStationCommand( + 'orgId', + 'rescueStationId', + { + leaders: -1, + subLeaders: 1, + helpers: 1, + }, + 'note', + ['unitId'], + [{ alertGroupId: 'alertGroupId', unitIds: ['unitId'] }], + ); + + await expect(handler.execute(command)).rejects.toThrow(ValidationException); + }); +}); diff --git a/libs/api/deployment/src/lib/core/command/sign-in-rescue-station.command.ts b/libs/api/deployment/src/lib/core/command/sign-in-rescue-station.command.ts new file mode 100644 index 00000000..6a3ebb27 --- /dev/null +++ b/libs/api/deployment/src/lib/core/command/sign-in-rescue-station.command.ts @@ -0,0 +1,69 @@ +import { Inject } from '@nestjs/common'; +import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; + +import { UNIT_OF_WORK_SERVICE, UnitOfWorkService } from '@kordis/api/shared'; + +import { + RESCUE_STATION_DEPLOYMENT_REPOSITORY, + RescueStationDeploymentRepository, +} from '../repository/rescue-station-deployment.repository'; +import { DeploymentAssignmentService } from '../service/deployment-assignment.service'; +import { StrengthFromCommandFactory } from '../service/strength-from-command.factory'; + +export class SignInRescueStationCommand { + constructor( + readonly orgId: string, + readonly rescueStationId: string, + readonly strength: { + readonly leaders: number; + readonly subLeaders: number; + readonly helpers: number; + }, + readonly note: string, + readonly assignedUnitIds: string[], + readonly assignedAlertGroups: { + readonly alertGroupId: string; + readonly unitIds: string[]; + }[], + ) {} +} + +@CommandHandler(SignInRescueStationCommand) +export class SignInRescueStationHandler + implements ICommandHandler +{ + constructor( + @Inject(RESCUE_STATION_DEPLOYMENT_REPOSITORY) + private readonly rescueStationDeploymentRepository: RescueStationDeploymentRepository, + private readonly deploymentAssignmentService: DeploymentAssignmentService, + private readonly strengthFactory: StrengthFromCommandFactory, + @Inject(UNIT_OF_WORK_SERVICE) + private readonly uow: UnitOfWorkService, + ) {} + + async execute(command: SignInRescueStationCommand): Promise { + await this.uow.asTransaction(async (uow) => { + await this.deploymentAssignmentService.setAssignmentsOfDeployment( + command.orgId, + command.rescueStationId, + command.assignedUnitIds, + command.assignedAlertGroups, + uow, + ); + + const strength = this.strengthFactory.create(command.strength); + await strength.validOrThrow(); + + await this.rescueStationDeploymentRepository.updateOne( + command.orgId, + command.rescueStationId, + { + signedIn: true, + note: command.note, + strength: strength, + }, + uow, + ); + }, 3); + } +} diff --git a/libs/api/deployment/src/lib/core/command/sign-off-rescue-station.command.spec.ts b/libs/api/deployment/src/lib/core/command/sign-off-rescue-station.command.spec.ts new file mode 100644 index 00000000..a3e3d28e --- /dev/null +++ b/libs/api/deployment/src/lib/core/command/sign-off-rescue-station.command.spec.ts @@ -0,0 +1,73 @@ +import { createMock } from '@golevelup/ts-jest'; +import { Test, TestingModule } from '@nestjs/testing'; + +import { uowMockProvider } from '@kordis/api/test-helpers'; + +import { + DEPLOYMENT_ASSIGNMENT_REPOSITORY, + DeploymentAssignmentRepository, +} from '../repository/deployment-assignment.repository'; +import { + RESCUE_STATION_DEPLOYMENT_REPOSITORY, + RescueStationDeploymentRepository, +} from '../repository/rescue-station-deployment.repository'; +import { + SignOffRescueStationCommand, + SignOffRescueStationHandler, +} from './sign-off-rescue-station.command'; + +describe('SignOffRescueStationHandler', () => { + let handler: SignOffRescueStationHandler; + const mockRescueStationDeploymentRepository = + createMock(); + const mockDeploymentAssignmentRepository = + createMock(); + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + SignOffRescueStationHandler, + { + provide: RESCUE_STATION_DEPLOYMENT_REPOSITORY, + useValue: mockRescueStationDeploymentRepository, + }, + { + provide: DEPLOYMENT_ASSIGNMENT_REPOSITORY, + useValue: mockDeploymentAssignmentRepository, + }, + uowMockProvider(), + ], + }).compile(); + + handler = module.get( + SignOffRescueStationHandler, + ); + }); + + it('should call specific methods when command is executed', async () => { + const orgId = 'orgId'; + const rescueStationId = 'rescueStationId'; + const command = new SignOffRescueStationCommand(orgId, rescueStationId); + await handler.execute(command); + + expect( + mockDeploymentAssignmentRepository.removeAssignmentsOfDeployment, + ).toHaveBeenCalledWith(orgId, rescueStationId, expect.anything()); + expect( + mockRescueStationDeploymentRepository.updateOne, + ).toHaveBeenCalledWith( + orgId, + rescueStationId, + { + signedIn: false, + note: '', + strength: { + helpers: 0, + leaders: 0, + subLeaders: 0, + }, + }, + expect.anything(), + ); + }); +}); diff --git a/libs/api/deployment/src/lib/core/command/sign-off-rescue-station.command.ts b/libs/api/deployment/src/lib/core/command/sign-off-rescue-station.command.ts new file mode 100644 index 00000000..72f7a6b0 --- /dev/null +++ b/libs/api/deployment/src/lib/core/command/sign-off-rescue-station.command.ts @@ -0,0 +1,59 @@ +import { Inject } from '@nestjs/common'; +import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; + +import { UNIT_OF_WORK_SERVICE, UnitOfWorkService } from '@kordis/api/shared'; + +import { RescueStationStrength } from '../entity/rescue-station-deployment.entity'; +import { + DEPLOYMENT_ASSIGNMENT_REPOSITORY, + DeploymentAssignmentRepository, +} from '../repository/deployment-assignment.repository'; +import { + RESCUE_STATION_DEPLOYMENT_REPOSITORY, + RescueStationDeploymentRepository, +} from '../repository/rescue-station-deployment.repository'; + +export class SignOffRescueStationCommand { + constructor( + readonly orgId: string, + readonly rescueStationId: string, + ) {} +} + +@CommandHandler(SignOffRescueStationCommand) +export class SignOffRescueStationHandler + implements ICommandHandler +{ + constructor( + @Inject(RESCUE_STATION_DEPLOYMENT_REPOSITORY) + private readonly rescueStationDeploymentRepository: RescueStationDeploymentRepository, + @Inject(DEPLOYMENT_ASSIGNMENT_REPOSITORY) + private readonly deploymentAssignmentRepository: DeploymentAssignmentRepository, + @Inject(UNIT_OF_WORK_SERVICE) + private readonly uow: UnitOfWorkService, + ) {} + + async execute({ + orgId, + rescueStationId, + }: SignOffRescueStationCommand): Promise { + await this.uow.asTransaction(async (uow) => { + await this.deploymentAssignmentRepository.removeAssignmentsOfDeployment( + orgId, + rescueStationId, + uow, + ); + + await this.rescueStationDeploymentRepository.updateOne( + orgId, + rescueStationId, + { + signedIn: false, + note: '', + strength: RescueStationStrength.makeDefault(), + }, + uow, + ); + }); + } +} diff --git a/libs/api/deployment/src/lib/core/command/update-signed-in-rescue-station.command.spec.ts b/libs/api/deployment/src/lib/core/command/update-signed-in-rescue-station.command.spec.ts new file mode 100644 index 00000000..96eebd1b --- /dev/null +++ b/libs/api/deployment/src/lib/core/command/update-signed-in-rescue-station.command.spec.ts @@ -0,0 +1,91 @@ +import { createMock } from '@golevelup/ts-jest'; +import { Test, TestingModule } from '@nestjs/testing'; + +import { uowMockProvider } from '@kordis/api/test-helpers'; + +import { + RESCUE_STATION_DEPLOYMENT_REPOSITORY, + RescueStationDeploymentRepository, +} from '../repository/rescue-station-deployment.repository'; +import { DeploymentAssignmentService } from '../service/deployment-assignment.service'; +import { StrengthFromCommandFactory } from '../service/strength-from-command.factory'; +import { + UpdateSignedInRescueStationCommand, + UpdateSignedInRescueStationHandler, +} from './update-signed-in-rescue-station.command'; + +describe('UpdateSignedInRescueStationHandler', () => { + let handler: UpdateSignedInRescueStationHandler; + const mockRescueStationDeploymentRepository = + createMock(); + const mockDeploymentAssignmentService = + createMock(); + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + UpdateSignedInRescueStationHandler, + StrengthFromCommandFactory, + { + provide: RESCUE_STATION_DEPLOYMENT_REPOSITORY, + useValue: mockRescueStationDeploymentRepository, + }, + { + provide: DeploymentAssignmentService, + useValue: mockDeploymentAssignmentService, + }, + uowMockProvider(), + ], + }).compile(); + + handler = module.get( + UpdateSignedInRescueStationHandler, + ); + }); + + it('should call specific methods when command is executed', async () => { + const orgId = 'orgId'; + const rescueStationId = 'rescueStationId'; + const strength = { + leaders: 1, + subLeaders: 1, + helpers: 1, + }; + const note = 'note'; + const assignedUnitIds = ['unitId']; + const assignedAlertGroups = [ + { alertGroupId: 'alertGroupId', unitIds: ['unitId'] }, + ]; + const command = new UpdateSignedInRescueStationCommand( + orgId, + rescueStationId, + strength, + note, + assignedUnitIds, + assignedAlertGroups, + ); + await handler.execute(command); + + expect( + mockDeploymentAssignmentService.setAssignmentsOfDeployment, + ).toHaveBeenCalledWith( + orgId, + rescueStationId, + assignedUnitIds, + assignedAlertGroups, + expect.anything(), + ); + + expect( + mockRescueStationDeploymentRepository.updateOne, + ).toHaveBeenCalledWith( + orgId, + rescueStationId, + { + note, + strength, + }, + expect.anything(), + ); + }); +}); diff --git a/libs/api/deployment/src/lib/core/command/update-signed-in-rescue-station.command.ts b/libs/api/deployment/src/lib/core/command/update-signed-in-rescue-station.command.ts new file mode 100644 index 00000000..7dc3d7c5 --- /dev/null +++ b/libs/api/deployment/src/lib/core/command/update-signed-in-rescue-station.command.ts @@ -0,0 +1,68 @@ +import { Inject } from '@nestjs/common'; +import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; + +import { UNIT_OF_WORK_SERVICE, UnitOfWorkService } from '@kordis/api/shared'; + +import { + RESCUE_STATION_DEPLOYMENT_REPOSITORY, + RescueStationDeploymentRepository, +} from '../repository/rescue-station-deployment.repository'; +import { DeploymentAssignmentService } from '../service/deployment-assignment.service'; +import { StrengthFromCommandFactory } from '../service/strength-from-command.factory'; + +export class UpdateSignedInRescueStationCommand { + constructor( + readonly orgId: string, + readonly rescueStationId: string, + readonly strength: { + readonly leaders: number; + readonly subLeaders: number; + readonly helpers: number; + }, + readonly note: string, + readonly assignedUnitIds: string[], + readonly assignedAlertGroups: { + readonly alertGroupId: string; + readonly unitIds: string[]; + }[], + ) {} +} + +@CommandHandler(UpdateSignedInRescueStationCommand) +export class UpdateSignedInRescueStationHandler + implements ICommandHandler +{ + constructor( + @Inject(RESCUE_STATION_DEPLOYMENT_REPOSITORY) + private readonly rescueStationDeploymentRepository: RescueStationDeploymentRepository, + private readonly deploymentAssignmentService: DeploymentAssignmentService, + private readonly strengthFactory: StrengthFromCommandFactory, + @Inject(UNIT_OF_WORK_SERVICE) + private readonly uow: UnitOfWorkService, + ) {} + + async execute(command: UpdateSignedInRescueStationCommand): Promise { + await this.uow.asTransaction(async (uow) => { + await this.deploymentAssignmentService.setAssignmentsOfDeployment( + command.orgId, + command.rescueStationId, + command.assignedUnitIds, + command.assignedAlertGroups, + uow, + ); + + const strength = this.strengthFactory.create(command.strength); + await strength.validOrThrow(); + + await this.rescueStationDeploymentRepository.updateOne( + command.orgId, + command.rescueStationId, + { + note: command.note, + strength: strength, + }, + uow, + ); + }); + } +} diff --git a/libs/api/deployment/src/lib/core/entity/deployment.entity.ts b/libs/api/deployment/src/lib/core/entity/deployment.entity.ts new file mode 100644 index 00000000..b1d79c79 --- /dev/null +++ b/libs/api/deployment/src/lib/core/entity/deployment.entity.ts @@ -0,0 +1,38 @@ +import { AutoMap } from '@automapper/classes'; +import { Field, ObjectType } from '@nestjs/graphql'; +import { Type } from 'class-transformer'; +import { IsString, ValidateNested } from 'class-validator'; + +import { BaseEntityModel } from '@kordis/api/shared'; +import { AlertGroupViewModel, UnitViewModel } from '@kordis/api/unit'; + +@ObjectType() +export class DeploymentUnit { + @Field(() => UnitViewModel) + unit: { id: string }; +} + +@ObjectType() +export class DeploymentAlertGroup { + @Field(() => AlertGroupViewModel) + alertGroup: { id: string }; + + @Field(() => [DeploymentUnit]) + assignedUnits: DeploymentUnit[]; +} + +@ObjectType({ isAbstract: true }) +export class BaseDeploymentEntity extends BaseEntityModel { + @Field() + @IsString() + @AutoMap() + name: string; + + @ValidateNested({ each: true }) + @Type(() => DeploymentUnit) + assignedUnits: DeploymentUnit[]; + + @ValidateNested({ each: true }) + @Type(() => DeploymentAlertGroup) + assignedAlertGroups: DeploymentAlertGroup[]; +} diff --git a/libs/api/deployment/src/lib/core/entity/rescue-station-deployment.entity.ts b/libs/api/deployment/src/lib/core/entity/rescue-station-deployment.entity.ts new file mode 100644 index 00000000..ac0273a1 --- /dev/null +++ b/libs/api/deployment/src/lib/core/entity/rescue-station-deployment.entity.ts @@ -0,0 +1,112 @@ +import { AutoMap } from '@automapper/classes'; +import { Field, ObjectType } from '@nestjs/graphql'; +import { Type } from 'class-transformer'; +import { + IsBoolean, + IsInt, + IsNotEmpty, + IsString, + Min, + ValidateNested, +} from 'class-validator'; + +import { Coordinate, Validatable } from '@kordis/api/shared'; +import { UnitViewModel } from '@kordis/api/unit'; + +import { BaseDeploymentEntity } from './deployment.entity'; + +@ObjectType() +export class RescueStationStrength extends Validatable { + @Field() + @IsInt() + @Min(0) + @AutoMap() + leaders: number; + + @Field() + @IsInt() + @Min(0) + @AutoMap() + subLeaders: number; + + @Field() + @IsInt() + @Min(0) + @AutoMap() + helpers: number; + + static makeDefault(): RescueStationStrength { + const strength = new RescueStationStrength(); + strength.leaders = 0; + strength.subLeaders = 0; + strength.helpers = 0; + return strength; + } +} + +@ObjectType() +export class RescueStationAddress { + @Field() + @IsString() + @IsNotEmpty() + @AutoMap() + street: string; + + @Field() + @IsString() + @IsNotEmpty() + @AutoMap() + city: string; + + @Field() + @IsString() + @IsNotEmpty() + @AutoMap() + postalCode: string; +} + +@ObjectType() +export class RescueStationLocation { + @Field(() => Coordinate) + @ValidateNested() + @Type(() => Coordinate) + @AutoMap() + coordinate: Coordinate; + + @Field(() => RescueStationAddress) + @ValidateNested() + @Type(() => RescueStationAddress) + @AutoMap() + address: RescueStationAddress; +} + +@ObjectType({ isAbstract: true }) +export class RescueStationDeploymentEntity extends BaseDeploymentEntity { + @Field(() => RescueStationStrength) + @ValidateNested() + @Type(() => RescueStationStrength) + @AutoMap() + strength: RescueStationStrength; + + @Field() + @IsString() + @AutoMap() + note: string; + + @Field() + @IsBoolean() + @AutoMap() + signedIn: boolean; + + @Field(() => RescueStationLocation) + @ValidateNested() + @Type(() => RescueStationLocation) + @AutoMap() + location: RescueStationLocation; + + @Field(() => [UnitViewModel]) + @ValidateNested({ each: true }) + @Type(() => UnitViewModel) + @AutoMap() + defaultUnits: { id: string }[]; +} diff --git a/libs/api/deployment/src/lib/core/exception/deployment-not-found.exception.ts b/libs/api/deployment/src/lib/core/exception/deployment-not-found.exception.ts new file mode 100644 index 00000000..d67a0b33 --- /dev/null +++ b/libs/api/deployment/src/lib/core/exception/deployment-not-found.exception.ts @@ -0,0 +1,5 @@ +export class DeploymentNotFoundException extends Error { + constructor() { + super('Deployment not found.'); + } +} diff --git a/libs/api/deployment/src/lib/core/query/get-alert-group-by-unit-id.query.spec.ts b/libs/api/deployment/src/lib/core/query/get-alert-group-by-unit-id.query.spec.ts new file mode 100644 index 00000000..0fbb4250 --- /dev/null +++ b/libs/api/deployment/src/lib/core/query/get-alert-group-by-unit-id.query.spec.ts @@ -0,0 +1,43 @@ +import { createMock } from '@golevelup/ts-jest'; +import { Test, TestingModule } from '@nestjs/testing'; + +import { + UNIT_ASSIGNMENT_REPOSITORY, + UnitAssignmentRepository, +} from '../repository/unit-assignment.repository'; +import { + GetAlertGroupByUnitIdHandler, + GetAlertGroupByUnitIdQuery, +} from './get-alert-group-by-unit-id.query'; + +describe('GetAlertGroupByUnitIdHandler', () => { + let handler: GetAlertGroupByUnitIdHandler; + const mockUnitAssignmentRepository = createMock(); + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + GetAlertGroupByUnitIdHandler, + { + provide: UNIT_ASSIGNMENT_REPOSITORY, + useValue: mockUnitAssignmentRepository, + }, + ], + }).compile(); + + handler = module.get( + GetAlertGroupByUnitIdHandler, + ); + }); + + it('should get alert group of unit from repository', async () => { + const orgId = 'orgId'; + const unitId = 'unitId'; + const command = new GetAlertGroupByUnitIdQuery(orgId, unitId); + await handler.execute(command); + + expect( + mockUnitAssignmentRepository.findAlertGroupOfUnit, + ).toHaveBeenCalledWith(orgId, unitId); + }); +}); diff --git a/libs/api/deployment/src/lib/core/query/get-alert-group-by-unit-id.query.ts b/libs/api/deployment/src/lib/core/query/get-alert-group-by-unit-id.query.ts new file mode 100644 index 00000000..6566ee00 --- /dev/null +++ b/libs/api/deployment/src/lib/core/query/get-alert-group-by-unit-id.query.ts @@ -0,0 +1,30 @@ +import { Inject } from '@nestjs/common'; +import { QueryHandler } from '@nestjs/cqrs'; + +import { DeploymentAlertGroup } from '../entity/deployment.entity'; +import { + UNIT_ASSIGNMENT_REPOSITORY, + UnitAssignmentRepository, +} from '../repository/unit-assignment.repository'; + +export class GetAlertGroupByUnitIdQuery { + constructor( + readonly orgId: string, + readonly unitId: string, + ) {} +} + +@QueryHandler(GetAlertGroupByUnitIdQuery) +export class GetAlertGroupByUnitIdHandler { + constructor( + @Inject(UNIT_ASSIGNMENT_REPOSITORY) + private readonly repository: UnitAssignmentRepository, + ) {} + + async execute({ + orgId, + unitId, + }: GetAlertGroupByUnitIdQuery): Promise { + return this.repository.findAlertGroupOfUnit(orgId, unitId); + } +} diff --git a/libs/api/deployment/src/lib/core/query/get-current-assignment-of-entity.query.spec.ts b/libs/api/deployment/src/lib/core/query/get-current-assignment-of-entity.query.spec.ts new file mode 100644 index 00000000..d638c7b8 --- /dev/null +++ b/libs/api/deployment/src/lib/core/query/get-current-assignment-of-entity.query.spec.ts @@ -0,0 +1,53 @@ +import { createMock } from '@golevelup/ts-jest'; +import { Test, TestingModule } from '@nestjs/testing'; + +import { RescueStationDeploymentEntity } from '../entity/rescue-station-deployment.entity'; +import { + DEPLOYMENT_ASSIGNMENT_REPOSITORY, + DeploymentAssignmentRepository, +} from '../repository/deployment-assignment.repository'; +import { + GetCurrentAssignmentOfEntity, + GetUnitAssignmentHandlerHandler, +} from './get-current-assignment-of-entity.query'; + +describe('GetUnitAssignmentHandlerHandler', () => { + let handler: GetUnitAssignmentHandlerHandler; + const mockDeploymentAssignmentRepository = + createMock(); + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + GetUnitAssignmentHandlerHandler, + { + provide: DEPLOYMENT_ASSIGNMENT_REPOSITORY, + useValue: mockDeploymentAssignmentRepository, + }, + ], + }).compile(); + + handler = module.get( + GetUnitAssignmentHandlerHandler, + ); + }); + + it('should get assignment of entity', async () => { + const orgId = 'orgId'; + const entityId = 'entityId'; + const command = new GetCurrentAssignmentOfEntity(orgId, entityId); + + const mockDeployment = new RescueStationDeploymentEntity(); + mockDeployment.note = 'foo'; + mockDeploymentAssignmentRepository.getAssignment.mockResolvedValue( + mockDeployment, + ); + + const result = await handler.execute(command); + + expect( + mockDeploymentAssignmentRepository.getAssignment, + ).toHaveBeenCalledWith(orgId, entityId); + expect(result).toEqual(mockDeployment); + }); +}); diff --git a/libs/api/deployment/src/lib/core/query/get-current-assignment-of-entity.query.ts b/libs/api/deployment/src/lib/core/query/get-current-assignment-of-entity.query.ts new file mode 100644 index 00000000..87624e84 --- /dev/null +++ b/libs/api/deployment/src/lib/core/query/get-current-assignment-of-entity.query.ts @@ -0,0 +1,32 @@ +import { Inject } from '@nestjs/common'; +import { IQueryHandler, QueryHandler } from '@nestjs/cqrs'; + +import { RescueStationDeploymentEntity } from '../entity/rescue-station-deployment.entity'; +import { + DEPLOYMENT_ASSIGNMENT_REPOSITORY, + DeploymentAssignmentRepository, +} from '../repository/deployment-assignment.repository'; + +export class GetCurrentAssignmentOfEntity { + constructor( + readonly orgId: string, + readonly entityId: string, + ) {} +} + +@QueryHandler(GetCurrentAssignmentOfEntity) +export class GetUnitAssignmentHandlerHandler + implements IQueryHandler +{ + constructor( + @Inject(DEPLOYMENT_ASSIGNMENT_REPOSITORY) + private readonly repository: DeploymentAssignmentRepository, + ) {} + + async execute({ + orgId, + entityId, + }: GetCurrentAssignmentOfEntity): Promise { + return this.repository.getAssignment(orgId, entityId); + } +} diff --git a/libs/api/deployment/src/lib/core/query/get-deployments.query.spec.ts b/libs/api/deployment/src/lib/core/query/get-deployments.query.spec.ts new file mode 100644 index 00000000..bf3c2acc --- /dev/null +++ b/libs/api/deployment/src/lib/core/query/get-deployments.query.spec.ts @@ -0,0 +1,54 @@ +import { createMock } from '@golevelup/ts-jest'; +import { Test, TestingModule } from '@nestjs/testing'; + +import { RescueStationDeploymentEntity } from '../entity/rescue-station-deployment.entity'; +import { + RESCUE_STATION_DEPLOYMENT_REPOSITORY, + RescueStationDeploymentRepository, +} from '../repository/rescue-station-deployment.repository'; +import { + GetDeploymentsHandler, + GetDeploymentsQuery, +} from './get-deployments.query'; + +describe('GetDeploymentsHandler', () => { + let handler: GetDeploymentsHandler; + const mockRescueStationDeploymentRepository = + createMock(); + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + GetDeploymentsHandler, + { + provide: RESCUE_STATION_DEPLOYMENT_REPOSITORY, + useValue: mockRescueStationDeploymentRepository, + }, + ], + }).compile(); + + handler = module.get(GetDeploymentsHandler); + }); + + it('should find rescue stations by orgId', async () => { + const orgId = 'orgId'; + const command = new GetDeploymentsQuery(orgId); + + const mockDeployment1 = new RescueStationDeploymentEntity(); + mockDeployment1.note = 'somenote'; + const mockDeployment2 = new RescueStationDeploymentEntity(); + mockDeployment2.note = 'someothernote'; + + mockRescueStationDeploymentRepository.findByOrgId.mockResolvedValue([ + mockDeployment1, + mockDeployment2, + ]); + + const result = await handler.execute(command); + + expect( + mockRescueStationDeploymentRepository.findByOrgId, + ).toHaveBeenCalledWith(orgId, undefined); + expect(result).toEqual([mockDeployment1, mockDeployment2]); + }); +}); diff --git a/libs/api/deployment/src/lib/core/query/get-deployments.query.ts b/libs/api/deployment/src/lib/core/query/get-deployments.query.ts new file mode 100644 index 00000000..4a6b7971 --- /dev/null +++ b/libs/api/deployment/src/lib/core/query/get-deployments.query.ts @@ -0,0 +1,33 @@ +import { Inject } from '@nestjs/common'; +import { IQueryHandler, QueryHandler } from '@nestjs/cqrs'; + +import { RescueStationDeploymentEntity } from '../entity/rescue-station-deployment.entity'; +import { + RESCUE_STATION_DEPLOYMENT_REPOSITORY, + RescueStationDeploymentRepository, + RescueStationEntityDTO, +} from '../repository/rescue-station-deployment.repository'; + +export class GetDeploymentsQuery { + constructor( + readonly orgId: string, + readonly filter?: RescueStationEntityDTO, + ) {} +} + +@QueryHandler(GetDeploymentsQuery) +export class GetDeploymentsHandler + implements IQueryHandler +{ + constructor( + @Inject(RESCUE_STATION_DEPLOYMENT_REPOSITORY) + private rescueStationDeploymentRepository: RescueStationDeploymentRepository, + ) {} + + async execute({ + orgId, + filter, + }: GetDeploymentsQuery): Promise { + return this.rescueStationDeploymentRepository.findByOrgId(orgId, filter); + } +} diff --git a/libs/api/deployment/src/lib/core/query/get-rescue-station-deployment.query.spec.ts b/libs/api/deployment/src/lib/core/query/get-rescue-station-deployment.query.spec.ts new file mode 100644 index 00000000..c3b3c305 --- /dev/null +++ b/libs/api/deployment/src/lib/core/query/get-rescue-station-deployment.query.spec.ts @@ -0,0 +1,54 @@ +import { createMock } from '@golevelup/ts-jest'; +import { Test, TestingModule } from '@nestjs/testing'; + +import { RescueStationDeploymentEntity } from '../entity/rescue-station-deployment.entity'; +import { + RESCUE_STATION_DEPLOYMENT_REPOSITORY, + RescueStationDeploymentRepository, +} from '../repository/rescue-station-deployment.repository'; +import { + GetRescueStationDeploymentHandler, + GetRescueStationDeploymentQuery, +} from './get-rescue-station-deployment.query'; + +describe('GetRescueStationDeploymentHandler', () => { + let handler: GetRescueStationDeploymentHandler; + const mockRescueStationDeploymentRepository = + createMock(); + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + GetRescueStationDeploymentHandler, + { + provide: RESCUE_STATION_DEPLOYMENT_REPOSITORY, + useValue: mockRescueStationDeploymentRepository, + }, + ], + }).compile(); + + handler = module.get( + GetRescueStationDeploymentHandler, + ); + }); + + it('should find rescue station deployment by orgId and id', async () => { + const orgId = 'orgId'; + const id = 'id'; + const command = new GetRescueStationDeploymentQuery(orgId, id); + + const mockDeployment = new RescueStationDeploymentEntity(); + mockDeployment.note = 'somenote'; + mockRescueStationDeploymentRepository.findById.mockResolvedValue( + mockDeployment, + ); + + const result = await handler.execute(command); + + expect(mockRescueStationDeploymentRepository.findById).toHaveBeenCalledWith( + orgId, + id, + ); + expect(result).toEqual(mockDeployment); + }); +}); diff --git a/libs/api/deployment/src/lib/core/query/get-rescue-station-deployment.query.ts b/libs/api/deployment/src/lib/core/query/get-rescue-station-deployment.query.ts new file mode 100644 index 00000000..ca70a778 --- /dev/null +++ b/libs/api/deployment/src/lib/core/query/get-rescue-station-deployment.query.ts @@ -0,0 +1,36 @@ +import { Inject } from '@nestjs/common'; +import { IQueryHandler, QueryHandler } from '@nestjs/cqrs'; + +import { RescueStationDeploymentEntity } from '../entity/rescue-station-deployment.entity'; +import { + RESCUE_STATION_DEPLOYMENT_REPOSITORY, + RescueStationDeploymentRepository, +} from '../repository/rescue-station-deployment.repository'; + +export class GetRescueStationDeploymentQuery { + constructor( + readonly orgId: string, + readonly id: string, + ) {} +} + +@QueryHandler(GetRescueStationDeploymentQuery) +export class GetRescueStationDeploymentHandler + implements + IQueryHandler< + GetRescueStationDeploymentQuery, + RescueStationDeploymentEntity + > +{ + constructor( + @Inject(RESCUE_STATION_DEPLOYMENT_REPOSITORY) + private readonly rescueStationDeploymentRepository: RescueStationDeploymentRepository, + ) {} + + async execute({ + orgId, + id, + }: GetRescueStationDeploymentQuery): Promise { + return this.rescueStationDeploymentRepository.findById(orgId, id); + } +} diff --git a/libs/api/deployment/src/lib/core/query/get-unassigned-entities.query.spec.ts b/libs/api/deployment/src/lib/core/query/get-unassigned-entities.query.spec.ts new file mode 100644 index 00000000..1d52538c --- /dev/null +++ b/libs/api/deployment/src/lib/core/query/get-unassigned-entities.query.spec.ts @@ -0,0 +1,58 @@ +import { createMock } from '@golevelup/ts-jest'; +import { Test, TestingModule } from '@nestjs/testing'; + +import { + DeploymentAlertGroup, + DeploymentUnit, +} from '../entity/deployment.entity'; +import { + DEPLOYMENT_ASSIGNMENT_REPOSITORY, + DeploymentAssignmentRepository, +} from '../repository/deployment-assignment.repository'; +import { + GetUnassignedEntitiesHandler, + GetUnassignedEntitiesQuery, +} from './get-unassigned-entities.query'; + +describe('GetUnassignedEntitiesHandler', () => { + let handler: GetUnassignedEntitiesHandler; + const mockDeploymentAssignmentRepository = + createMock(); + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + GetUnassignedEntitiesHandler, + { + provide: DEPLOYMENT_ASSIGNMENT_REPOSITORY, + useValue: mockDeploymentAssignmentRepository, + }, + ], + }).compile(); + + handler = module.get( + GetUnassignedEntitiesHandler, + ); + }); + + it('should find unassigned entities by orgId', async () => { + const orgId = 'orgId'; + const command = new GetUnassignedEntitiesQuery(orgId); + + const mockDeploymentUnit = new DeploymentUnit(); + (mockDeploymentUnit as any).id = 'unitDeploymentId'; + const mockDeploymentAlertGroup = new DeploymentAlertGroup(); + (mockDeploymentAlertGroup as any).id = 'alertGroupDeploymentId'; + mockDeploymentAssignmentRepository.getUnassigned.mockResolvedValue([ + mockDeploymentUnit, + mockDeploymentAlertGroup, + ]); + + const result = await handler.execute(command); + + expect( + mockDeploymentAssignmentRepository.getUnassigned, + ).toHaveBeenCalledWith(orgId); + expect(result).toEqual([mockDeploymentUnit, mockDeploymentAlertGroup]); + }); +}); diff --git a/libs/api/deployment/src/lib/core/query/get-unassigned-entities.query.ts b/libs/api/deployment/src/lib/core/query/get-unassigned-entities.query.ts new file mode 100644 index 00000000..276bd312 --- /dev/null +++ b/libs/api/deployment/src/lib/core/query/get-unassigned-entities.query.ts @@ -0,0 +1,33 @@ +import { Inject } from '@nestjs/common'; +import { IQueryHandler, QueryHandler } from '@nestjs/cqrs'; + +import { + DeploymentAlertGroup, + DeploymentUnit, +} from '../entity/deployment.entity'; +import { + DEPLOYMENT_ASSIGNMENT_REPOSITORY, + DeploymentAssignmentRepository, +} from '../repository/deployment-assignment.repository'; + +export class GetUnassignedEntitiesQuery { + constructor(readonly orgId: string) {} +} + +@QueryHandler(GetUnassignedEntitiesQuery) +export class GetUnassignedEntitiesHandler + implements IQueryHandler +{ + constructor( + @Inject(DEPLOYMENT_ASSIGNMENT_REPOSITORY) + private readonly deploymentAssignmentRepository: DeploymentAssignmentRepository, + ) {} + + execute({ + orgId, + }: GetUnassignedEntitiesQuery): Promise< + (DeploymentUnit | DeploymentAlertGroup)[] + > { + return this.deploymentAssignmentRepository.getUnassigned(orgId); + } +} diff --git a/libs/api/deployment/src/lib/core/repository/deployment-assignment.repository.ts b/libs/api/deployment/src/lib/core/repository/deployment-assignment.repository.ts new file mode 100644 index 00000000..320b1c0f --- /dev/null +++ b/libs/api/deployment/src/lib/core/repository/deployment-assignment.repository.ts @@ -0,0 +1,35 @@ +import { DbSessionProvider } from '@kordis/api/shared'; + +import { + DeploymentAlertGroup, + DeploymentUnit, +} from '../entity/deployment.entity'; +import { RescueStationDeploymentEntity } from '../entity/rescue-station-deployment.entity'; + +export const DEPLOYMENT_ASSIGNMENT_REPOSITORY = Symbol( + 'DEPLOYMENT_ASSIGNMENT_REPOSITORY', +); + +export interface DeploymentAssignmentRepository { + getAssignment( + orgId: string, + entityId: string, + ): Promise; + + removeAssignmentsOfDeployment( + orgId: string, + deploymentId: string, + uow?: DbSessionProvider | undefined, + ): Promise; + + assignEntitiesToDeployment( + orgId: string, + deploymentId: string, + entityIds: string[], + uow?: DbSessionProvider | undefined, + ): Promise; + + getUnassigned( + orgId: string, + ): Promise<(DeploymentUnit | DeploymentAlertGroup)[]>; +} diff --git a/libs/api/deployment/src/lib/core/repository/deployment.repository.ts b/libs/api/deployment/src/lib/core/repository/deployment.repository.ts new file mode 100644 index 00000000..ef70a0b0 --- /dev/null +++ b/libs/api/deployment/src/lib/core/repository/deployment.repository.ts @@ -0,0 +1,19 @@ +import { DbSessionProvider } from '@kordis/api/shared'; + +import { BaseDeploymentEntity } from '../entity/deployment.entity'; + +export interface DeploymentRepository< + T extends BaseDeploymentEntity, + TDto extends Partial, +> { + findById(orgId: string, id: string): Promise; + + findByOrgId(orgId: string, filter?: Partial): Promise; + + updateOne( + orgId: string, + id: string, + data: Partial, + uow?: DbSessionProvider, + ): Promise; +} diff --git a/libs/api/deployment/src/lib/core/repository/rescue-station-deployment.repository.ts b/libs/api/deployment/src/lib/core/repository/rescue-station-deployment.repository.ts new file mode 100644 index 00000000..dbb8e17a --- /dev/null +++ b/libs/api/deployment/src/lib/core/repository/rescue-station-deployment.repository.ts @@ -0,0 +1,30 @@ +import { AutoMap } from '@automapper/classes'; + +import { + RescueStationDeploymentEntity, + RescueStationStrength, +} from '../entity/rescue-station-deployment.entity'; +import { DeploymentRepository } from './deployment.repository'; + +export const RESCUE_STATION_DEPLOYMENT_REPOSITORY = Symbol( + 'RESCUE_STATION_DEPLOYMENT_REPOSITORY', +); + +// this dto is for use in the core layer, as it covers entity fields, that will be mapped to the document dto. +export class RescueStationEntityDTO + implements Partial +{ + @AutoMap() + note: string; + @AutoMap() + strength: RescueStationStrength; + @AutoMap() + signedIn: boolean; + @AutoMap() + defaultUnitIds: string[]; +} + +export type RescueStationDeploymentRepository = DeploymentRepository< + RescueStationDeploymentEntity, + RescueStationEntityDTO +>; diff --git a/libs/api/deployment/src/lib/core/repository/unit-assignment.repository.ts b/libs/api/deployment/src/lib/core/repository/unit-assignment.repository.ts new file mode 100644 index 00000000..ca8adde1 --- /dev/null +++ b/libs/api/deployment/src/lib/core/repository/unit-assignment.repository.ts @@ -0,0 +1,31 @@ +import { DbSessionProvider } from '@kordis/api/shared'; + +import { DeploymentAlertGroup } from '../entity/deployment.entity'; + +export const UNIT_ASSIGNMENT_REPOSITORY = Symbol('UNIT_ASSIGNMENT_REPOSITORY'); + +export interface UnitAssignmentRepository { + removeAlertGroupAssignmentsByAlertGroups( + orgId: string, + alertGroupIds: string[], + uow?: DbSessionProvider, + ): Promise; + + removeAlertGroupAssignmentsFromUnits( + orgId: string, + unitIds: string[], + uow?: DbSessionProvider, + ): Promise; + + setAlertGroupAssignment( + orgId: string, + unitIds: string[], + alertGroupId: string, + uow?: DbSessionProvider | undefined, + ): Promise; + + findAlertGroupOfUnit( + orgId: string, + unitId: string, + ): Promise; +} diff --git a/libs/api/deployment/src/lib/core/service/deployment-assignment.service.spec.ts b/libs/api/deployment/src/lib/core/service/deployment-assignment.service.spec.ts new file mode 100644 index 00000000..31001797 --- /dev/null +++ b/libs/api/deployment/src/lib/core/service/deployment-assignment.service.spec.ts @@ -0,0 +1,94 @@ +import { createMock } from '@golevelup/ts-jest'; +import { Test, TestingModule } from '@nestjs/testing'; + +import { + DEPLOYMENT_ASSIGNMENT_REPOSITORY, + DeploymentAssignmentRepository, +} from '../repository/deployment-assignment.repository'; +import { + UNIT_ASSIGNMENT_REPOSITORY, + UnitAssignmentRepository, +} from '../repository/unit-assignment.repository'; +import { DeploymentAssignmentService } from './deployment-assignment.service'; + +describe('DeploymentAssignmentService', () => { + let service: DeploymentAssignmentService; + const mockDeploymentAssignmentRepository = + createMock(); + const mockUnitAssignmentRepository = createMock(); + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + DeploymentAssignmentService, + { + provide: DEPLOYMENT_ASSIGNMENT_REPOSITORY, + useValue: mockDeploymentAssignmentRepository, + }, + { + provide: UNIT_ASSIGNMENT_REPOSITORY, + useValue: mockUnitAssignmentRepository, + }, + ], + }).compile(); + + service = module.get( + DeploymentAssignmentService, + ); + }); + + it('should set assignments of a deployment', async () => { + const orgId = 'orgId'; + const deploymentId = 'deploymentId'; + const unitIds = ['unitId1', 'unitId2']; + const alertGroups = [ + { alertGroupId: 'alertGroupId1', unitIds: ['unitId3', 'unitId4'] }, + { alertGroupId: 'alertGroupId2', unitIds: ['unitId5', 'unitId6'] }, + ]; + + await service.setAssignmentsOfDeployment( + orgId, + deploymentId, + unitIds, + alertGroups, + ); + + expect( + mockDeploymentAssignmentRepository.removeAssignmentsOfDeployment, + ).toHaveBeenCalledWith(orgId, deploymentId, undefined); + expect( + mockUnitAssignmentRepository.removeAlertGroupAssignmentsByAlertGroups, + ).toHaveBeenCalledWith( + orgId, + ['alertGroupId1', 'alertGroupId2'], + undefined, + ); + expect( + mockUnitAssignmentRepository.removeAlertGroupAssignmentsFromUnits, + ).toHaveBeenCalledWith( + orgId, + ['unitId3', 'unitId4', 'unitId5', 'unitId6', 'unitId1', 'unitId2'], + undefined, + ); + expect( + mockUnitAssignmentRepository.setAlertGroupAssignment, + ).toHaveBeenCalledTimes(2); + expect( + mockDeploymentAssignmentRepository.assignEntitiesToDeployment, + ).toHaveBeenCalledWith( + orgId, + deploymentId, + [ + 'unitId1', + 'unitId2', + 'unitId3', + 'unitId4', + 'unitId5', + 'unitId6', + 'alertGroupId1', + 'alertGroupId2', + ], + undefined, + ); + }); +}); diff --git a/libs/api/deployment/src/lib/core/service/deployment-assignment.service.ts b/libs/api/deployment/src/lib/core/service/deployment-assignment.service.ts new file mode 100644 index 00000000..c7120c50 --- /dev/null +++ b/libs/api/deployment/src/lib/core/service/deployment-assignment.service.ts @@ -0,0 +1,90 @@ +import { Inject, Injectable } from '@nestjs/common'; + +import { DbSessionProvider } from '@kordis/api/shared'; + +import { + DEPLOYMENT_ASSIGNMENT_REPOSITORY, + DeploymentAssignmentRepository, +} from '../repository/deployment-assignment.repository'; +import { + UNIT_ASSIGNMENT_REPOSITORY, + UnitAssignmentRepository, +} from '../repository/unit-assignment.repository'; + +@Injectable() +export class DeploymentAssignmentService { + constructor( + @Inject(DEPLOYMENT_ASSIGNMENT_REPOSITORY) + private readonly deploymentAssignmentRepository: DeploymentAssignmentRepository, + @Inject(UNIT_ASSIGNMENT_REPOSITORY) + private readonly unitAssignmentRepository: UnitAssignmentRepository, + ) {} + + /** + * Sets the assignments of a deployment by completely removing all previous assignments and assigning the new units and alert groups. + * Keeps units of alert groups that are not assigned to the deployment in their old deployment but without the alert group assignment. + * @param orgId The organization id. + * @param deploymentId The deployment id to assign the units and alert groups to. + * @param unitIds The unit ids to assign to the deployment. + * @param alertGroups The alert groups with their respective unit ids to assign to the deployment. + * @param uow An optional unit of work. + */ + async setAssignmentsOfDeployment( + orgId: string, + deploymentId: string, + unitIds: string[], + alertGroups: { + alertGroupId: string; + unitIds: string[]; + }[], + uow?: DbSessionProvider | undefined, + ): Promise { + const alertGroupIds: string[] = []; // alert group ids to assign + const alertGroupUnitIds: string[][] = []; // alert group unit ids to assign, 1:1 index mapping with alertGroupIds + const flatAlertGroupUnitIds: string[] = []; // a flat list of all unit ids of all alert groups + for (const alertGroup of alertGroups) { + alertGroupIds.push(alertGroup.alertGroupId); + alertGroupUnitIds.push(alertGroup.unitIds); + flatAlertGroupUnitIds.push(...alertGroup.unitIds); + } + + // first remove all assignments + await this.deploymentAssignmentRepository.removeAssignmentsOfDeployment( + orgId, + deploymentId, + uow, + ); + + // remove all alert group assignments of the units, example: alertgroup A is assigned away from an deployment, but leaves behind units, these need to be unassigned from the alert group + await this.unitAssignmentRepository.removeAlertGroupAssignmentsByAlertGroups( + orgId, + alertGroupIds, + uow, + ); + + // all newly assigned units (those that are part of an alert group or directly assigned) need to be unassigned from all previous alert groups + await this.unitAssignmentRepository.removeAlertGroupAssignmentsFromUnits( + orgId, + [...flatAlertGroupUnitIds, ...unitIds], + uow, + ); + + // assign units to alert groups + for (let i = 0; i < alertGroupIds.length; i++) { + await this.unitAssignmentRepository.setAlertGroupAssignment( + orgId, + alertGroupUnitIds[i], + alertGroupIds[i], + uow, + ); + } + + // assign units and alert groups to the deployment + await this.deploymentAssignmentRepository.assignEntitiesToDeployment( + orgId, + deploymentId, + [...unitIds, ...flatAlertGroupUnitIds, ...alertGroupIds], + uow, + ); + } +} diff --git a/libs/api/deployment/src/lib/core/service/strength-from-command.factory.spec.ts b/libs/api/deployment/src/lib/core/service/strength-from-command.factory.spec.ts new file mode 100644 index 00000000..948a63fd --- /dev/null +++ b/libs/api/deployment/src/lib/core/service/strength-from-command.factory.spec.ts @@ -0,0 +1,25 @@ +import { RescueStationStrength } from '../entity/rescue-station-deployment.entity'; +import { StrengthFromCommandFactory } from './strength-from-command.factory'; + +describe('StrengthFromCommandFactory', () => { + let factory: StrengthFromCommandFactory; + + beforeEach(() => { + factory = new StrengthFromCommandFactory(); + }); + + it('should create a RescueStationStrength from command', () => { + const command = { + leaders: 1, + subLeaders: 2, + helpers: 3, + }; + + const strength = factory.create(command); + + expect(strength).toBeInstanceOf(RescueStationStrength); + expect(strength.leaders).toEqual(command.leaders); + expect(strength.subLeaders).toEqual(command.subLeaders); + expect(strength.helpers).toEqual(command.helpers); + }); +}); diff --git a/libs/api/deployment/src/lib/core/service/strength-from-command.factory.ts b/libs/api/deployment/src/lib/core/service/strength-from-command.factory.ts new file mode 100644 index 00000000..6e66197b --- /dev/null +++ b/libs/api/deployment/src/lib/core/service/strength-from-command.factory.ts @@ -0,0 +1,18 @@ +import { Injectable } from '@nestjs/common'; + +import { RescueStationStrength } from '../entity/rescue-station-deployment.entity'; + +@Injectable() +export class StrengthFromCommandFactory { + create(strength: { + leaders: number; + subLeaders: number; + helpers: number; + }): RescueStationStrength { + const strengthValueObject = new RescueStationStrength(); + strengthValueObject.leaders = strength.leaders; + strengthValueObject.subLeaders = strength.subLeaders; + strengthValueObject.helpers = strength.helpers; + return strengthValueObject; + } +} diff --git a/libs/api/deployment/src/lib/infra/controller/deployment-assignment.resolver.spec.ts b/libs/api/deployment/src/lib/infra/controller/deployment-assignment.resolver.spec.ts new file mode 100644 index 00000000..e668efdc --- /dev/null +++ b/libs/api/deployment/src/lib/infra/controller/deployment-assignment.resolver.spec.ts @@ -0,0 +1,108 @@ +import { createMock } from '@golevelup/ts-jest'; +import { QueryBus } from '@nestjs/cqrs'; +import { Test, TestingModule } from '@nestjs/testing'; + +import { AlertGroupViewModel, UnitViewModel } from '@kordis/api/unit'; +import { AuthUser } from '@kordis/shared/model'; + +import { GetAlertGroupByUnitIdQuery } from '../../core/query/get-alert-group-by-unit-id.query'; +import { GetCurrentAssignmentOfEntity } from '../../core/query/get-current-assignment-of-entity.query'; +import { + AlertGroupAssignmentResolver, + EntityRescueStationAssignment, + UnitAssignmentResolver, +} from './deployment-assignment.resolver'; + +describe('UnitAssignmentResolver', () => { + let resolver: UnitAssignmentResolver; + const mockQueryBus = createMock(); + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + UnitAssignmentResolver, + { provide: QueryBus, useValue: mockQueryBus }, + ], + }).compile(); + + resolver = module.get(UnitAssignmentResolver); + }); + + it('should get assignment', async () => { + const orgId = 'orgId'; + const unitId = 'unitId'; + const mockEntityRescueStationAssignment = + new EntityRescueStationAssignment(); + mockEntityRescueStationAssignment.note = 'somenote'; + mockQueryBus.execute.mockResolvedValueOnce( + mockEntityRescueStationAssignment, + ); + + const result = await resolver.assignment( + { organizationId: orgId } as AuthUser, + { id: unitId } as UnitViewModel, + ); + + expect(mockQueryBus.execute).toHaveBeenCalledWith( + new GetCurrentAssignmentOfEntity(orgId, unitId), + ); + expect(result).toEqual(mockEntityRescueStationAssignment); + }); + + it('should get alert group', async () => { + const orgId = 'orgId'; + const unitId = 'unitId'; + const mockAlertGroupViewModel = new AlertGroupViewModel(); + mockAlertGroupViewModel.name = 'somename'; + mockQueryBus.execute.mockResolvedValueOnce(mockAlertGroupViewModel); + + const result = await resolver.alertGroup( + { organizationId: orgId } as AuthUser, + { id: unitId } as UnitViewModel, + ); + + expect(mockQueryBus.execute).toHaveBeenCalledWith( + new GetAlertGroupByUnitIdQuery(orgId, unitId), + ); + expect(result).toEqual(mockAlertGroupViewModel); + }); +}); + +describe('AlertGroupAssignmentResolver', () => { + let resolver: AlertGroupAssignmentResolver; + const mockQueryBus = createMock(); + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AlertGroupAssignmentResolver, + { provide: QueryBus, useValue: mockQueryBus }, + ], + }).compile(); + + resolver = module.get( + AlertGroupAssignmentResolver, + ); + }); + + it('should get assignment', async () => { + const orgId = 'orgId'; + const alertGroupId = 'alertGroupId'; + const mockEntityRescueStationAssignment = + new EntityRescueStationAssignment(); + mockEntityRescueStationAssignment.note = 'somenote'; + mockQueryBus.execute.mockResolvedValueOnce( + mockEntityRescueStationAssignment, + ); + + const result = await resolver.assignment( + { organizationId: orgId } as AuthUser, + { id: alertGroupId } as AlertGroupViewModel, + ); + + expect(mockQueryBus.execute).toHaveBeenCalledWith( + new GetCurrentAssignmentOfEntity(orgId, alertGroupId), + ); + expect(result).toEqual(mockEntityRescueStationAssignment); + }); +}); diff --git a/libs/api/deployment/src/lib/infra/controller/deployment-assignment.resolver.ts b/libs/api/deployment/src/lib/infra/controller/deployment-assignment.resolver.ts new file mode 100644 index 00000000..517917ff --- /dev/null +++ b/libs/api/deployment/src/lib/infra/controller/deployment-assignment.resolver.ts @@ -0,0 +1,62 @@ +import { QueryBus } from '@nestjs/cqrs'; +import { + ObjectType, + OmitType, + Parent, + ResolveField, + Resolver, +} from '@nestjs/graphql'; + +import { RequestUser } from '@kordis/api/auth'; +import { AlertGroupViewModel, UnitViewModel } from '@kordis/api/unit'; +import { AuthUser } from '@kordis/shared/model'; + +import { GetAlertGroupByUnitIdQuery } from '../../core/query/get-alert-group-by-unit-id.query'; +import { GetCurrentAssignmentOfEntity } from '../../core/query/get-current-assignment-of-entity.query'; +import { RescueStationDeploymentViewModel } from '../rescue-station.view-model'; + +@ObjectType() +export class EntityRescueStationAssignment extends OmitType( + RescueStationDeploymentViewModel, + ['assignments', 'assignedAlertGroups', 'assignedUnits'] as const, +) {} + +@Resolver(() => UnitViewModel) +export class UnitAssignmentResolver { + constructor(private readonly queryBus: QueryBus) {} + + @ResolveField(() => EntityRescueStationAssignment, { nullable: true }) + async assignment( + @RequestUser() { organizationId }: AuthUser, + @Parent() unit: UnitViewModel, + ): Promise { + return this.queryBus.execute( + new GetCurrentAssignmentOfEntity(organizationId, unit.id), + ); + } + + @ResolveField(() => AlertGroupViewModel, { nullable: true }) + alertGroup( + @RequestUser() { organizationId }: AuthUser, + @Parent() unit: UnitViewModel, + ): Promise { + return this.queryBus.execute( + new GetAlertGroupByUnitIdQuery(organizationId, unit.id), + ); + } +} + +@Resolver(() => AlertGroupViewModel) +export class AlertGroupAssignmentResolver { + constructor(private readonly queryBus: QueryBus) {} + + @ResolveField(() => EntityRescueStationAssignment, { nullable: true }) + assignment( + @RequestUser() { organizationId }: AuthUser, + @Parent() alertGroup: AlertGroupViewModel, + ): Promise { + return this.queryBus.execute( + new GetCurrentAssignmentOfEntity(organizationId, alertGroup.id), + ); + } +} diff --git a/libs/api/deployment/src/lib/infra/controller/deployment.resolver.spec.ts b/libs/api/deployment/src/lib/infra/controller/deployment.resolver.spec.ts new file mode 100644 index 00000000..6fddf185 --- /dev/null +++ b/libs/api/deployment/src/lib/infra/controller/deployment.resolver.spec.ts @@ -0,0 +1,220 @@ +import { classes } from '@automapper/classes'; +import { AutomapperModule } from '@automapper/nestjs'; +import { createMock } from '@golevelup/ts-jest'; +import { QueryBus } from '@nestjs/cqrs'; +import { Test, TestingModule } from '@nestjs/testing'; +import DataLoader from 'dataloader'; + +import { DataLoaderContextProvider } from '@kordis/api/shared'; +import { UnitViewModel } from '@kordis/api/unit'; +import { AuthUser } from '@kordis/shared/model'; + +import { + DeploymentAlertGroup, + DeploymentUnit, +} from '../../core/entity/deployment.entity'; +import { RescueStationDeploymentEntity } from '../../core/entity/rescue-station-deployment.entity'; +import { GetDeploymentsQuery } from '../../core/query/get-deployments.query'; +import { GetUnassignedEntitiesQuery } from '../../core/query/get-unassigned-entities.query'; +import { RescueStationEntityDTO } from '../../core/repository/rescue-station-deployment.repository'; +import { RescueStationDtoMapperProfile } from '../mapper/rescue-station-dto.mapper-profile'; +import { RescueStationViewModelProfile } from '../mapper/rescue-station-view-model.mapper'; +import { RescueStationDeploymentViewModel } from '../rescue-station.view-model'; +import { + DeploymentResolver, + DeploymentUnitResolver, + RescueStationDeploymentDefaultUnitsResolver, +} from './deployment.resolver'; +import { RescueStationFilterArgs } from './rescue-station-filter.args'; + +describe('DeploymentResolver', () => { + let resolver: DeploymentResolver; + const mockQueryBus = createMock(); + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + imports: [ + AutomapperModule.forRoot({ + strategyInitializer: classes(), + }), + ], + providers: [ + DeploymentResolver, + RescueStationDtoMapperProfile, + RescueStationViewModelProfile, + { provide: QueryBus, useValue: mockQueryBus }, + ], + }).compile(); + + resolver = module.get(DeploymentResolver); + }); + + it('should find rescue station deployments by orgId', async () => { + const orgId = 'orgId'; + const filter = new RescueStationFilterArgs(); + filter.signedIn = true; + + const deploymentResult1 = new RescueStationDeploymentEntity(); + deploymentResult1.note = 'somenote'; + const deploymentUnit1 = new DeploymentUnit(); + deploymentUnit1.unit = { id: 'deploymentUnitId1' }; + deploymentResult1.assignedUnits = [deploymentUnit1]; + const deploymentAlertGroup1 = new DeploymentAlertGroup(); + deploymentAlertGroup1.alertGroup = { id: 'deploymentAlertGroupId1' }; + const deploymentUnit3 = new DeploymentUnit(); + deploymentUnit3.unit = { id: 'deploymentUnitId3' }; + deploymentAlertGroup1.assignedUnits = [deploymentUnit3]; + deploymentResult1.assignedAlertGroups = [deploymentAlertGroup1]; + + const deploymentResult2 = new RescueStationDeploymentEntity(); + deploymentResult2.note = 'someothernote'; + const deploymentUnit2 = new DeploymentUnit(); + deploymentUnit2.unit = { id: 'deploymentUnitId2' }; + deploymentResult2.assignedUnits = [deploymentUnit2]; + const deploymentAlertGroup2 = new DeploymentAlertGroup(); + deploymentAlertGroup2.alertGroup = { id: 'deploymentAlertGroupId2' }; + const deploymentUnit4 = new DeploymentUnit(); + deploymentUnit4.unit = { id: 'deploymentUnitId4' }; + deploymentAlertGroup2.assignedUnits = [deploymentUnit4]; + + deploymentResult2.assignedAlertGroups = [deploymentAlertGroup2]; + + mockQueryBus.execute.mockResolvedValueOnce([ + deploymentResult1, + deploymentResult2, + ]); + + const result = await resolver.rescueStationDeployments( + { organizationId: orgId } as AuthUser, + filter, + ); + const filterDto = new RescueStationEntityDTO(); + filterDto.signedIn = true; + expect(mockQueryBus.execute).toHaveBeenCalledWith( + new GetDeploymentsQuery(orgId, filterDto), + ); + + expect(result.length).toBe(2); + expect(result[0].note).toBe('somenote'); + expect(result[1].note).toBe('someothernote'); + expect(result[0].assignments).toEqual([ + { + unit: { id: 'deploymentUnitId1' }, + }, + { + alertGroup: { id: 'deploymentAlertGroupId1' }, + assignedUnits: [{ unit: { id: 'deploymentUnitId3' } }], + }, + ]); + expect(result[1].assignments).toEqual([ + { + unit: { id: 'deploymentUnitId2' }, + }, + { + alertGroup: { id: 'deploymentAlertGroupId2' }, + assignedUnits: [{ unit: { id: 'deploymentUnitId4' } }], + }, + ]); + }); + + it('should find unassigned entities by orgId', async () => { + const orgId = 'orgId'; + + const mockDeploymentUnit = new DeploymentUnit(); + (mockDeploymentUnit as any).id = 'unitDeploymentId'; + const mockDeploymentAlertGroup = new DeploymentAlertGroup(); + (mockDeploymentAlertGroup as any).id = 'alertGroupDeploymentId'; + + mockQueryBus.execute.mockResolvedValue([ + mockDeploymentUnit, + mockDeploymentAlertGroup, + ]); + + const result = await resolver.unassignedEntities({ + organizationId: orgId, + } as AuthUser); + + expect(mockQueryBus.execute).toHaveBeenCalledWith( + new GetUnassignedEntitiesQuery(orgId), + ); + expect(result).toEqual([mockDeploymentUnit, mockDeploymentAlertGroup]); + }); +}); + +describe('DeploymentUnitResolver', () => { + let resolver: DeploymentUnitResolver; + const mockLoadersProvider = createMock(); + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [DeploymentUnitResolver], + }).compile(); + + resolver = module.get(DeploymentUnitResolver); + }); + + it('should load unit by id', async () => { + const unitId = 'unitId'; + const mockUnitViewModel = new UnitViewModel(); + (mockUnitViewModel as any).id = unitId; + const mockDataLoader = createMock>(); + mockDataLoader.load.mockResolvedValueOnce(mockUnitViewModel); + mockLoadersProvider.getLoader.mockReturnValueOnce(mockDataLoader); + + const deploymentUnit = new DeploymentUnit(); + deploymentUnit.unit = { id: unitId }; + + const result = await resolver.unit(deploymentUnit, { + loadersProvider: mockLoadersProvider, + }); + + expect(mockDataLoader.load).toHaveBeenCalledWith(unitId); + expect(result).toEqual(mockUnitViewModel); + }); +}); + +describe('RescueStationDeploymentDefaultUnitsResolver', () => { + let resolver: RescueStationDeploymentDefaultUnitsResolver; + const mockLoadersProvider = createMock(); + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + RescueStationDeploymentDefaultUnitsResolver, + { provide: DataLoaderContextProvider, useValue: mockLoadersProvider }, + ], + }).compile(); + + resolver = module.get( + RescueStationDeploymentDefaultUnitsResolver, + ); + }); + + it('should load default units by ids', async () => { + const unitIds = ['unitId1', 'unitId2']; + const mockUnitViewModels = unitIds.map((id) => { + const mockUnitViewModel = new UnitViewModel(); + (mockUnitViewModel as any).id = id; + return mockUnitViewModel; + }); + const mockDataLoader = createMock>(); + mockDataLoader.loadMany.mockResolvedValueOnce(mockUnitViewModels); + mockLoadersProvider.getLoader.mockReturnValueOnce(mockDataLoader); + + const rescueStationDeploymentViewModel = + new RescueStationDeploymentViewModel(); + rescueStationDeploymentViewModel.defaultUnits = unitIds.map((id) => ({ + id, + })); + + const result = await resolver.defaultUnits( + rescueStationDeploymentViewModel, + { + loadersProvider: mockLoadersProvider, + }, + ); + + expect(mockDataLoader.loadMany).toHaveBeenCalledWith(unitIds); + expect(result).toEqual(mockUnitViewModels); + }); +}); diff --git a/libs/api/deployment/src/lib/infra/controller/deployment.resolver.ts b/libs/api/deployment/src/lib/infra/controller/deployment.resolver.ts new file mode 100644 index 00000000..aef7ea30 --- /dev/null +++ b/libs/api/deployment/src/lib/infra/controller/deployment.resolver.ts @@ -0,0 +1,128 @@ +import { Mapper } from '@automapper/core'; +import { getMapperToken } from '@automapper/nestjs'; +import { Inject } from '@nestjs/common'; +import { QueryBus } from '@nestjs/cqrs'; +import { + Args, + Context, + Parent, + Query, + ResolveField, + Resolver, +} from '@nestjs/graphql'; + +import { RequestUser } from '@kordis/api/auth'; +import { DataLoaderContextProvider } from '@kordis/api/shared'; +import { AlertGroupViewModel, UnitViewModel } from '@kordis/api/unit'; +import { AuthUser } from '@kordis/shared/model'; + +import { + DeploymentAlertGroup, + DeploymentUnit, +} from '../../core/entity/deployment.entity'; +import { RescueStationDeploymentEntity } from '../../core/entity/rescue-station-deployment.entity'; +import { GetDeploymentsQuery } from '../../core/query/get-deployments.query'; +import { GetUnassignedEntitiesQuery } from '../../core/query/get-unassigned-entities.query'; +import { RescueStationEntityDTO } from '../../core/repository/rescue-station-deployment.repository'; +import { ALERT_GROUPS_DATA_LOADER } from '../data-loader/alert-groups.data-loader'; +import { UNITS_DATA_LOADER } from '../data-loader/units.data-loader'; +import { + DeploymentAssignment, + RescueStationDeploymentViewModel, +} from '../rescue-station.view-model'; +import { RescueStationFilterArgs } from './rescue-station-filter.args'; + +@Resolver() +export class DeploymentResolver { + constructor( + private readonly queryBus: QueryBus, + @Inject(getMapperToken()) private readonly mapper: Mapper, + ) {} + + @Query(() => [RescueStationDeploymentViewModel]) + async rescueStationDeployments( + @RequestUser() { organizationId }: AuthUser, + @Args({ nullable: true }) filter?: RescueStationFilterArgs, + ): Promise { + const filterDto: RescueStationEntityDTO | undefined = filter + ? await this.mapper.mapAsync( + filter, + RescueStationFilterArgs, + RescueStationEntityDTO, + ) + : undefined; + + const deployments = await this.queryBus.execute< + GetDeploymentsQuery, + RescueStationDeploymentEntity[] + >(new GetDeploymentsQuery(organizationId, filterDto)); + + return this.mapper.mapArrayAsync( + deployments, + RescueStationDeploymentEntity, + RescueStationDeploymentViewModel, + ); + } + + @Query(() => [DeploymentAssignment]) + async unassignedEntities( + @RequestUser() { organizationId }: AuthUser, + ): Promise<(DeploymentUnit | DeploymentAlertGroup)[]> { + return this.queryBus.execute( + new GetUnassignedEntitiesQuery(organizationId), + ); + } +} + +@Resolver(() => DeploymentUnit) +export class DeploymentUnitResolver { + @ResolveField() + async unit( + @Parent() { unit }: DeploymentUnit, + @Context() + { loadersProvider }: { loadersProvider: DataLoaderContextProvider }, + ): Promise { + const loader = loadersProvider.getLoader( + UNITS_DATA_LOADER, + ); + return loader.load(unit.id); + } +} + +@Resolver(() => RescueStationDeploymentViewModel) +export class RescueStationDeploymentDefaultUnitsResolver { + @ResolveField() + async defaultUnits( + @Parent() { defaultUnits }: RescueStationDeploymentViewModel, + @Context() + { loadersProvider }: { loadersProvider: DataLoaderContextProvider }, + ): Promise { + const loader = loadersProvider.getLoader( + UNITS_DATA_LOADER, + ); + // check if any error occurred, fail fast + const res = await loader.loadMany(defaultUnits.map(({ id }) => id)); + const error = res.find( + (possibleError: unknown) => possibleError instanceof Error, + ); + if (error) { + throw error; + } + return res as UnitViewModel[]; + } +} + +@Resolver(() => DeploymentAlertGroup) +export class DeploymentAlertGroupResolver { + @ResolveField() + async alertGroup( + @Parent() { alertGroup }: DeploymentAlertGroup, + @Context() + { loadersProvider }: { loadersProvider: DataLoaderContextProvider }, + ): Promise { + const loader = loadersProvider.getLoader( + ALERT_GROUPS_DATA_LOADER, + ); + return loader.load(alertGroup.id); + } +} diff --git a/libs/api/deployment/src/lib/infra/controller/rescue-station-filter.args.ts b/libs/api/deployment/src/lib/infra/controller/rescue-station-filter.args.ts new file mode 100644 index 00000000..2f3320dc --- /dev/null +++ b/libs/api/deployment/src/lib/infra/controller/rescue-station-filter.args.ts @@ -0,0 +1,13 @@ +import { AutoMap } from '@automapper/classes'; +import { ArgsType, Field } from '@nestjs/graphql'; + +import { RescueStationEntityDTO } from '../../core/repository/rescue-station-deployment.repository'; + +@ArgsType() +export class RescueStationFilterArgs + implements Partial +{ + @Field({ nullable: true }) + @AutoMap() + signedIn?: boolean; +} diff --git a/libs/api/deployment/src/lib/infra/data-loader/alert-groups.data-loader.spec.ts b/libs/api/deployment/src/lib/infra/data-loader/alert-groups.data-loader.spec.ts new file mode 100644 index 00000000..aef6a01a --- /dev/null +++ b/libs/api/deployment/src/lib/infra/data-loader/alert-groups.data-loader.spec.ts @@ -0,0 +1,41 @@ +import { DeepMocked, createMock } from '@golevelup/ts-jest'; +import { QueryBus } from '@nestjs/cqrs'; +import DataLoader from 'dataloader'; + +import { DataLoaderContainer } from '@kordis/api/shared'; +import { GetAlertGroupsByIdsQuery } from '@kordis/api/unit'; + +import { + ALERT_GROUPS_DATA_LOADER, + AlertGroupsDataLoader, +} from './alert-groups.data-loader'; + +describe('AlertGroupsDataLoader', () => { + let dataLoaderContainer: DataLoaderContainer; + let queryBusMock: DeepMocked; + + beforeEach(() => { + dataLoaderContainer = new DataLoaderContainer(); + queryBusMock = createMock(); + new AlertGroupsDataLoader(dataLoaderContainer, queryBusMock); + + jest.spyOn(dataLoaderContainer, 'registerLoadingFunction'); + jest.spyOn(queryBusMock, 'execute'); + }); + + it('should be registered in container', async () => { + const loader = dataLoaderContainer.getFactory(ALERT_GROUPS_DATA_LOADER)(); + + expect(loader).toBeInstanceOf(DataLoader); + }); + + it('should load alert group ids', async () => { + const alertGroupIds = ['id1', 'id2']; + const loader = dataLoaderContainer.getFactory(ALERT_GROUPS_DATA_LOADER)(); + await loader.loadMany(alertGroupIds); + + expect(queryBusMock.execute).toHaveBeenCalledWith( + new GetAlertGroupsByIdsQuery(alertGroupIds), + ); + }); +}); diff --git a/libs/api/deployment/src/lib/infra/data-loader/alert-groups.data-loader.ts b/libs/api/deployment/src/lib/infra/data-loader/alert-groups.data-loader.ts new file mode 100644 index 00000000..cf6df30e --- /dev/null +++ b/libs/api/deployment/src/lib/infra/data-loader/alert-groups.data-loader.ts @@ -0,0 +1,18 @@ +import { Injectable } from '@nestjs/common'; +import { QueryBus } from '@nestjs/cqrs'; + +import { DataLoaderContainer } from '@kordis/api/shared'; +import { GetAlertGroupsByIdsQuery } from '@kordis/api/unit'; + +export const ALERT_GROUPS_DATA_LOADER = Symbol('ALERT_GROUPS_DATA_LOADER'); + +@Injectable() +export class AlertGroupsDataLoader { + constructor(loaderContainer: DataLoaderContainer, bus: QueryBus) { + loaderContainer.registerLoadingFunction( + ALERT_GROUPS_DATA_LOADER, + async (alertGroupIds: readonly string[]) => + bus.execute(new GetAlertGroupsByIdsQuery(alertGroupIds as string[])), + ); + } +} diff --git a/libs/api/deployment/src/lib/infra/data-loader/units.data-loader.spec.ts b/libs/api/deployment/src/lib/infra/data-loader/units.data-loader.spec.ts new file mode 100644 index 00000000..2298a549 --- /dev/null +++ b/libs/api/deployment/src/lib/infra/data-loader/units.data-loader.spec.ts @@ -0,0 +1,38 @@ +import { DeepMocked, createMock } from '@golevelup/ts-jest'; +import { QueryBus } from '@nestjs/cqrs'; +import DataLoader from 'dataloader'; + +import { DataLoaderContainer } from '@kordis/api/shared'; +import { GetUnitsByIdsQuery } from '@kordis/api/unit'; + +import { UNITS_DATA_LOADER, UnitsDataLoader } from './units.data-loader'; + +describe('UnitsDataLoader', () => { + let dataLoaderContainer: DataLoaderContainer; + let queryBusMock: DeepMocked; + + beforeEach(() => { + dataLoaderContainer = new DataLoaderContainer(); + queryBusMock = createMock(); + new UnitsDataLoader(dataLoaderContainer, queryBusMock); + + jest.spyOn(dataLoaderContainer, 'registerLoadingFunction'); + jest.spyOn(queryBusMock, 'execute'); + }); + + it('should be registered in container', async () => { + const loader = dataLoaderContainer.getFactory(UNITS_DATA_LOADER)(); + + expect(loader).toBeInstanceOf(DataLoader); + }); + + it('should load unit ids', async () => { + const unitIds = ['id1', 'id2']; + const loader = dataLoaderContainer.getFactory(UNITS_DATA_LOADER)(); + await loader.loadMany(unitIds); + + expect(queryBusMock.execute).toHaveBeenCalledWith( + new GetUnitsByIdsQuery(unitIds), + ); + }); +}); diff --git a/libs/api/deployment/src/lib/infra/data-loader/units.data-loader.ts b/libs/api/deployment/src/lib/infra/data-loader/units.data-loader.ts new file mode 100644 index 00000000..56efc1b3 --- /dev/null +++ b/libs/api/deployment/src/lib/infra/data-loader/units.data-loader.ts @@ -0,0 +1,18 @@ +import { Injectable } from '@nestjs/common'; +import { QueryBus } from '@nestjs/cqrs'; + +import { DataLoaderContainer } from '@kordis/api/shared'; +import { GetUnitsByIdsQuery } from '@kordis/api/unit'; + +export const UNITS_DATA_LOADER = Symbol('UNITS_DATA_LOADER'); + +@Injectable() +export class UnitsDataLoader { + constructor(loaderContainer: DataLoaderContainer, bus: QueryBus) { + loaderContainer.registerLoadingFunction( + UNITS_DATA_LOADER, + async (unitIds: readonly string[]) => + bus.execute(new GetUnitsByIdsQuery(unitIds as string[])), + ); + } +} diff --git a/libs/api/deployment/src/lib/infra/deployment.module.ts b/libs/api/deployment/src/lib/infra/deployment.module.ts new file mode 100644 index 00000000..a3af9d3d --- /dev/null +++ b/libs/api/deployment/src/lib/infra/deployment.module.ts @@ -0,0 +1,146 @@ +import { Module } from '@nestjs/common'; +import { MongooseModule } from '@nestjs/mongoose'; + +import { SignInRescueStationHandler } from '../core/command/sign-in-rescue-station.command'; +import { SignOffRescueStationHandler } from '../core/command/sign-off-rescue-station.command'; +import { UpdateSignedInRescueStationHandler } from '../core/command/update-signed-in-rescue-station.command'; +import { GetAlertGroupByUnitIdHandler } from '../core/query/get-alert-group-by-unit-id.query'; +import { GetUnitAssignmentHandlerHandler } from '../core/query/get-current-assignment-of-entity.query'; +import { GetDeploymentsHandler } from '../core/query/get-deployments.query'; +import { GetRescueStationDeploymentHandler } from '../core/query/get-rescue-station-deployment.query'; +import { GetUnassignedEntitiesHandler } from '../core/query/get-unassigned-entities.query'; +import { DEPLOYMENT_ASSIGNMENT_REPOSITORY } from '../core/repository/deployment-assignment.repository'; +import { RESCUE_STATION_DEPLOYMENT_REPOSITORY } from '../core/repository/rescue-station-deployment.repository'; +import { UNIT_ASSIGNMENT_REPOSITORY } from '../core/repository/unit-assignment.repository'; +import { DeploymentAssignmentService } from '../core/service/deployment-assignment.service'; +import { StrengthFromCommandFactory } from '../core/service/strength-from-command.factory'; +import { + AlertGroupAssignmentResolver, + UnitAssignmentResolver, +} from './controller/deployment-assignment.resolver'; +import { + DeploymentAlertGroupResolver, + DeploymentResolver, + DeploymentUnitResolver, + RescueStationDeploymentDefaultUnitsResolver, +} from './controller/deployment.resolver'; +import { AlertGroupsDataLoader } from './data-loader/alert-groups.data-loader'; +import { UnitsDataLoader } from './data-loader/units.data-loader'; +import { DeploymentAggregateProfile } from './mapper/deployment-aggregate.mapper-profile'; +import { DeploymentAssignmentProfile } from './mapper/deployment-assignment.mapper-profile'; +import { + RescueStationDeploymentAggregateProfile, + RescueStationDeploymentValueObjectProfile, +} from './mapper/rescue-station-deployment-aggregate.mapper-profile'; +import { RescueStationDtoMapperProfile } from './mapper/rescue-station-dto.mapper-profile'; +import { RescueStationViewModelProfile } from './mapper/rescue-station-view-model.mapper'; +import { DeploymentAssignmentRepositoryImpl } from './repository/assignment/deployment-assignment.repository'; +import { UnitAssignmentRepositoryImpl } from './repository/assignment/unit-assignment.repository'; +import { RescueStationDeploymentRepositoryImpl } from './repository/deployment/rescue-station-deployment.repository'; +import { + AlertGroupAssignmentDocument, + AlertGroupAssignmentSchema, + DeploymentAssignmentSchema, + DeploymentAssignmentType, + DeploymentAssignmentsDocument, + UnitAssignmentDocument, + UnitAssignmentSchema, +} from './schema/deployment-assignment.schema'; +import { DeploymentType } from './schema/deployment-type.enum'; +import { + BaseDeploymentDocument, + DeploymentSchema, +} from './schema/deployment.schema'; +import { + RescueStationDeploymentDocument, + RescueStationDeploymentSchema, +} from './schema/rescue-station-deployment.schema'; + +const REPOSITORIES = [ + { + provide: RESCUE_STATION_DEPLOYMENT_REPOSITORY, + useClass: RescueStationDeploymentRepositoryImpl, + }, + { + provide: DEPLOYMENT_ASSIGNMENT_REPOSITORY, + useClass: DeploymentAssignmentRepositoryImpl, + }, + { + provide: UNIT_ASSIGNMENT_REPOSITORY, + useClass: UnitAssignmentRepositoryImpl, + }, +]; +const CQRS_HANDLERS = [ + GetDeploymentsHandler, + GetRescueStationDeploymentHandler, + SignInRescueStationHandler, + SignOffRescueStationHandler, + UpdateSignedInRescueStationHandler, + GetUnassignedEntitiesHandler, + GetAlertGroupByUnitIdHandler, + GetUnitAssignmentHandlerHandler, +]; +const RESOLVERS = [ + DeploymentResolver, + DeploymentUnitResolver, + DeploymentAlertGroupResolver, + UnitAssignmentResolver, + AlertGroupAssignmentResolver, + RescueStationDeploymentDefaultUnitsResolver, +]; +const DATA_LOADERS = [UnitsDataLoader, AlertGroupsDataLoader]; +const MAPPER_PROFILES = [ + DeploymentAggregateProfile, + DeploymentAssignmentProfile, + RescueStationDeploymentAggregateProfile, + RescueStationDeploymentValueObjectProfile, + RescueStationDtoMapperProfile, + RescueStationViewModelProfile, +]; +const DOMAIN_SERVICES = [ + DeploymentAssignmentService, + StrengthFromCommandFactory, +]; + +@Module({ + imports: [ + MongooseModule.forFeature([ + { + name: BaseDeploymentDocument.name, + schema: DeploymentSchema, + discriminators: [ + { + value: DeploymentType.RESCUE_STATION, + name: RescueStationDeploymentDocument.name, + schema: RescueStationDeploymentSchema, + }, + ], + }, + { + name: DeploymentAssignmentsDocument.name, + schema: DeploymentAssignmentSchema, + discriminators: [ + { + value: DeploymentAssignmentType.UNIT, + name: UnitAssignmentDocument.name, + schema: UnitAssignmentSchema, + }, + { + value: DeploymentAssignmentType.ALERT_GROUP, + name: AlertGroupAssignmentDocument.name, + schema: AlertGroupAssignmentSchema, + }, + ], + }, + ]), + ], + providers: [ + ...REPOSITORIES, + ...CQRS_HANDLERS, + ...RESOLVERS, + ...DATA_LOADERS, + ...MAPPER_PROFILES, + ...DOMAIN_SERVICES, + ], +}) +export class DeploymentModule {} diff --git a/libs/api/deployment/src/lib/infra/mapper/deployment-aggregate.mapper-profile.ts b/libs/api/deployment/src/lib/infra/mapper/deployment-aggregate.mapper-profile.ts new file mode 100644 index 00000000..4ec669f8 --- /dev/null +++ b/libs/api/deployment/src/lib/infra/mapper/deployment-aggregate.mapper-profile.ts @@ -0,0 +1,50 @@ +import { + Mapper, + MappingProfile, + createMap, + forMember, + mapWith, +} from '@automapper/core'; +import { AutomapperProfile, getMapperToken } from '@automapper/nestjs'; +import { Inject, Injectable } from '@nestjs/common'; + +import { + BaseDeploymentEntity, + DeploymentAlertGroup, + DeploymentUnit, +} from '../../core/entity/deployment.entity'; +import { + DeploymentAggregate, + DeploymentAlertGroupAggregate, +} from '../repository/deployment/abstract-deployment.repository'; +import { UnitAssignmentDocument } from '../schema/deployment-assignment.schema'; + +// Mapping from the aggregate result (document) to the entity +@Injectable() +export class DeploymentAggregateProfile extends AutomapperProfile { + constructor(@Inject(getMapperToken()) mapper: Mapper) { + super(mapper); + } + + override get profile(): MappingProfile { + return (mapper: Mapper): void => { + createMap( + mapper, + DeploymentAggregate, + BaseDeploymentEntity, + forMember( + (d) => d.assignedUnits, + mapWith(DeploymentUnit, UnitAssignmentDocument, (s) => s.units), + ), + forMember( + (d) => d.assignedAlertGroups, + mapWith( + DeploymentAlertGroup, + DeploymentAlertGroupAggregate, + (s) => s.alertGroups, + ), + ), + ); + }; + } +} diff --git a/libs/api/deployment/src/lib/infra/mapper/deployment-assignment.mapper-profile.ts b/libs/api/deployment/src/lib/infra/mapper/deployment-assignment.mapper-profile.ts new file mode 100644 index 00000000..6878b98a --- /dev/null +++ b/libs/api/deployment/src/lib/infra/mapper/deployment-assignment.mapper-profile.ts @@ -0,0 +1,54 @@ +import { Mapper, createMap, forMember, mapFrom } from '@automapper/core'; +import { AutomapperProfile, getMapperToken } from '@automapper/nestjs'; +import { Inject, Injectable } from '@nestjs/common'; + +import { + DeploymentAlertGroup, + DeploymentUnit, +} from '../../core/entity/deployment.entity'; +import { DeploymentAlertGroupAggregate } from '../repository/deployment/abstract-deployment.repository'; +import { UnitAssignmentDocument } from '../schema/deployment-assignment.schema'; + +@Injectable() +export class DeploymentAssignmentProfile extends AutomapperProfile { + constructor(@Inject(getMapperToken()) mapper: Mapper) { + super(mapper); + } + + override get profile() { + return (mapper: Mapper): void => { + createMap( + mapper, + UnitAssignmentDocument, + DeploymentUnit, + forMember( + (s) => s.unit, + mapFrom((s) => ({ + id: s.entityId, + })), + ), + ); + createMap( + mapper, + DeploymentAlertGroupAggregate, + DeploymentAlertGroup, + forMember( + (s) => s.alertGroup, + mapFrom((s) => ({ + id: s.entityId, + })), + ), + forMember( + (s) => s.assignedUnits, + mapFrom((s) => + s.assignedUnitIds.map((id) => ({ + unit: { + id, + }, + })), + ), + ), + ); + }; + } +} diff --git a/libs/api/deployment/src/lib/infra/mapper/rescue-station-deployment-aggregate.mapper-profile.ts b/libs/api/deployment/src/lib/infra/mapper/rescue-station-deployment-aggregate.mapper-profile.ts new file mode 100644 index 00000000..dda5a9dd --- /dev/null +++ b/libs/api/deployment/src/lib/infra/mapper/rescue-station-deployment-aggregate.mapper-profile.ts @@ -0,0 +1,98 @@ +import { + Mapper, + MappingConfiguration, + createMap, + extend, + forMember, + mapFrom, +} from '@automapper/core'; +import { AutomapperProfile, getMapperToken } from '@automapper/nestjs'; +import { Inject, Injectable } from '@nestjs/common'; + +import { BaseMapperProfile, Coordinate } from '@kordis/api/shared'; + +import { BaseDeploymentEntity } from '../../core/entity/deployment.entity'; +import { + RescueStationAddress as RescueStationAddressValueObject, + RescueStationDeploymentEntity, + RescueStationLocation as RescueStationLocationValueObject, + RescueStationStrength as RescueStationStrengthValueObject, +} from '../../core/entity/rescue-station-deployment.entity'; +import { DeploymentAggregate } from '../repository/deployment/abstract-deployment.repository'; +import { + RescueStationAddress, + RescueStationDeploymentDocument, + RescueStationLocation, + RescueStationStrength, +} from '../schema/rescue-station-deployment.schema'; + +@Injectable() +export class RescueStationDeploymentValueObjectProfile extends AutomapperProfile { + constructor(@Inject(getMapperToken()) mapper: Mapper) { + super(mapper); + } + + override get profile() { + return (mapper: Mapper): void => { + createMap( + mapper, + RescueStationLocation, + RescueStationLocationValueObject, + ); + createMap( + mapper, + RescueStationLocationValueObject, + RescueStationLocation, + ); + createMap( + mapper, + RescueStationStrength, + RescueStationStrengthValueObject, + ); + createMap( + mapper, + RescueStationStrengthValueObject, + RescueStationStrength, + ); + createMap(mapper, RescueStationAddress, RescueStationAddressValueObject); + createMap(mapper, RescueStationAddressValueObject, RescueStationAddress); + createMap(mapper, Coordinate, Coordinate); + }; + } +} + +@Injectable() +export class RescueStationDeploymentAggregateProfile extends BaseMapperProfile { + constructor(@Inject(getMapperToken()) mapper: Mapper) { + super(mapper); + } + + override get profile() { + return (mapper: Mapper): void => { + createMap( + mapper, + RescueStationDeploymentDocument, + RescueStationDeploymentEntity, + forMember( + (d) => d.id, + mapFrom((s) => s.referenceId), + ), + forMember( + (d) => d.defaultUnits, + mapFrom((s) => + s.defaultUnitIds.map((id) => ({ + id, + })), + ), + ), + ); + }; + } + + protected override get mappingConfigurations(): MappingConfiguration[] { + return [ + ...super.mappingConfigurations, + extend(DeploymentAggregate, BaseDeploymentEntity), + ]; + } +} diff --git a/libs/api/deployment/src/lib/infra/mapper/rescue-station-dto.mapper-profile.ts b/libs/api/deployment/src/lib/infra/mapper/rescue-station-dto.mapper-profile.ts new file mode 100644 index 00000000..8884ca38 --- /dev/null +++ b/libs/api/deployment/src/lib/infra/mapper/rescue-station-dto.mapper-profile.ts @@ -0,0 +1,21 @@ +import { Mapper, createMap } from '@automapper/core'; +import { AutomapperProfile, getMapperToken } from '@automapper/nestjs'; +import { Inject, Injectable } from '@nestjs/common'; + +import { RescueStationEntityDTO } from '../../core/repository/rescue-station-deployment.repository'; +import { RescueStationFilterArgs } from '../controller/rescue-station-filter.args'; +import { RescueStationDocumentDTO } from '../repository/deployment/rescue-station-deployment.repository'; + +@Injectable() +export class RescueStationDtoMapperProfile extends AutomapperProfile { + constructor(@Inject(getMapperToken()) mapper: Mapper) { + super(mapper); + } + + override get profile() { + return (mapper: Mapper): void => { + createMap(mapper, RescueStationFilterArgs, RescueStationEntityDTO); + createMap(mapper, RescueStationEntityDTO, RescueStationDocumentDTO); + }; + } +} diff --git a/libs/api/deployment/src/lib/infra/mapper/rescue-station-view-model.mapper.ts b/libs/api/deployment/src/lib/infra/mapper/rescue-station-view-model.mapper.ts new file mode 100644 index 00000000..d74f773c --- /dev/null +++ b/libs/api/deployment/src/lib/infra/mapper/rescue-station-view-model.mapper.ts @@ -0,0 +1,39 @@ +import { + Mapper, + MappingProfile, + createMap, + forMember, + mapFrom, +} from '@automapper/core'; +import { AutomapperProfile, getMapperToken } from '@automapper/nestjs'; +import { Inject, Injectable } from '@nestjs/common'; + +import { RescueStationDeploymentEntity } from '../../core/entity/rescue-station-deployment.entity'; +import { RescueStationDeploymentViewModel } from '../rescue-station.view-model'; + +@Injectable() +export class RescueStationViewModelProfile extends AutomapperProfile { + constructor(@Inject(getMapperToken()) mapper: Mapper) { + super(mapper); + } + + override get profile(): MappingProfile { + return (mapper: Mapper): void => { + createMap( + mapper, + RescueStationDeploymentEntity, + RescueStationDeploymentViewModel, + forMember( + (d) => d.assignments, + mapFrom((s) => { + return [...s.assignedUnits, ...s.assignedAlertGroups]; + }), + ), + forMember( + (d) => d.defaultUnits, + mapFrom((s) => s.defaultUnits), + ), + ); + }; + } +} diff --git a/libs/api/deployment/src/lib/infra/repository/assignment/deployment-assignment.repository.spec.ts b/libs/api/deployment/src/lib/infra/repository/assignment/deployment-assignment.repository.spec.ts new file mode 100644 index 00000000..99b0a609 --- /dev/null +++ b/libs/api/deployment/src/lib/infra/repository/assignment/deployment-assignment.repository.spec.ts @@ -0,0 +1,147 @@ +import { classes } from '@automapper/classes'; +import { AutomapperModule } from '@automapper/nestjs'; +import { createMock } from '@golevelup/ts-jest'; +import { getModelToken } from '@nestjs/mongoose'; +import { Test, TestingModule } from '@nestjs/testing'; +import { Model } from 'mongoose'; + +import { BaseModelProfile } from '@kordis/api/shared'; +import { mockModelMethodResults } from '@kordis/api/test-helpers'; + +import { + DeploymentAlertGroup, + DeploymentUnit, +} from '../../../core/entity/deployment.entity'; +import { RescueStationDeploymentEntity } from '../../../core/entity/rescue-station-deployment.entity'; +import { DeploymentAggregateProfile } from '../../mapper/deployment-aggregate.mapper-profile'; +import { DeploymentAssignmentProfile } from '../../mapper/deployment-assignment.mapper-profile'; +import { + RescueStationDeploymentAggregateProfile, + RescueStationDeploymentValueObjectProfile, +} from '../../mapper/rescue-station-deployment-aggregate.mapper-profile'; +import { DeploymentAssignmentsDocument } from '../../schema/deployment-assignment.schema'; +import { DeploymentAssignmentRepositoryImpl } from './deployment-assignment.repository'; + +describe('DeploymentAssignmentRepositoryImpl', () => { + let repository: DeploymentAssignmentRepositoryImpl; + let deploymentAssignmentModel: Model; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + imports: [ + AutomapperModule.forRoot({ + strategyInitializer: classes(), + }), + ], + providers: [ + BaseModelProfile, + DeploymentAggregateProfile, + DeploymentAssignmentProfile, + RescueStationDeploymentAggregateProfile, + RescueStationDeploymentValueObjectProfile, + DeploymentAssignmentRepositoryImpl, + { + provide: getModelToken(DeploymentAssignmentsDocument.name), + useValue: createMock>(), + }, + ], + }).compile(); + + repository = module.get( + DeploymentAssignmentRepositoryImpl, + ); + deploymentAssignmentModel = module.get< + Model + >(getModelToken(DeploymentAssignmentsDocument.name)); + }); + + it('should get assignment', async () => { + const orgId = 'orgId'; + const entityId = 'entityId'; + mockModelMethodResults( + deploymentAssignmentModel, + [ + { + deployment: [ + { + name: 'somename', + defaultUnitIds: ['someUnitId1', 'someUnitId2'], + }, + ], + }, + ], + 'aggregate', + ); + + const result = await repository.getAssignment(orgId, entityId); + expect(result).not.toBeNull(); + expect(result).toBeInstanceOf(RescueStationDeploymentEntity); + expect(result!.name).toEqual('somename'); + expect(result!.defaultUnits).toEqual([ + { id: 'someUnitId1' }, + { id: 'someUnitId2' }, + ]); + }); + + it('should get unassigned', async () => { + const orgId = 'orgId'; + + mockModelMethodResults( + deploymentAssignmentModel, + [ + { + units: [{ entityId: 'someUnitId' }], + alertGroups: [ + { + entityId: 'someAlertGroupId', + assignedUnitIds: ['someOtherUnitId'], + }, + ], + }, + ], + 'aggregate', + ); + + const result = await repository.getUnassigned(orgId); + + expect(result).toEqual([ + { + alertGroup: { + id: 'someAlertGroupId', + }, + assignedUnits: [ + { + unit: { + id: 'someOtherUnitId', + }, + }, + ], + }, + { + unit: { + id: 'someUnitId', + }, + }, + ]); + expect(result[0]).toBeInstanceOf(DeploymentAlertGroup); + expect(result[1]).toBeInstanceOf(DeploymentUnit); + }); + + it('should remove assignments of deployment', async () => { + const orgId = 'orgId'; + const deploymentId = '662b83b325f7a66dd0fd53de'; + + await repository.removeAssignmentsOfDeployment(orgId, deploymentId); + expect(deploymentAssignmentModel.updateMany).toHaveBeenCalled(); + }); + + it('should assign entities to deployment', async () => { + const orgId = 'orgId'; + const deploymentId = '662b83b325f7a66dd0fd53de'; + const entityIds = ['entityId1', 'entityId2']; + + await repository.assignEntitiesToDeployment(orgId, deploymentId, entityIds); + + expect(deploymentAssignmentModel.updateMany).toHaveBeenCalled(); + }); +}); diff --git a/libs/api/deployment/src/lib/infra/repository/assignment/deployment-assignment.repository.ts b/libs/api/deployment/src/lib/infra/repository/assignment/deployment-assignment.repository.ts new file mode 100644 index 00000000..a6b9432d --- /dev/null +++ b/libs/api/deployment/src/lib/infra/repository/assignment/deployment-assignment.repository.ts @@ -0,0 +1,208 @@ +import { Mapper } from '@automapper/core'; +import { getMapperToken } from '@automapper/nestjs'; +import { Inject, Injectable } from '@nestjs/common'; +import { InjectModel } from '@nestjs/mongoose'; +import { Model, Types } from 'mongoose'; + +import { DbSessionProvider, runDbOperation } from '@kordis/api/shared'; + +import { + DeploymentAlertGroup, + DeploymentUnit, +} from '../../../core/entity/deployment.entity'; +import { RescueStationDeploymentEntity } from '../../../core/entity/rescue-station-deployment.entity'; +import { DeploymentAssignmentRepository } from '../../../core/repository/deployment-assignment.repository'; +import { + DeploymentAssignmentType, + DeploymentAssignmentsDocument, + UnitAssignmentDocument, +} from '../../schema/deployment-assignment.schema'; +import { RescueStationDeploymentDocument } from '../../schema/rescue-station-deployment.schema'; +import { DeploymentAlertGroupAggregate } from '../deployment/abstract-deployment.repository'; + +@Injectable() +export class DeploymentAssignmentRepositoryImpl + implements DeploymentAssignmentRepository +{ + constructor( + @InjectModel(DeploymentAssignmentsDocument.name) + private readonly deploymentAssignmentModel: Model, + @Inject(getMapperToken()) private readonly mapper: Mapper, + ) {} + + /* + * Get assignment of a unit or alert group. + * @param orgId The organization id. + * @param entityId The id of the unit or the alert group. + */ + async getAssignment( + orgId: string, + entityId: string, + ): Promise { + const assignment = await this.deploymentAssignmentModel + .aggregate([ + { + $match: { + orgId, + entityId, + }, + }, + { + $lookup: { + from: 'deployments', + localField: 'deploymentId', + foreignField: '_id', + as: 'deployment', + }, + }, + ]) + .exec(); + + if (assignment.length > 0 && assignment[0].deployment.length > 0) { + return this.mapper.map( + assignment[0].deployment[0], + RescueStationDeploymentDocument, + RescueStationDeploymentEntity, + ); + } + + return null; + } + + /* + * Get unassigned units and alert groups. + * @param orgId The organization id. + */ + async getUnassigned( + orgId: string, + ): Promise<(DeploymentUnit | DeploymentAlertGroup)[]> { + const unassignedEntities = await this.deploymentAssignmentModel + .aggregate([ + { + $match: { + orgId, + deploymentId: null, + }, + }, + { + $facet: { + alertGroups: [ + { + $match: { + type: DeploymentAssignmentType.ALERT_GROUP, + }, + }, + { + $lookup: { + from: 'deployment-assignments', + localField: 'entityId', + foreignField: 'alertGroupId', + as: 'units', + }, + }, + { + $addFields: { + assignedUnitIds: { + $map: { + input: '$units', + as: 'item', + in: '$$item.entityId', + }, + }, + }, + }, + { + $project: { + _id: 0, + entityId: 1, + assignedUnitIds: 1, + }, + }, + ], + units: [ + { + $match: { + type: DeploymentAssignmentType.UNIT, + alertGroupId: null, + }, + }, + { + $project: { + _id: 0, + entityId: 1, + }, + }, + ], + }, + }, + ]) + .exec(); + + return Promise.all([ + this.mapper.mapArrayAsync( + unassignedEntities[0].alertGroups, + DeploymentAlertGroupAggregate, + DeploymentAlertGroup, + ), + this.mapper.mapArrayAsync( + unassignedEntities[0].units, + UnitAssignmentDocument, + DeploymentUnit, + ), + ]).then(([units, alertGroups]) => [...units, ...alertGroups]); + } + + /* + * Removes all assignments (units and alert groups with their units) of a deployment, + * @param orgId The organization id. + * @param deploymentId The deployment id. + * @param uow The unit of work. + */ + async removeAssignmentsOfDeployment( + orgId: string, + deploymentId: string, + uow?: DbSessionProvider | undefined, + ): Promise { + const operation = this.deploymentAssignmentModel.updateMany( + { + orgId, + deploymentId: new Types.ObjectId(deploymentId), + }, + { + $set: { + deploymentId: null, + }, + }, + ); + + await runDbOperation(operation, uow); + } + + /* + * Assigns units and alert groups to a deployment. + * @param orgId The organization id. + * @param deploymentId The deployment id. + * @param entityIds The ids of units and/or alert groups. + * @param uow The unit of work. + */ + async assignEntitiesToDeployment( + orgId: string, + deploymentId: string, + entityIds: string[], + uow?: DbSessionProvider | undefined, + ): Promise { + const operation = this.deploymentAssignmentModel.updateMany( + { + orgId, + entityId: { $in: entityIds }, + }, + { + $set: { + deploymentId: new Types.ObjectId(deploymentId), + }, + }, + ); + + await runDbOperation(operation, uow); + } +} diff --git a/libs/api/deployment/src/lib/infra/repository/assignment/unit-assignment.repository.spec.ts b/libs/api/deployment/src/lib/infra/repository/assignment/unit-assignment.repository.spec.ts new file mode 100644 index 00000000..43ccd833 --- /dev/null +++ b/libs/api/deployment/src/lib/infra/repository/assignment/unit-assignment.repository.spec.ts @@ -0,0 +1,104 @@ +import { classes } from '@automapper/classes'; +import { AutomapperModule } from '@automapper/nestjs'; +import { createMock } from '@golevelup/ts-jest'; +import { getModelToken } from '@nestjs/mongoose'; +import { Test, TestingModule } from '@nestjs/testing'; +import { Model } from 'mongoose'; + +import { BaseModelProfile } from '@kordis/api/shared'; +import { mockModelMethodResults } from '@kordis/api/test-helpers'; + +import { + DeploymentAlertGroup, + DeploymentUnit, +} from '../../../core/entity/deployment.entity'; +import { DeploymentAssignmentProfile } from '../../mapper/deployment-assignment.mapper-profile'; +import { UnitAssignmentDocument } from '../../schema/deployment-assignment.schema'; +import { UnitAssignmentRepositoryImpl } from './unit-assignment.repository'; + +describe('UnitAssignmentRepositoryImpl', () => { + let repository: UnitAssignmentRepositoryImpl; + let unitAssignmentModel: Model; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + imports: [ + AutomapperModule.forRoot({ + strategyInitializer: classes(), + }), + ], + providers: [ + BaseModelProfile, + DeploymentAssignmentProfile, + UnitAssignmentRepositoryImpl, + { + provide: getModelToken(UnitAssignmentDocument.name), + useValue: createMock>(), + }, + ], + }).compile(); + + repository = module.get( + UnitAssignmentRepositoryImpl, + ); + unitAssignmentModel = module.get>( + getModelToken(UnitAssignmentDocument.name), + ); + }); + + it('should get alert group of unit', async () => { + const orgId = 'orgId'; + const unitId = 'unitId'; + + const mockResult = [ + { + orgId, + entityId: 'alertGroupId', + assignedUnitIds: ['unitId'], + }, + ]; + + mockModelMethodResults(unitAssignmentModel, mockResult, 'aggregate'); + + const result = await repository.findAlertGroupOfUnit(orgId, unitId); + + const expectedResult = new DeploymentAlertGroup(); + expectedResult.alertGroup = { id: 'alertGroupId' }; + const expectedDeploymentUnit = new DeploymentUnit(); + expectedDeploymentUnit.unit = { id: 'unitId' }; + expectedResult.assignedUnits = [expectedDeploymentUnit]; + + expect(result).toEqual(expectedResult); + }); + + it('should remove alert group assignments by alert groups', async () => { + const orgId = 'orgId'; + const alertGroupIds = ['alertGroupId1', 'alertGroupId2']; + + await repository.removeAlertGroupAssignmentsByAlertGroups( + orgId, + alertGroupIds, + ); + + expect(unitAssignmentModel.updateMany).toHaveBeenCalled(); + }); + + it('should remove alert group assignments from units', async () => { + const orgId = 'orgId'; + const unitIds = ['unitId1', 'unitId2']; + + await repository.removeAlertGroupAssignmentsFromUnits(orgId, unitIds); + + expect(unitAssignmentModel.updateMany).toHaveBeenCalled(); + }); + + it('should set alert group assignment', async () => { + const orgId = 'orgId'; + const unitIds = ['unitId1', 'unitId2']; + const alertGroupId = 'alertGroupId'; + + await repository.setAlertGroupAssignment(orgId, unitIds, alertGroupId); + + expect(unitAssignmentModel.updateMany).toHaveBeenCalled(); + }); +}); diff --git a/libs/api/deployment/src/lib/infra/repository/assignment/unit-assignment.repository.ts b/libs/api/deployment/src/lib/infra/repository/assignment/unit-assignment.repository.ts new file mode 100644 index 00000000..104e02bd --- /dev/null +++ b/libs/api/deployment/src/lib/infra/repository/assignment/unit-assignment.repository.ts @@ -0,0 +1,145 @@ +import { Mapper } from '@automapper/core'; +import { getMapperToken } from '@automapper/nestjs'; +import { Inject } from '@nestjs/common'; +import { InjectModel } from '@nestjs/mongoose'; +import { Model } from 'mongoose'; + +import { + DbSessionProvider, + UnitOfWork, + runDbOperation, +} from '@kordis/api/shared'; + +import { DeploymentAlertGroup } from '../../../core/entity/deployment.entity'; +import { UnitAssignmentRepository } from '../../../core/repository/unit-assignment.repository'; +import { UnitAssignmentDocument } from '../../schema/deployment-assignment.schema'; +import { DeploymentAlertGroupAggregate } from '../deployment/abstract-deployment.repository'; + +export class UnitAssignmentRepositoryImpl implements UnitAssignmentRepository { + constructor( + @InjectModel(UnitAssignmentDocument.name) + private readonly unitAssignmentModel: Model, + @Inject(getMapperToken()) private readonly mapper: Mapper, + ) {} + + async findAlertGroupOfUnit( + orgId: string, + unitId: string, + ): Promise { + const res = await this.unitAssignmentModel + .aggregate([ + { + $match: { + orgId, + entityId: unitId, + }, + }, + { + $lookup: { + from: 'deployment-assignments', + localField: 'alertGroupId', + foreignField: 'entityId', + as: 'alertGroup', + }, + }, + { + $lookup: { + from: 'deployment-assignments', + localField: 'alertGroupId', + foreignField: 'alertGroupId', + as: 'alertGroupUnits', + }, + }, + { + $set: { + alertGroup: { + $arrayElemAt: ['$alertGroups', 0], + }, + }, + }, + { + $addFields: { + 'alertGroup.assignedUnitIds': { + $map: { + input: '$alertGroupUnits', + as: 'item', + in: '$$item.entityId', + }, + }, + }, + }, + { + $replaceRoot: { + newRoot: '$alertGroup', + }, + }, + ]) + .exec(); + + return res?.[0] + ? this.mapper.map( + res?.[0], + DeploymentAlertGroupAggregate, + DeploymentAlertGroup, + ) + : null; + } + + async removeAlertGroupAssignmentsByAlertGroups( + orgId: string, + alertGroupIds: string[], + uow?: UnitOfWork | undefined, + ): Promise { + const operation = this.unitAssignmentModel.updateMany( + { + orgId, + alertGroupId: { $in: alertGroupIds }, + }, + { + $set: { + alertGroupId: null, + }, + }, + ); + await runDbOperation(operation, uow); + } + + async removeAlertGroupAssignmentsFromUnits( + orgId: string, + unitIds: string[], + uow?: DbSessionProvider, + ): Promise { + const operation = this.unitAssignmentModel.updateMany( + { + orgId, + entityId: { $in: unitIds }, + }, + { + $set: { + alertGroupId: null, + }, + }, + ); + await runDbOperation(operation, uow); + } + + async setAlertGroupAssignment( + orgId: string, + unitIds: string[], + alertGroupId: string, + uow?: UnitOfWork | undefined, + ): Promise { + const operation = this.unitAssignmentModel.updateMany( + { + orgId, + entityId: { $in: unitIds }, + }, + { + $set: { + alertGroupId, + }, + }, + ); + await runDbOperation(operation, uow); + } +} diff --git a/libs/api/deployment/src/lib/infra/repository/deployment/abstract-deployment.repository.ts b/libs/api/deployment/src/lib/infra/repository/deployment/abstract-deployment.repository.ts new file mode 100644 index 00000000..ace41c76 --- /dev/null +++ b/libs/api/deployment/src/lib/infra/repository/deployment/abstract-deployment.repository.ts @@ -0,0 +1,188 @@ +import { Mapper, ModelIdentifier } from '@automapper/core'; +import { FilterQuery, Model } from 'mongoose'; + +import { DbSessionProvider, runDbOperation } from '@kordis/api/shared'; + +import { BaseDeploymentEntity } from '../../../core/entity/deployment.entity'; +import { DeploymentNotFoundException } from '../../../core/exception/deployment-not-found.exception'; +import { DeploymentRepository } from '../../../core/repository/deployment.repository'; +import { + AlertGroupAssignmentDocument, + DeploymentAssignmentType, + UnitAssignmentDocument, +} from '../../schema/deployment-assignment.schema'; +import { BaseDeploymentDocument } from '../../schema/deployment.schema'; + +export class DeploymentAlertGroupAggregate extends AlertGroupAssignmentDocument { + assignedUnitIds: string[]; +} + +export class DeploymentAggregate { + alertGroups: DeploymentAlertGroupAggregate[]; + units: UnitAssignmentDocument[]; +} + +const ASSIGNMENT_JOIN_PIPELINE_STEPS = Object.freeze([ + { + $lookup: { + from: 'deployment-assignments', + let: { deploymentId: '$_id' }, + pipeline: [ + { + $match: { + $expr: { + $and: [ + { $eq: ['$deploymentId', '$$deploymentId'] }, + { $eq: ['$type', DeploymentAssignmentType.ALERT_GROUP] }, + ], + }, + }, + }, + { + $lookup: { + from: 'deployment-assignments', + localField: 'entityId', + foreignField: 'alertGroupId', + as: 'units', + }, + }, + { + $addFields: { + assignedUnitIds: { + $map: { + input: '$units', + as: 'item', + in: '$$item.entityId', + }, + }, + }, + }, + { + $project: { + entityId: 1, + assignedUnitIds: 1, + _id: 0, + }, + }, + ], + as: 'alertGroups', + }, + }, + { + $lookup: { + from: 'deployment-assignments', + let: { deploymentId: '$_id' }, + pipeline: [ + { + $match: { + $expr: { + $and: [ + { $eq: ['$deploymentId', '$$deploymentId'] }, + { $eq: ['$type', DeploymentAssignmentType.UNIT] }, + { $eq: ['$alertGroupId', null] }, + ], + }, + }, + }, + { $project: { entityId: 1, _id: 0 } }, + ], + as: 'units', + }, + }, +]); + +export abstract class DeploymentRepositoryImpl< + TEntity extends BaseDeploymentEntity, + TEntityDTO extends Partial, + TDocument extends BaseDeploymentDocument, +> implements DeploymentRepository +{ + protected constructor( + private readonly deploymentModel: Model, + private readonly mapper: Mapper, + private readonly entityTypeValue: ModelIdentifier, + private readonly documentTypeValue: ModelIdentifier, + private readonly documentDtoTypeValue: ModelIdentifier>, + private readonly entityDtoTypeValue: ModelIdentifier, + ) {} + + async findById(orgId: string, id: string): Promise { + const deployment = await this.deploymentModel + .aggregate([ + { + $match: { + orgId, + referenceId: id, + }, + }, + ...ASSIGNMENT_JOIN_PIPELINE_STEPS, + ]) + .exec(); + + if (!deployment?.[0]) { + throw new DeploymentNotFoundException(); + } + + return this.mapper.mapAsync( + deployment[0], + this.documentTypeValue, + this.entityTypeValue, + ); + } + + async findByOrgId(orgId: string, filter?: TEntityDTO): Promise { + const filterDoc = filter + ? await this.mapper.mapAsync( + filter, + this.entityDtoTypeValue, + this.documentDtoTypeValue, + ) + : {}; + + const deployments = await this.deploymentModel + .aggregate( + [ + { + $match: { + ...filterDoc, + orgId, + }, + }, + ...ASSIGNMENT_JOIN_PIPELINE_STEPS, + ], + {}, + ) + .exec(); + + return this.mapper.mapArrayAsync( + deployments, + this.documentTypeValue, + this.entityTypeValue, + ); + } + + async updateOne( + orgId: string, + id: string, + data: TEntityDTO, + uow?: DbSessionProvider, + ): Promise { + const updateDoc = await this.mapper.mapAsync( + data, + this.entityDtoTypeValue, + this.documentDtoTypeValue, + ); + + const query = this.deploymentModel.updateOne( + { + orgId, + _id: id, + } as FilterQuery, + { + $set: updateDoc, + }, + ); + + await runDbOperation(query, uow); + } +} diff --git a/libs/api/deployment/src/lib/infra/repository/deployment/rescue-station-deployment.repository.spec.ts b/libs/api/deployment/src/lib/infra/repository/deployment/rescue-station-deployment.repository.spec.ts new file mode 100644 index 00000000..1f6ddc64 --- /dev/null +++ b/libs/api/deployment/src/lib/infra/repository/deployment/rescue-station-deployment.repository.spec.ts @@ -0,0 +1,129 @@ +import { classes } from '@automapper/classes'; +import { AutomapperModule } from '@automapper/nestjs'; +import { createMock } from '@golevelup/ts-jest'; +import { getModelToken } from '@nestjs/mongoose'; +import { Test, TestingModule } from '@nestjs/testing'; +import { Model } from 'mongoose'; + +import { BaseModelProfile } from '@kordis/api/shared'; +import { mockModelMethodResults } from '@kordis/api/test-helpers'; + +import { RescueStationDeploymentEntity } from '../../../core/entity/rescue-station-deployment.entity'; +import { RescueStationEntityDTO } from '../../../core/repository/rescue-station-deployment.repository'; +import { DeploymentAggregateProfile } from '../../mapper/deployment-aggregate.mapper-profile'; +import { DeploymentAssignmentProfile } from '../../mapper/deployment-assignment.mapper-profile'; +import { + RescueStationDeploymentAggregateProfile, + RescueStationDeploymentValueObjectProfile, +} from '../../mapper/rescue-station-deployment-aggregate.mapper-profile'; +import { RescueStationDtoMapperProfile } from '../../mapper/rescue-station-dto.mapper-profile'; +import { RescueStationViewModelProfile } from '../../mapper/rescue-station-view-model.mapper'; +import { RescueStationDeploymentDocument } from '../../schema/rescue-station-deployment.schema'; +import { RescueStationDeploymentRepositoryImpl } from './rescue-station-deployment.repository'; + +describe('RescueStationDeploymentRepositoryImpl', () => { + let repository: RescueStationDeploymentRepositoryImpl; + let rescueStationDeploymentModel: Model; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + imports: [ + AutomapperModule.forRoot({ + strategyInitializer: classes(), + }), + ], + providers: [ + RescueStationDeploymentRepositoryImpl, + BaseModelProfile, + DeploymentAggregateProfile, + DeploymentAssignmentProfile, + RescueStationDeploymentAggregateProfile, + RescueStationDeploymentValueObjectProfile, + RescueStationDtoMapperProfile, + RescueStationViewModelProfile, + { + provide: getModelToken(RescueStationDeploymentDocument.name), + useValue: createMock>(), + }, + ], + }).compile(); + + repository = module.get( + RescueStationDeploymentRepositoryImpl, + ); + rescueStationDeploymentModel = module.get< + Model + >(getModelToken(RescueStationDeploymentDocument.name)); + }); + + it('should find station by id', async () => { + const orgId = 'org1'; + const id = 'id1'; + const mockRescueStation = { + _id: id, + orgId, + referenceId: id, + defaultUnitIds: ['unitId'], + }; + + mockModelMethodResults( + rescueStationDeploymentModel, + [mockRescueStation], + 'aggregate', + ); + + const result = await repository.findById(orgId, id); + expect(result).toBeInstanceOf(RescueStationDeploymentEntity); + expect(result.id).toBe(id); + expect(result.orgId).toBe(orgId); + expect(result.defaultUnits).toEqual([{ id: 'unitId' }]); + }); + + it('should find deployments by org id', async () => { + const mockDeployments = [ + { + _id: 'id1', + orgId: 'orgId', + referenceId: 'id1', + defaultUnitIds: ['unitId1'], + }, + { + _id: 'id2', + orgId: 'orgId', + referenceId: 'id2', + defaultUnitIds: ['unitId2'], + }, + ]; + + mockModelMethodResults( + rescueStationDeploymentModel, + mockDeployments, + 'aggregate', + ); + + const result = await repository.findByOrgId('orgId'); + + expect(result[0]).toBeInstanceOf(RescueStationDeploymentEntity); + expect(result[0].id).toBe('id1'); + expect(result[0].orgId).toBe('orgId'); + expect(result[0].defaultUnits).toEqual([{ id: 'unitId1' }]); + expect(result[1]).toBeInstanceOf(RescueStationDeploymentEntity); + expect(result[1].id).toBe('id2'); + expect(result[1].orgId).toBe('orgId'); + expect(result[1].defaultUnits).toEqual([{ id: 'unitId2' }]); + }); + + it('should update one deployment', async () => { + const orgId = 'org1'; + const id = 'id1'; + const data = new RescueStationEntityDTO(); + data.note = 'note'; + + await repository.updateOne(orgId, id, data); + + expect(rescueStationDeploymentModel.updateOne).toHaveBeenCalledWith( + { orgId, _id: id }, + { $set: data }, + ); + }); +}); diff --git a/libs/api/deployment/src/lib/infra/repository/deployment/rescue-station-deployment.repository.ts b/libs/api/deployment/src/lib/infra/repository/deployment/rescue-station-deployment.repository.ts new file mode 100644 index 00000000..b55c55ad --- /dev/null +++ b/libs/api/deployment/src/lib/infra/repository/deployment/rescue-station-deployment.repository.ts @@ -0,0 +1,52 @@ +import { AutoMap } from '@automapper/classes'; +import { Mapper } from '@automapper/core'; +import { getMapperToken } from '@automapper/nestjs'; +import { Inject } from '@nestjs/common'; +import { InjectModel } from '@nestjs/mongoose'; +import { Model } from 'mongoose'; + +import { RescueStationDeploymentEntity } from '../../../core/entity/rescue-station-deployment.entity'; +import { RescueStationEntityDTO } from '../../../core/repository/rescue-station-deployment.repository'; +import { + RescueStationDeploymentDocument, + RescueStationLocation, + RescueStationStrength, +} from '../../schema/rescue-station-deployment.schema'; +import { DeploymentRepositoryImpl } from './abstract-deployment.repository'; + +// this dto is mostly for mapper purposes, as we cannot map from a document to an entity directly (initializing a mongoose document would fail) +export class RescueStationDocumentDTO + implements Partial +{ + @AutoMap() + note: string; + @AutoMap() + strength: RescueStationStrength; + @AutoMap() + signedIn: boolean; + @AutoMap() + location: RescueStationLocation; + @AutoMap() + defaultUnitIds: string[]; +} + +export class RescueStationDeploymentRepositoryImpl extends DeploymentRepositoryImpl< + RescueStationDeploymentEntity, + RescueStationEntityDTO, + RescueStationDeploymentDocument +> { + constructor( + @InjectModel(RescueStationDeploymentDocument.name) + deploymentModel: Model, + @Inject(getMapperToken()) mapper: Mapper, + ) { + super( + deploymentModel, + mapper, + RescueStationDeploymentEntity, + RescueStationDeploymentDocument, + RescueStationDocumentDTO, + RescueStationEntityDTO, + ); + } +} diff --git a/libs/api/deployment/src/lib/infra/rescue-station.view-model.ts b/libs/api/deployment/src/lib/infra/rescue-station.view-model.ts new file mode 100644 index 00000000..db6cc96f --- /dev/null +++ b/libs/api/deployment/src/lib/infra/rescue-station.view-model.ts @@ -0,0 +1,25 @@ +import { Field, ObjectType, createUnionType } from '@nestjs/graphql'; + +import { + DeploymentAlertGroup, + DeploymentUnit, +} from '../core/entity/deployment.entity'; +import { RescueStationDeploymentEntity } from '../core/entity/rescue-station-deployment.entity'; + +export const DeploymentAssignment = createUnionType({ + name: 'DeploymentAssignment', + types: () => [DeploymentUnit, DeploymentAlertGroup] as const, + resolveType: (o) => { + if (o instanceof DeploymentUnit) { + return DeploymentUnit.name; + } else { + return DeploymentAlertGroup.name; + } + }, +}); + +@ObjectType('RescueStationDeployment') +export class RescueStationDeploymentViewModel extends RescueStationDeploymentEntity { + @Field(() => [DeploymentAssignment]) + assignments: (DeploymentUnit | DeploymentAlertGroup)[]; +} diff --git a/libs/api/deployment/src/lib/infra/schema/deployment-assignment.schema.ts b/libs/api/deployment/src/lib/infra/schema/deployment-assignment.schema.ts new file mode 100644 index 00000000..8eca9d7b --- /dev/null +++ b/libs/api/deployment/src/lib/infra/schema/deployment-assignment.schema.ts @@ -0,0 +1,62 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; +import { Types } from 'mongoose'; + +import { BaseDocument } from '@kordis/api/shared'; + +export enum DeploymentAssignmentType { + UNIT = 'UNIT', + ALERT_GROUP = 'ALERT_GROUP', +} + +export class DeploymentAssignmentsDocumentContract extends BaseDocument { + entityId: string; + deploymentId: Types.ObjectId | null; + type: string; +} + +@Schema({ + discriminatorKey: 'type', + timestamps: true, + collection: 'deployment-assignments', +}) +export class DeploymentAssignmentsDocument + extends BaseDocument + implements DeploymentAssignmentsDocumentContract +{ + @Prop({ index: true }) + entityId: string; + + @Prop({ default: null, type: Types.ObjectId, index: true }) + deploymentId: Types.ObjectId | null; + + @Prop({ type: String, enum: Object.values(DeploymentAssignmentType) }) + type: string; +} + +export const DeploymentAssignmentSchema = SchemaFactory.createForClass( + DeploymentAssignmentsDocument, +); + +DeploymentAssignmentSchema.index({ orgId: 1, entityId: 1 }, { unique: true }); +DeploymentAssignmentSchema.index( + { orgId: 1, deploymentId: 1 }, + { unique: true }, +); + +@Schema() +export class UnitAssignmentDocument extends DeploymentAssignmentsDocumentContract { + @Prop({ default: null, type: Types.ObjectId, index: true }) + alertGroupId: string | null; +} + +export const UnitAssignmentSchema = SchemaFactory.createForClass( + UnitAssignmentDocument, +); +UnitAssignmentSchema.index({ orgId: 1, alertGroupId: 1 }, { unique: true }); + +@Schema() +export class AlertGroupAssignmentDocument extends DeploymentAssignmentsDocumentContract {} + +export const AlertGroupAssignmentSchema = SchemaFactory.createForClass( + AlertGroupAssignmentDocument, +); diff --git a/libs/api/deployment/src/lib/infra/schema/deployment-type.enum.ts b/libs/api/deployment/src/lib/infra/schema/deployment-type.enum.ts new file mode 100644 index 00000000..ef9126a3 --- /dev/null +++ b/libs/api/deployment/src/lib/infra/schema/deployment-type.enum.ts @@ -0,0 +1,4 @@ +// used as collection discriminator +export enum DeploymentType { + RESCUE_STATION = 'RESCUE_STATION', +} diff --git a/libs/api/deployment/src/lib/infra/schema/deployment.schema.ts b/libs/api/deployment/src/lib/infra/schema/deployment.schema.ts new file mode 100644 index 00000000..2eae3e6b --- /dev/null +++ b/libs/api/deployment/src/lib/infra/schema/deployment.schema.ts @@ -0,0 +1,39 @@ +import { AutoMap } from '@automapper/classes'; +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; + +import { BaseDocument } from '@kordis/api/shared'; + +import { DeploymentType } from './deployment-type.enum'; + +// we need this class, since the discriminator documents can not extend from DeploymentDocument directly as they inherit the metadata +export abstract class DeploymentDocumentContract + extends BaseDocument + implements BaseDeploymentDocument +{ + @AutoMap() + name: string; + type: string; + referenceId: string; +} + +@Schema({ + discriminatorKey: 'type', + timestamps: true, + collection: 'deployments', +}) +export class BaseDeploymentDocument extends BaseDocument { + @Prop() + name: string; + + @Prop({ type: String, enum: Object.values(DeploymentType) }) + type: string; + + @Prop({ index: true }) + referenceId: string; +} + +export const DeploymentSchema = SchemaFactory.createForClass( + BaseDeploymentDocument, +); + +DeploymentSchema.index({ orgId: 1, referenceId: 1 }, { unique: true }); diff --git a/libs/api/deployment/src/lib/infra/schema/rescue-station-deployment.schema.ts b/libs/api/deployment/src/lib/infra/schema/rescue-station-deployment.schema.ts new file mode 100644 index 00000000..eea7cd28 --- /dev/null +++ b/libs/api/deployment/src/lib/infra/schema/rescue-station-deployment.schema.ts @@ -0,0 +1,86 @@ +import { AutoMap } from '@automapper/classes'; +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; + +import { Coordinate } from '@kordis/api/shared'; + +import { RescueStationStrength as RescueStationStrengthValueObject } from '../../core/entity/rescue-station-deployment.entity'; +import { DeploymentDocumentContract } from './deployment.schema'; + +@Schema() +export class RescueStationStrength { + @Prop() + @AutoMap() + leaders: number; + + @Prop() + @AutoMap() + subLeaders: number; + + @Prop() + @AutoMap() + helpers: number; +} + +const RescueStationStrengthSchema = SchemaFactory.createForClass( + RescueStationStrength, +); + +const RescueStationCoordinatesSchema = SchemaFactory.createForClass(Coordinate); + +@Schema() +export class RescueStationAddress { + @Prop() + @AutoMap() + street: string; + + @Prop() + @AutoMap() + city: string; + + @Prop() + @AutoMap() + postalCode: string; +} + +const RescueStationAddressSchema = + SchemaFactory.createForClass(RescueStationAddress); + +@Schema() +export class RescueStationLocation { + @Prop({ type: RescueStationCoordinatesSchema }) + coordinate: Coordinate; + + @Prop({ type: RescueStationAddressSchema }) + address: RescueStationAddress; +} + +const RescueStationLocationSchema = SchemaFactory.createForClass( + RescueStationLocation, +); + +@Schema() +export class RescueStationDeploymentDocument extends DeploymentDocumentContract { + @Prop({ type: RescueStationStrengthSchema }) + @AutoMap(() => RescueStationStrengthValueObject) + strength: RescueStationStrength; + + @Prop() + @AutoMap() + note: string; + + @Prop() + @AutoMap() + signedIn: boolean; + + @Prop({ type: RescueStationLocationSchema }) + @AutoMap(() => RescueStationLocation) + location: RescueStationLocation; + + @Prop() + @AutoMap() + defaultUnitIds: string[]; +} + +export const RescueStationDeploymentSchema = SchemaFactory.createForClass( + RescueStationDeploymentDocument, +); diff --git a/libs/api/deployment/tsconfig.json b/libs/api/deployment/tsconfig.json new file mode 100644 index 00000000..f6b8052a --- /dev/null +++ b/libs/api/deployment/tsconfig.json @@ -0,0 +1,22 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "module": "commonjs", + "forceConsistentCasingInFileNames": true, + "strict": true, + "noImplicitOverride": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "strictPropertyInitialization": false + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/libs/api/deployment/tsconfig.lib.json b/libs/api/deployment/tsconfig.lib.json new file mode 100644 index 00000000..f7abb4b6 --- /dev/null +++ b/libs/api/deployment/tsconfig.lib.json @@ -0,0 +1,16 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "declaration": true, + "types": ["node"], + "target": "es2021", + "strictNullChecks": true, + "noImplicitAny": true, + "strictBindCallApply": true, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src/**/*.ts", "../../../reset.d.ts"], + "exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"] +} diff --git a/libs/api/deployment/tsconfig.spec.json b/libs/api/deployment/tsconfig.spec.json new file mode 100644 index 00000000..136451db --- /dev/null +++ b/libs/api/deployment/tsconfig.spec.json @@ -0,0 +1,15 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "module": "commonjs", + "types": ["jest", "node"], + "esModuleInterop": true + }, + "include": [ + "jest.config.ts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.d.ts" + ] +} diff --git a/libs/api/organization/src/lib/core/entity/organization.entity.ts b/libs/api/organization/src/lib/core/entity/organization.entity.ts index 230450b8..4dd76cd1 100644 --- a/libs/api/organization/src/lib/core/entity/organization.entity.ts +++ b/libs/api/organization/src/lib/core/entity/organization.entity.ts @@ -1,33 +1,17 @@ import { AutoMap } from '@automapper/classes'; -import { Field, Float, InputType, ObjectType } from '@nestjs/graphql'; +import { Field, InputType, ObjectType } from '@nestjs/graphql'; import { Type } from 'class-transformer'; import { - IsLatitude, - IsLongitude, IsNotEmpty, IsString, Validate, ValidateNested, } from 'class-validator'; -import { BaseEntityModel } from '@kordis/api/shared'; +import { BaseEntityModel, Coordinate } from '@kordis/api/shared'; import { IsBBox } from './bbox.validator'; -@ObjectType() -@InputType('CoordinateInput') -export class Coordinate { - @IsLatitude({ message: 'Der Wert muss ein gültiger Längengrad sein.' }) - @Field(() => Float) - @AutoMap() - lat: number; - - @IsLongitude({ message: 'Der Wert muss ein gültiger Breitengrad sein.' }) - @Field(() => Float) - @AutoMap() - lon: number; -} - @ObjectType() @InputType('BBoxInput') export class BBox { diff --git a/libs/api/organization/src/lib/infra/organization.mapper-profile.ts b/libs/api/organization/src/lib/infra/organization.mapper-profile.ts index 0adfdf7d..aecf7574 100644 --- a/libs/api/organization/src/lib/infra/organization.mapper-profile.ts +++ b/libs/api/organization/src/lib/infra/organization.mapper-profile.ts @@ -3,17 +3,15 @@ import { createMap } from '@automapper/core'; import { AutomapperProfile, getMapperToken } from '@automapper/nestjs'; import { Inject, Injectable } from '@nestjs/common'; -import { BaseMapperProfile } from '@kordis/api/shared'; +import { BaseMapperProfile, Coordinate } from '@kordis/api/shared'; import { BBox as BBoxEntity, - Coordinate as CoordinateEntity, Organization as OrganizationEntity, OrganizationGeoSettings as OrganizationGeoSettingsEntity, } from '../core/entity/organization.entity'; import { BBox as BBoxDocument, - Coordinate as CoordinateDocument, OrganizationDocument, OrganizationGeoSettings as OrganizationGeoSettingsDocument, } from './schema/organization.schema'; @@ -32,7 +30,7 @@ export class OrganizationValueObjectsProfile extends AutomapperProfile { OrganizationGeoSettingsEntity, ); createMap(mapper, BBoxDocument, BBoxEntity); - createMap(mapper, CoordinateDocument, CoordinateEntity); + createMap(mapper, Coordinate, Coordinate); }; } } diff --git a/libs/api/organization/src/lib/infra/repository/organization.respository.spec.ts b/libs/api/organization/src/lib/infra/repository/organization.respository.spec.ts index 87c8c0b5..c78c775f 100644 --- a/libs/api/organization/src/lib/infra/repository/organization.respository.spec.ts +++ b/libs/api/organization/src/lib/infra/repository/organization.respository.spec.ts @@ -6,6 +6,7 @@ import { Test } from '@nestjs/testing'; import { Model } from 'mongoose'; import { + Coordinate, Mutable, SharedKernel, UNIT_OF_WORK_SERVICE, @@ -14,7 +15,6 @@ import { mockModelMethodResult } from '@kordis/api/test-helpers'; import { BBox, - Coordinate, Organization as OrganizationEntity, OrganizationGeoSettings, } from '../../core/entity/organization.entity'; diff --git a/libs/api/organization/src/lib/infra/schema/organization.schema.ts b/libs/api/organization/src/lib/infra/schema/organization.schema.ts index afe75244..4dbe3744 100644 --- a/libs/api/organization/src/lib/infra/schema/organization.schema.ts +++ b/libs/api/organization/src/lib/infra/schema/organization.schema.ts @@ -1,17 +1,7 @@ import { AutoMap } from '@automapper/classes'; import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; -import { BaseDocument } from '@kordis/api/shared'; - -@Schema({ _id: false }) -export class Coordinate { - @Prop() - @AutoMap() - lat: number; - @Prop() - @AutoMap() - lon: number; -} +import { BaseDocument, Coordinate } from '@kordis/api/shared'; @Schema({ _id: false }) export class BBox { diff --git a/libs/api/shared/src/index.ts b/libs/api/shared/src/index.ts index f2ff9254..da20c67b 100644 --- a/libs/api/shared/src/index.ts +++ b/libs/api/shared/src/index.ts @@ -2,6 +2,7 @@ export * from './lib/models/request.model'; export * from './lib/models/base-entity.model'; export * from './lib/models/base-document.model'; export * from './lib/models/validatable.model'; +export * from './lib/models/coordinate.model'; export * from './lib/models/base.mapper-profile'; export * from './lib/kernel/graphql'; export * from './lib/kernel/mongodb'; diff --git a/libs/api/shared/src/lib/models/coordinate.model.ts b/libs/api/shared/src/lib/models/coordinate.model.ts new file mode 100644 index 00000000..2e9c168b --- /dev/null +++ b/libs/api/shared/src/lib/models/coordinate.model.ts @@ -0,0 +1,21 @@ +import { AutoMap } from '@automapper/classes'; +import { Field, Float, InputType, ObjectType } from '@nestjs/graphql'; +import { Prop, Schema } from '@nestjs/mongoose'; +import { IsLatitude, IsLongitude } from 'class-validator'; + +@ObjectType() +@Schema() +@InputType('CoordinateInput') +export class Coordinate { + @Field(() => Float) + @Prop() + @IsLatitude({ message: 'Der Wert muss ein gültiger Längengrad sein.' }) + @AutoMap() + lat: number; + + @Field(() => Float) + @Prop() + @IsLongitude({ message: 'Der Wert muss ein gültiger Breitengrad sein.' }) + @AutoMap() + lon: number; +} diff --git a/tools/db/data/deployment-assignments.data.ts b/tools/db/data/deployment-assignments.data.ts new file mode 100644 index 00000000..c5fc64e1 --- /dev/null +++ b/tools/db/data/deployment-assignments.data.ts @@ -0,0 +1,77 @@ +import { Types } from 'mongoose'; + +import type { + AlertGroupAssignmentDocument, + UnitAssignmentDocument, +} from '../../../libs/api/deployment/src/lib/infra/schema/deployment-assignment.schema'; +import { CollectionData } from './collection-data.model'; + +const collectionData: CollectionData< + UnitAssignmentDocument | AlertGroupAssignmentDocument +> = { + collectionName: 'deployment-assignments', + entries: [ + { + orgId: 'dff7584efe2c174eee8bae45', + entityId: '65d7d90709cdb6f3b2082ab3', // Greif 5 + deploymentId: new Types.ObjectId('65d7e01b4ecd7d5b2d380ca4'), // EZ + type: 'UNIT', + alertGroupId: null, + }, + { + orgId: 'dff7584efe2c174eee8bae45', + entityId: '65d7d9ae8b516612650163d8', // ATV + deploymentId: null, + type: 'UNIT', + alertGroupId: null, + }, + { + orgId: 'dff7584efe2c174eee8bae45', + entityId: '65d7da8630f360f158caec53', // GWT + deploymentId: null, + type: 'UNIT', + alertGroupId: null, + }, + { + orgId: 'dff7584efe2c174eee8bae45', + entityId: '661d51d719bfd9cb73e27834', // Greif 1 + deploymentId: null, + type: 'UNIT', + alertGroupId: null, + }, + { + orgId: 'dff7584efe2c174eee8bae45', + entityId: '661d523ecc560fd0042fe40e', // GW-WR + deploymentId: null, + type: 'UNIT', + alertGroupId: null, + }, + { + orgId: 'dff7584efe2c174eee8bae45', + entityId: '661d52f2459197edda093912', // Greif 14 + deploymentId: null, + type: 'UNIT', + alertGroupId: null, + }, + { + orgId: 'dff7584efe2c174eee8bae45', + entityId: '66155eb19bceefe5e63fa651', // SEG Altona + deploymentId: new Types.ObjectId('65d7e01b4ecd7d5b2d380ca4'), + type: 'ALERT_GROUP', + }, + { + orgId: 'dff7584efe2c174eee8bae45', + entityId: '66239459ef2a6ac579f55cce', // SEG Tauchen + deploymentId: null, + type: 'ALERT_GROUP', + }, + { + orgId: 'dff7584efe2c174eee8bae45', + entityId: '662394314aab59510b80e38a', // SEG Sonar + deploymentId: null, + type: 'ALERT_GROUP', + }, + ], +}; + +export default collectionData; diff --git a/tools/db/data/deployments.data.ts b/tools/db/data/deployments.data.ts new file mode 100644 index 00000000..5aeb24e5 --- /dev/null +++ b/tools/db/data/deployments.data.ts @@ -0,0 +1,91 @@ +import { Types } from 'mongoose'; + +import { DeploymentType } from '../../../libs/api/deployment/src/lib/infra/schema/deployment-type.enum'; +import { RescueStationDeploymentDocument } from '../../../libs/api/deployment/src/lib/infra/schema/rescue-station-deployment.schema'; +import { CollectionData } from './collection-data.model'; + +const collectionData: CollectionData = { + collectionName: 'deployments', + entries: [ + { + _id: new Types.ObjectId('65d7e01b4ecd7d5b2d380ca4'), + referenceId: '65d7e01b4ecd7d5b2d380ca4', + orgId: 'dff7584efe2c174eee8bae45', + name: 'DLRG Einsatzzentrale HH', + type: DeploymentType.RESCUE_STATION, + defaultUnitIds: [], + location: { + address: { + city: 'Hamburg', + postalCode: '22559', + street: 'Rissener Ufer 29', + }, + coordinate: { + lat: 53.56397, + lon: 9.75567, + }, + }, + note: '', + signedIn: true, + strength: { + helpers: 3, + leaders: 1, + subLeaders: 1, + }, + }, + { + _id: new Types.ObjectId('6615542b3063c832feb732ab'), + referenceId: '6615542b3063c832feb732ab', + name: 'DLRG RW Hohendeich', + type: DeploymentType.RESCUE_STATION, + orgId: 'dff7584efe2c174eee8bae45', + defaultUnitIds: [], + location: { + address: { + city: 'Hamburg', + postalCode: '21037', + street: 'Hohendeich', + }, + coordinate: { + lat: 53.44475, + lon: 10.11029, + }, + }, + note: 'Notiz 123', + signedIn: true, + strength: { + helpers: 5, + leaders: 1, + subLeaders: 1, + }, + }, + { + _id: new Types.ObjectId('661d516bb912a6f426c13dea'), + referenceId: '661d516bb912a6f426c13dea', + name: 'DLRG RW Süderelbe', + type: DeploymentType.RESCUE_STATION, + orgId: 'dff7584efe2c174eee8bae45', + defaultUnitIds: ['661d52f2459197edda093912'], + location: { + address: { + city: 'Hamburg', + postalCode: '21109', + street: ' Finkenrieker Hauptdeich 5', + }, + coordinate: { + lat: 53.47438, + lon: 10.00176, + }, + }, + note: '', + signedIn: false, + strength: { + helpers: 0, + leaders: 0, + subLeaders: 0, + }, + }, + ], +}; + +export default collectionData; diff --git a/tsconfig.base.json b/tsconfig.base.json index 2013c798..ef2f218a 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -16,6 +16,7 @@ "baseUrl": ".", "paths": { "@kordis/api/auth": ["libs/api/auth/src/index.ts"], + "@kordis/api/deployment": ["libs/api/deployment/src/index.ts"], "@kordis/api/observability": ["libs/api/observability/src/index.ts"], "@kordis/api/organization": ["libs/api/organization/src/index.ts"], "@kordis/api/shared": ["libs/api/shared/src/index.ts"], @@ -30,6 +31,9 @@ "@kordis/spa/core/observability": [ "libs/spa/core/observability/src/index.ts" ], + "@kordis/spa/feature/deployment": [ + "libs/spa/feature/deployment/src/index.ts" + ], "@kordis/spa/view/auth": ["libs/spa/view/auth/src/index.ts"], "@kordis/spa/view/dashboard": ["libs/spa/view/dashboard/src/index.ts"] }