diff --git a/src/modules/maps/domain/mappers/route/route.d.ts b/src/modules/maps/domain/mappers/route/route.d.ts new file mode 100644 index 0000000..170de96 --- /dev/null +++ b/src/modules/maps/domain/mappers/route/route.d.ts @@ -0,0 +1,15 @@ +export type TRouteRaw = { + id: string; + name: string; + path_display: object; + provider: string; + user_id: string; +}; + +export type TRouteOrm = { + id: string; + name: string; + pathDisplay: object; + provider: string; + userId: string; +}; diff --git a/src/modules/maps/domain/mappers/route/route.mapper.ts b/src/modules/maps/domain/mappers/route/route.mapper.ts new file mode 100644 index 0000000..b115bf4 --- /dev/null +++ b/src/modules/maps/domain/mappers/route/route.mapper.ts @@ -0,0 +1,33 @@ +import { Mapper } from '@core/domain/mapper'; +import { Route } from '../../route'; +import { UniqueEntityID } from '@core/domain/unique-entity-id'; +import { TRouteOrm } from './route'; + +export class RouteMap implements Mapper { + public static async toDomain(raw: any): Promise { + const routeOrError = Route.create( + { + name: raw.name, + pathDisplay: raw.path_display, + provider: raw.provider, + userId: raw.user_id, + }, + new UniqueEntityID(raw.id), + ); + + // eslint-disable-next-line no-console + routeOrError.isFailed ? console.log(routeOrError.getErrorValue()) : ''; + + return routeOrError.isSuccess ? routeOrError.getValue() : null; + } + + public static async toPersistence(route: Route): Promise { + return { + id: route.id.getStringValue(), + name: route.name, + pathDisplay: route.pathDisplay, + provider: route.provider, + userId: route.userId, + }; + } +} diff --git a/src/modules/maps/domain/route.ts b/src/modules/maps/domain/route.ts new file mode 100644 index 0000000..4a36f53 --- /dev/null +++ b/src/modules/maps/domain/route.ts @@ -0,0 +1,65 @@ +import { AggregateRootDomain } from '@core/domain/aggregate-root-domain'; +import { UniqueEntityID } from '@core/domain/unique-entity-id'; +import { RouteId } from './value-objects/route-id'; +import { Result } from '@core/logic/errors-handler'; +import { Guard } from '@core/logic/guard'; + +class PathDisplay { + overview_polyline?: string; + input_polyline?: string; + waypoint_order?: number[]; +} + +interface RouteProps { + name: string; + pathDisplay?: PathDisplay; + provider: string; + userId: string; +} + +export class Route extends AggregateRootDomain { + private constructor(props: RouteProps, id?: UniqueEntityID) { + super(props, id); + } + + get id(): RouteId { + return RouteId.create(this._id).getValue(); + } + + get name(): string { + return this.props.name; + } + + get pathDisplay(): PathDisplay { + return this.props.pathDisplay; + } + + get provider(): string { + return this.props.provider; + } + + get userId(): string { + return this.props.userId; + } + + public static create(props: RouteProps, id?: UniqueEntityID): Result { + const guardResult = Guard.againstNullOrUndefinedBulk([ + { arg: props.name, argName: 'name' }, + { arg: props.provider, argName: 'provider' }, + { arg: props.userId, argName: 'userId' }, + ]); + + if (guardResult.isFailed) { + return Result.fail(guardResult.getErrorValue()); + } + + const route = new Route( + { + ...props, + }, + id, + ); + + return Result.ok(route); + } +} diff --git a/src/modules/maps/domain/value-objects/route-id.ts b/src/modules/maps/domain/value-objects/route-id.ts new file mode 100644 index 0000000..c4e52d2 --- /dev/null +++ b/src/modules/maps/domain/value-objects/route-id.ts @@ -0,0 +1,27 @@ +import { UniqueEntityID } from '@core/domain/unique-entity-id'; +import { ValueObject } from '@core/domain/value-object'; +import { Result } from '@core/logic/errors-handler'; +import { Guard } from '@core/logic/guard'; + +export class RouteId extends ValueObject<{ value: UniqueEntityID }> { + private constructor(value: UniqueEntityID) { + super({ value }); + } + + getStringValue(): string { + return this.props.value.toString(); + } + + getValue(): UniqueEntityID { + return this.props.value; + } + + public static create(value: UniqueEntityID): Result { + const guardResult = Guard.againstNullOrUndefined(value, 'value'); + if (guardResult.isFailed) { + return Result.fail(guardResult.getErrorValue()); + } + + return Result.ok(new RouteId(value)); + } +} diff --git a/src/modules/maps/infra/restful-api/map.controller.ts b/src/modules/maps/infra/restful-api/map.controller.ts index f9a5b1e..85d66bc 100644 --- a/src/modules/maps/infra/restful-api/map.controller.ts +++ b/src/modules/maps/infra/restful-api/map.controller.ts @@ -21,13 +21,20 @@ import { InternalServerErrorException, Post, } from '@nestjs/common'; -import { QueryBus } from '@nestjs/cqrs'; +import { CommandBus, QueryBus } from '@nestjs/cqrs'; import { ApiOperation, ApiResponse } from '@nestjs/swagger'; import { GetOptimizedRouteRequestDto } from '../../usecases/get-optimized-route/get-optimized-route.dto'; +import { SaveRouteRequestDto } from '@modules/maps/usecases/save-route/save-route.dto'; +import { SaveRouteResponse } from '@modules/maps/usecases/save-route/save-route.response'; +import { SaveRouteCommand } from '@modules/maps/usecases/save-route/save-route.command'; +import { DuplicateRouteName } from '@modules/maps/usecases/save-route/save-route.errors'; @Controller('maps') export class MapController { - constructor(private readonly queryBus: QueryBus) {} + constructor( + private readonly queryBus: QueryBus, + private readonly commandBus: CommandBus, + ) {} @Post('geocode') @HttpCode(HttpStatus.OK) @@ -98,4 +105,38 @@ export class MapController { } } } + + @Post('save-route') + @HttpCode(HttpStatus.OK) + @ApiOperation({ + tags: ['maps'], + operationId: 'save-route', + summary: 'Save Route', + description: 'Save optimized route', + }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Successful', + }) + async saveRoute( + @Body() saveRouteRequestDto: SaveRouteRequestDto, + ): Promise { + const result = await this.commandBus.execute< + SaveRouteCommand, + SaveRouteResponse + >(new SaveRouteCommand(saveRouteRequestDto)); + + if (result.isSuccess()) { + return; + } else if (result.isFailure()) { + const error = result.value; + + switch (error.constructor) { + case DuplicateRouteName: + throw new BadRequestException(error.getErrorValue()); + default: + throw new InternalServerErrorException(error.getErrorValue()); + } + } + } } diff --git a/src/modules/maps/infra/restful-api/map.module.ts b/src/modules/maps/infra/restful-api/map.module.ts index 5f525b8..6572482 100644 --- a/src/modules/maps/infra/restful-api/map.module.ts +++ b/src/modules/maps/infra/restful-api/map.module.ts @@ -8,6 +8,8 @@ import { MapController } from './map.controller'; import { GoogleMapService } from '@modules/maps/services/providers/google-map.service'; import { GeocodeUseCase } from '@modules/maps/usecases/geocode/geocode.usecase'; import { GetOptimizedRouteUseCase } from '@modules/maps/usecases/get-optimized-route/get-optimized-route.usecase'; +import { TypeOrmRouteRepository } from '@modules/maps/repos/implementations/typeorm.route.repository'; +import { SaveRouteUseCase } from '@modules/maps/usecases/save-route/save-route.usecase'; @Module({ imports: [ @@ -27,10 +29,16 @@ import { GetOptimizedRouteUseCase } from '@modules/maps/usecases/get-optimized-r providers: [ GeocodeUseCase, GetOptimizedRouteUseCase, + SaveRouteUseCase, { provide: 'IMapService', useClass: GoogleMapService, }, + { + provide: 'IRouteRepository', + useClass: TypeOrmRouteRepository, + }, ], + exports: ['IRouteRepository', 'IMapService', PassportModule], }) export class MapModule {} diff --git a/src/modules/maps/repos/implementations/typeorm.route.repository.ts b/src/modules/maps/repos/implementations/typeorm.route.repository.ts new file mode 100644 index 0000000..02ae80e --- /dev/null +++ b/src/modules/maps/repos/implementations/typeorm.route.repository.ts @@ -0,0 +1,43 @@ +import { Injectable } from '@nestjs/common'; +import { IRouteRepository } from '../route.repository.interface'; +import { EntityManager } from 'typeorm'; +import { Route } from '@modules/maps/domain/route'; +import { RouteEntity } from '@database/typeorm/entities/route.entity'; +import { RouteMap } from '@modules/maps/domain/mappers/route/route.mapper'; + +@Injectable() +export class TypeOrmRouteRepository implements IRouteRepository { + constructor(private readonly model: EntityManager) {} + + async exists(name: string, userId: string): Promise { + const routeModel = this.model.getRepository(RouteEntity); + + const foundRoute = await routeModel.findOneBy({ + name: name, + userId: userId, + }); + + return !!foundRoute === true; + } + + async save(route: Route): Promise { + const routeModel = this.model.getRepository(RouteEntity); + + const routeExists = await this.exists(route.name, route.userId); + if (!routeExists) { + const rawRoute = await RouteMap.toPersistence(route); + await routeModel.save(rawRoute); + } + + return; + } + + async findOneBy(options: Record): Promise { + const routeModel = this.model.getRepository(RouteEntity); + + const foundRoute = await routeModel.findOneBy(options); + + if (!foundRoute) return null; + return RouteMap.toDomain(foundRoute); + } +} diff --git a/src/modules/maps/repos/route.repository.interface.ts b/src/modules/maps/repos/route.repository.interface.ts new file mode 100644 index 0000000..c39865b --- /dev/null +++ b/src/modules/maps/repos/route.repository.interface.ts @@ -0,0 +1,7 @@ +import { Route } from '../domain/route'; + +export interface IRouteRepository { + exists(name: string, userId: string): Promise; + save(route: Route): Promise; + findOneBy(options: Record): Promise; +} diff --git a/src/modules/maps/usecases/save-route/save-route.command.ts b/src/modules/maps/usecases/save-route/save-route.command.ts new file mode 100644 index 0000000..ecccbbc --- /dev/null +++ b/src/modules/maps/usecases/save-route/save-route.command.ts @@ -0,0 +1,5 @@ +import { SaveRouteRequestDto } from './save-route.dto'; + +export class SaveRouteCommand { + constructor(public readonly saveRouteDto: SaveRouteRequestDto) {} +} diff --git a/src/modules/maps/usecases/save-route/save-route.dto.ts b/src/modules/maps/usecases/save-route/save-route.dto.ts new file mode 100644 index 0000000..6ef3ade --- /dev/null +++ b/src/modules/maps/usecases/save-route/save-route.dto.ts @@ -0,0 +1,73 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { MapProvidersEnum } from '@shared/enum/map-providers.enum'; +import { Type } from 'class-transformer'; +import { + IsArray, + IsEnum, + IsNotEmpty, + IsOptional, + IsString, + IsUUID, + ValidateNested, +} from 'class-validator'; + +export class PathDisplayInput { + @IsString() + overview_polyline?: string; + + @IsArray() + input_coordinate?: [number, number][]; + + @IsArray() + waypoint_order?: number[]; +} + +export class SaveRouteRequestDto { + @ApiProperty({ + type: String, + example: 'Route 1', + }) + @IsString() + @IsNotEmpty() + name: string; + + @ApiProperty({ + type: PathDisplayInput, + example: { + overview_polyline: + 'g|caBqonsS`{RlfI|~^wcGbvXcr\\j~^qsZnvNkoQ`sHevNnlR{mMtne@{`D`sIcxNhdYmxJpwd@clWfqSs_Bxqf@jrFpdQc{IfmQnTxbn@nYbhh@ibPfic@gxH~eDwbHpjI`FjuJ`pDptI{}ChaKjvDzdKwxFphWcwCf|WsiHh}_@omE|b\\dcZjxh@zwPdrYu`LzcX|nV~yJ`wApaO}`In|QnxEhtOe~AbcXtpBdh_@`uOn{Sb~Nvp\\nzGpmMvyg@`Bl_m@~jSzkl@v~b@bje@dtNpuPvcEd}X_x@jgk@weA~jc@toDvjl@z~L`w', + input_coordinate: [ + [16.0815, 108.2138], + [16.4661, 107.5847], + [10.77, 106.7043], + [21.0396, 105.855], + [19.8206, 105.7687], + [20.4212, 106.1652], + [10.0342, 105.7834], + ], + waypoint_order: [0, 4, 1, 3, 2], + }, + }) + @IsOptional() + @ValidateNested() + @Type(() => PathDisplayInput) + pathDisplayInput?: PathDisplayInput; + + @ApiProperty({ + type: String, + example: 'GoogleMaps', + }) + @IsString() + @IsNotEmpty() + @IsEnum(MapProvidersEnum) + provider: MapProvidersEnum; + + @ApiProperty({ + type: String, + example: '18c1e2d6-23e6-4f75-980f-b1339hd328', + }) + @IsString() + @IsUUID() + @IsNotEmpty() + userId: string; +} diff --git a/src/modules/maps/usecases/save-route/save-route.errors.ts b/src/modules/maps/usecases/save-route/save-route.errors.ts new file mode 100644 index 0000000..f57a4b2 --- /dev/null +++ b/src/modules/maps/usecases/save-route/save-route.errors.ts @@ -0,0 +1,10 @@ +import { Result } from '@core/logic/errors-handler'; +import { UseCaseError } from '@core/logic/usecase-error'; + +export class DuplicateRouteName extends Result { + constructor(routeName: string) { + super(false, { + message: `Route name "${routeName}" already exists`, + }); + } +} diff --git a/src/modules/maps/usecases/save-route/save-route.response.ts b/src/modules/maps/usecases/save-route/save-route.response.ts new file mode 100644 index 0000000..f8a0a0e --- /dev/null +++ b/src/modules/maps/usecases/save-route/save-route.response.ts @@ -0,0 +1,8 @@ +import { Either, Result } from '@core/logic/errors-handler'; +import { UnexpectedError } from '@core/logic/application-error'; +import { DuplicateRouteName } from './save-route.errors'; + +export type SaveRouteResponse = Either< + DuplicateRouteName | UnexpectedError, + Result +>; diff --git a/src/modules/maps/usecases/save-route/save-route.usecase.ts b/src/modules/maps/usecases/save-route/save-route.usecase.ts new file mode 100644 index 0000000..fc570e8 --- /dev/null +++ b/src/modules/maps/usecases/save-route/save-route.usecase.ts @@ -0,0 +1,60 @@ +import * as polyline from '@mapbox/polyline'; +import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; +import { SaveRouteCommand } from './save-route.command'; +import { SaveRouteResponse } from './save-route.response'; +import { Inject } from '@nestjs/common'; +import { IRouteRepository } from '@modules/maps/repos/route.repository.interface'; +import { Route } from '@modules/maps/domain/route'; +import { Result, failure, success } from '@core/logic/errors-handler'; +import { DuplicateRouteName } from './save-route.errors'; +import { UnexpectedError } from '@core/logic/application-error'; + +@CommandHandler(SaveRouteCommand) +export class SaveRouteUseCase + implements ICommandHandler +{ + constructor( + @Inject('IRouteRepository') + private readonly routeRepository: IRouteRepository, + ) {} + + async execute({ + saveRouteDto, + }: SaveRouteCommand): Promise { + let route: Route; + + const routeExists = await this.routeRepository.exists( + saveRouteDto.name, + saveRouteDto.userId, + ); + + if (routeExists) { + return failure(new DuplicateRouteName(saveRouteDto.name)); + } else { + const routeOrError: Result = Route.create({ + name: saveRouteDto.name, + pathDisplay: saveRouteDto.pathDisplayInput + ? { + overview_polyline: + saveRouteDto.pathDisplayInput.overview_polyline, + waypoint_order: saveRouteDto.pathDisplayInput.waypoint_order, + input_polyline: polyline.encode( + saveRouteDto.pathDisplayInput.input_coordinate, + ), + } + : null, + provider: saveRouteDto.provider, + userId: saveRouteDto.userId, + }); + + if (routeOrError.isFailed) { + return failure(new UnexpectedError('Failed to create route')); + } + + route = routeOrError.getValue(); + await this.routeRepository.save(route); + } + + return success(Result.ok()); + } +}