diff --git a/prisma/migrations/20230609141640_init/migration.sql b/prisma/migrations/20230623091500_init/migration.sql similarity index 95% rename from prisma/migrations/20230609141640_init/migration.sql rename to prisma/migrations/20230623091500_init/migration.sql index a413ac9..f354d9a 100644 --- a/prisma/migrations/20230609141640_init/migration.sql +++ b/prisma/migrations/20230623091500_init/migration.sql @@ -38,8 +38,8 @@ CREATE TABLE "waypoint" ( "uuid" UUID NOT NULL, "adUuid" UUID NOT NULL, "position" SMALLINT NOT NULL, - "lon" DOUBLE PRECISION NOT NULL, - "lat" DOUBLE PRECISION NOT NULL, + "lon" DECIMAL(9,6) NOT NULL, + "lat" DECIMAL(8,6) NOT NULL, "name" TEXT, "houseNumber" TEXT, "street" TEXT, diff --git a/prisma/schema.prisma b/prisma/schema.prisma index ea20ac2..91e2e9a 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -47,8 +47,8 @@ model Waypoint { uuid String @id @default(uuid()) @db.Uuid adUuid String @db.Uuid position Int @db.SmallInt - lon Float - lat Float + lon Decimal @db.Decimal(9, 6) + lat Decimal @db.Decimal(8, 6) name String? houseNumber String? street String? diff --git a/src/libs/api/paginated.response.base.ts b/src/libs/api/paginated.response.base.ts index 8e8c3d3..f11d849 100644 --- a/src/libs/api/paginated.response.base.ts +++ b/src/libs/api/paginated.response.base.ts @@ -1,8 +1,8 @@ import { Paginated } from '../ddd'; export abstract class PaginatedResponseDto extends Paginated { - readonly count: number; - readonly limit: number; + readonly total: number; + readonly perPage: number; readonly page: number; abstract readonly data: readonly T[]; } diff --git a/src/libs/db/prisma-repository.base.ts b/src/libs/db/prisma-repository.base.ts index 031262c..9f8983d 100644 --- a/src/libs/db/prisma-repository.base.ts +++ b/src/libs/db/prisma-repository.base.ts @@ -2,13 +2,16 @@ import { EventEmitter2 } from '@nestjs/event-emitter'; import { AggregateRoot, Mapper, RepositoryPort } from '../ddd'; import { ObjectLiteral } from '../types'; import { LoggerPort } from '../ports/logger.port'; -import { None, Option, Some } from 'oxide.ts'; import { PrismaRawRepositoryPort, PrismaRepositoryPort, } from '../ports/prisma-repository.port'; import { Prisma } from '@prisma/client'; -import { ConflictException, DatabaseErrorException } from '@libs/exceptions'; +import { + ConflictException, + DatabaseErrorException, + NotFoundException, +} from '@libs/exceptions'; export abstract class PrismaRepositoryBase< Aggregate extends AggregateRoot, @@ -24,12 +27,13 @@ export abstract class PrismaRepositoryBase< protected readonly logger: LoggerPort, ) {} - async findOneById(id: string, include?: any): Promise> { + async findOneById(id: string, include?: any): Promise { const entity = await this.prisma.findUnique({ where: { uuid: id }, include, }); - return entity ? Some(this.mapper.toDomain(entity)) : None; + if (entity) return this.mapper.toDomain(entity); + throw new NotFoundException('Record not found'); } async insert(entity: Aggregate): Promise { @@ -52,7 +56,6 @@ export abstract class PrismaRepositoryBase< await this.prisma.$queryRaw`SELECT 1`; return true; } catch (e) { - console.log(e); if (e instanceof Prisma.PrismaClientKnownRequestError) { throw new DatabaseErrorException(e.message); } diff --git a/src/libs/ddd/query.base.ts b/src/libs/ddd/query.base.ts new file mode 100644 index 0000000..7aba63c --- /dev/null +++ b/src/libs/ddd/query.base.ts @@ -0,0 +1,31 @@ +import { OrderBy, PaginatedQueryParams } from './repository.port'; + +/** + * Base class for regular queries + */ +export abstract class QueryBase {} + +/** + * Base class for paginated queries + */ +export abstract class PaginatedQueryBase extends QueryBase { + perPage: number; + offset: number; + orderBy: OrderBy; + page: number; + + constructor(props: PaginatedParams) { + super(); + this.perPage = props.perPage || 10; + this.offset = props.page ? props.page * this.perPage : 0; + this.page = props.page || 0; + this.orderBy = props.orderBy || { field: true, param: 'desc' }; + } +} + +// Paginated query parameters +export type PaginatedParams = Omit< + T, + 'perPage' | 'offset' | 'orderBy' | 'page' +> & + Partial>; diff --git a/src/libs/ddd/repository.port.ts b/src/libs/ddd/repository.port.ts index 8d675ce..66d8450 100644 --- a/src/libs/ddd/repository.port.ts +++ b/src/libs/ddd/repository.port.ts @@ -1,5 +1,3 @@ -import { Option } from 'oxide.ts'; - /* Most of repositories will probably need generic save/find/delete operations, so it's easier to have some shared interfaces. @@ -8,14 +6,14 @@ import { Option } from 'oxide.ts'; */ export class Paginated { - readonly count: number; - readonly limit: number; + readonly total: number; + readonly perPage: number; readonly page: number; readonly data: readonly T[]; constructor(props: Paginated) { - this.count = props.count; - this.limit = props.limit; + this.total = props.total; + this.perPage = props.perPage; this.page = props.page; this.data = props.data; } @@ -24,7 +22,7 @@ export class Paginated { export type OrderBy = { field: string | true; param: 'asc' | 'desc' }; export type PaginatedQueryParams = { - limit: number; + perPage: number; page: number; offset: number; orderBy: OrderBy; @@ -32,7 +30,7 @@ export type PaginatedQueryParams = { export interface RepositoryPort { insert(entity: Entity | Entity[]): Promise; - findOneById(id: string): Promise>; + findOneById(id: string, include?: any): Promise; healthCheck(): Promise; // findAll(): Promise; // findAllPaginated(params: PaginatedQueryParams): Promise>; diff --git a/src/modules/ad/ad.mapper.ts b/src/modules/ad/ad.mapper.ts index 571cdc9..aa21320 100644 --- a/src/modules/ad/ad.mapper.ts +++ b/src/modules/ad/ad.mapper.ts @@ -62,49 +62,49 @@ export class AdMapper fromDate: new Date(copy.fromDate), toDate: new Date(copy.toDate), monTime: copy.schedule.mon - ? this.timeConverter.dateTimeToUtc( + ? this.timeConverter.localDateTimeToUtc( copy.fromDate, copy.schedule.mon, timezone, ) : undefined, tueTime: copy.schedule.tue - ? this.timeConverter.dateTimeToUtc( + ? this.timeConverter.localDateTimeToUtc( copy.fromDate, copy.schedule.tue, timezone, ) : undefined, wedTime: copy.schedule.wed - ? this.timeConverter.dateTimeToUtc( + ? this.timeConverter.localDateTimeToUtc( copy.fromDate, copy.schedule.wed, timezone, ) : undefined, thuTime: copy.schedule.thu - ? this.timeConverter.dateTimeToUtc( + ? this.timeConverter.localDateTimeToUtc( copy.fromDate, copy.schedule.thu, timezone, ) : undefined, friTime: copy.schedule.fri - ? this.timeConverter.dateTimeToUtc( + ? this.timeConverter.localDateTimeToUtc( copy.fromDate, copy.schedule.fri, timezone, ) : undefined, satTime: copy.schedule.sat - ? this.timeConverter.dateTimeToUtc( + ? this.timeConverter.localDateTimeToUtc( copy.fromDate, copy.schedule.sat, timezone, ) : undefined, sunTime: copy.schedule.sun - ? this.timeConverter.dateTimeToUtc( + ? this.timeConverter.localDateTimeToUtc( copy.fromDate, copy.schedule.sun, timezone, @@ -143,6 +143,11 @@ export class AdMapper }; toDomain = (record: AdReadModel): AdEntity => { + const timezone = this.timezoneFinder.timezones( + record.waypoints[0].lon, + record.waypoints[0].lat, + this.defaultParams.DEFAULT_TIMEZONE, + )[0]; const entity = new AdEntity({ id: record.uuid, createdAt: new Date(record.createdAt), @@ -152,13 +157,23 @@ export class AdMapper driver: record.driver, passenger: record.passenger, frequency: Frequency[record.frequency], - fromDate: record.fromDate.toISOString(), - toDate: record.toDate.toISOString(), + fromDate: record.fromDate.toISOString().split('T')[0], + toDate: record.toDate.toISOString().split('T')[0], schedule: { mon: record.monTime?.toISOString(), tue: record.tueTime?.toISOString(), - wed: record.wedTime?.toISOString(), - thu: record.thuTime?.toISOString(), + wed: record.wedTime + ? this.timeConverter.utcDatetimeToLocalTime( + record.wedTime.toISOString(), + timezone, + ) + : undefined, + thu: record.thuTime + ? this.timeConverter.utcDatetimeToLocalTime( + record.thuTime.toISOString(), + timezone, + ) + : undefined, fri: record.friTime?.toISOString(), sat: record.satTime?.toISOString(), sun: record.sunTime?.toISOString(), @@ -198,7 +213,27 @@ export class AdMapper toResponse = (entity: AdEntity): AdResponseDto => { const props = entity.getProps(); const response = new AdResponseDto(entity); - response.uuid = props.id; + response.userId = props.userId; + response.driver = props.driver; + response.passenger = props.passenger; + response.frequency = props.frequency; + response.fromDate = props.fromDate; + response.toDate = props.toDate; + response.schedule = { ...props.schedule }; + response.marginDurations = { ...props.marginDurations }; + response.seatsProposed = props.seatsProposed; + response.seatsRequested = props.seatsRequested; + response.waypoints = props.waypoints.map((waypoint: WaypointProps) => ({ + position: waypoint.position, + name: waypoint.address.name, + houseNumber: waypoint.address.houseNumber, + street: waypoint.address.street, + postalCode: waypoint.address.postalCode, + locality: waypoint.address.locality, + country: waypoint.address.country, + lon: waypoint.address.coordinates.lon, + lat: waypoint.address.coordinates.lat, + })); return response; }; diff --git a/src/modules/ad/ad.module.ts b/src/modules/ad/ad.module.ts index 602d973..40dd238 100644 --- a/src/modules/ad/ad.module.ts +++ b/src/modules/ad/ad.module.ts @@ -20,12 +20,15 @@ import { CreateAdService } from './core/commands/create-ad/create-ad.service'; import { TimezoneFinder } from './infrastructure/timezone-finder'; import { PrismaService } from '@libs/db/prisma.service'; import { TimeConverter } from './infrastructure/time-converter'; +import { FindAdByIdGrpcController } from './interface/grpc-controllers/find-ad-by-id.grpc.controller'; +import { FindAdByIdQueryHandler } from './core/queries/find-ad-by-id/find-ad-by-id.query-handler'; @Module({ imports: [CqrsModule], - controllers: [CreateAdGrpcController], + controllers: [CreateAdGrpcController, FindAdByIdGrpcController], providers: [ CreateAdService, + FindAdByIdQueryHandler, PrismaService, AdMapper, { diff --git a/src/modules/ad/core/ports/time-converter.port.ts b/src/modules/ad/core/ports/time-converter.port.ts index feb4d2c..e48dbd0 100644 --- a/src/modules/ad/core/ports/time-converter.port.ts +++ b/src/modules/ad/core/ports/time-converter.port.ts @@ -1,8 +1,9 @@ export interface TimeConverterPort { - dateTimeToUtc( + localDateTimeToUtc( date: string, time: string, timezone: string, dst?: boolean, ): Date; + utcDatetimeToLocalTime(isoString: string, timezone: string): string; } diff --git a/src/modules/ad/core/queries/find-ad-by-id/find-ad-by-id.query-handler.ts b/src/modules/ad/core/queries/find-ad-by-id/find-ad-by-id.query-handler.ts new file mode 100644 index 0000000..547c572 --- /dev/null +++ b/src/modules/ad/core/queries/find-ad-by-id/find-ad-by-id.query-handler.ts @@ -0,0 +1,17 @@ +import { IQueryHandler, QueryHandler } from '@nestjs/cqrs'; +import { FindAdByIdQuery } from './find-ad-by-id.query'; +import { AD_REPOSITORY } from '@modules/ad/ad.di-tokens'; +import { AdRepositoryPort } from '../../ports/ad.repository.port'; +import { Inject } from '@nestjs/common'; +import { AdEntity } from '../../ad.entity'; + +@QueryHandler(FindAdByIdQuery) +export class FindAdByIdQueryHandler implements IQueryHandler { + constructor( + @Inject(AD_REPOSITORY) + private readonly repository: AdRepositoryPort, + ) {} + async execute(query: FindAdByIdQuery): Promise { + return await this.repository.findOneById(query.id, { waypoints: true }); + } +} diff --git a/src/modules/ad/core/queries/find-ad-by-id/find-ad-by-id.query.ts b/src/modules/ad/core/queries/find-ad-by-id/find-ad-by-id.query.ts index 97c8ea0..defce96 100644 --- a/src/modules/ad/core/queries/find-ad-by-id/find-ad-by-id.query.ts +++ b/src/modules/ad/core/queries/find-ad-by-id/find-ad-by-id.query.ts @@ -1,9 +1,10 @@ -import { FindAdByIdRequestDTO } from '../../../interface/queries/find-ad-by-id/dtos/find-ad-by-id.request.dto'; +import { QueryBase } from '@libs/ddd/query.base'; -export class FindAdByIdQuery { +export class FindAdByIdQuery extends QueryBase { readonly id: string; - constructor(findAdByIdRequestDTO: FindAdByIdRequestDTO) { - this.id = findAdByIdRequestDTO.id; + constructor(id: string) { + super(); + this.id = id; } } diff --git a/src/modules/ad/infrastructure/time-converter.ts b/src/modules/ad/infrastructure/time-converter.ts index ef41158..b94b14a 100644 --- a/src/modules/ad/infrastructure/time-converter.ts +++ b/src/modules/ad/infrastructure/time-converter.ts @@ -4,7 +4,7 @@ import { DateTime, TimeZone } from 'timezonecomplete'; @Injectable() export class TimeConverter implements TimeConverterPort { - dateTimeToUtc = ( + localDateTimeToUtc = ( date: string, time: string, timezone: string, @@ -21,4 +21,16 @@ export class TimeConverter implements TimeConverterPort { return undefined; } }; + + utcDatetimeToLocalTime = (isoString: string, timezone: string): string => { + try { + return new DateTime(isoString) + .convert(TimeZone.zone(timezone)) + .toString() + .split('T')[1] + .substring(0, 5); + } catch (e) { + return undefined; + } + }; } diff --git a/src/modules/ad/interface/ad.presenter.ts b/src/modules/ad/interface/ad.presenter.ts deleted file mode 100644 index 9f19a71..0000000 --- a/src/modules/ad/interface/ad.presenter.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { AutoMap } from '@automapper/classes'; - -export class AdPresenter { - @AutoMap() - id: string; -} diff --git a/src/modules/ad/interface/dtos/ad.response.dto.ts b/src/modules/ad/interface/dtos/ad.response.dto.ts index 1ce10fd..61ec321 100644 --- a/src/modules/ad/interface/dtos/ad.response.dto.ts +++ b/src/modules/ad/interface/dtos/ad.response.dto.ts @@ -1,5 +1,43 @@ import { ResponseBase } from '@libs/api/response.base'; +import { Frequency } from '@modules/ad/core/ad.types'; export class AdResponseDto extends ResponseBase { - uuid: string; + userId: string; + driver: boolean; + passenger: boolean; + frequency: Frequency; + fromDate: string; + toDate: string; + schedule: { + mon?: string; + tue?: string; + wed?: string; + thu?: string; + fri?: string; + sat?: string; + sun?: string; + }; + marginDurations: { + mon?: number; + tue?: number; + wed?: number; + thu?: number; + fri?: number; + sat?: number; + sun?: number; + }; + seatsProposed: number; + seatsRequested: number; + strict: boolean; + waypoints: { + position: number; + name?: string; + houseNumber?: string; + street?: string; + postalCode?: string; + locality?: string; + country: string; + lon: number; + lat: number; + }[]; } diff --git a/src/modules/ad/interface/grpc-controllers/create-ad.grpc.controller.ts b/src/modules/ad/interface/grpc-controllers/create-ad.grpc.controller.ts index 033263a..2e5ba6c 100644 --- a/src/modules/ad/interface/grpc-controllers/create-ad.grpc.controller.ts +++ b/src/modules/ad/interface/grpc-controllers/create-ad.grpc.controller.ts @@ -2,8 +2,7 @@ import { Controller, UsePipes } from '@nestjs/common'; import { CommandBus } from '@nestjs/cqrs'; import { GrpcMethod, RpcException } from '@nestjs/microservices'; import { RpcValidationPipe } from '../../../../utils/pipes/rpc.validation-pipe'; -import { AdPresenter } from '../ad.presenter'; -import { CreateAdRequestDTO } from './dtos/create-ad.request.dto'; +import { CreateAdRequestDto } from './dtos/create-ad.request.dto'; import { CreateAdCommand } from '../../core/commands/create-ad/create-ad.command'; import { Result, match } from 'oxide.ts'; import { AggregateID } from '@libs/ddd'; @@ -21,7 +20,7 @@ export class CreateAdGrpcController { constructor(private readonly commandBus: CommandBus) {} @GrpcMethod('AdsService', 'Create') - async create(data: CreateAdRequestDTO): Promise { + async create(data: CreateAdRequestDto): Promise { const result: Result = await this.commandBus.execute(new CreateAdCommand(data)); diff --git a/src/modules/ad/interface/grpc-controllers/dtos/address.dto.ts b/src/modules/ad/interface/grpc-controllers/dtos/address.dto.ts index fc110a8..cdd5384 100644 --- a/src/modules/ad/interface/grpc-controllers/dtos/address.dto.ts +++ b/src/modules/ad/interface/grpc-controllers/dtos/address.dto.ts @@ -1,8 +1,8 @@ import { AutoMap } from '@automapper/classes'; import { IsOptional, IsString } from 'class-validator'; -import { CoordinatesDTO } from './coordinates.dto'; +import { CoordinatesDto as CoordinatesDto } from './coordinates.dto'; -export class AddressDTO extends CoordinatesDTO { +export class AddressDto extends CoordinatesDto { @IsOptional() @AutoMap() name?: string; diff --git a/src/modules/ad/interface/grpc-controllers/dtos/coordinates.dto.ts b/src/modules/ad/interface/grpc-controllers/dtos/coordinates.dto.ts index 7bce3b1..54b4654 100644 --- a/src/modules/ad/interface/grpc-controllers/dtos/coordinates.dto.ts +++ b/src/modules/ad/interface/grpc-controllers/dtos/coordinates.dto.ts @@ -1,11 +1,19 @@ import { AutoMap } from '@automapper/classes'; +import { Transform } from 'class-transformer'; import { IsLatitude, IsLongitude } from 'class-validator'; +import { toPrecision } from './validators/to-precision'; -export class CoordinatesDTO { +export class CoordinatesDto { + @Transform(({ value }) => toPrecision(value, 6), { + toClassOnly: true, + }) @IsLongitude() @AutoMap() lon: number; + @Transform(({ value }) => toPrecision(value, 6), { + toClassOnly: true, + }) @IsLatitude() @AutoMap() lat: number; diff --git a/src/modules/ad/interface/grpc-controllers/dtos/create-ad.request.dto.ts b/src/modules/ad/interface/grpc-controllers/dtos/create-ad.request.dto.ts index 45ba314..4bd2559 100644 --- a/src/modules/ad/interface/grpc-controllers/dtos/create-ad.request.dto.ts +++ b/src/modules/ad/interface/grpc-controllers/dtos/create-ad.request.dto.ts @@ -11,15 +11,15 @@ import { IsISO8601, } from 'class-validator'; import { Transform, Type } from 'class-transformer'; -import { ScheduleDTO } from './schedule.dto'; -import { MarginDurationsDTO } from './margin-durations.dto'; -import { WaypointDTO } from './waypoint.dto'; +import { ScheduleDto } from './schedule.dto'; +import { MarginDurationsDto } from './margin-durations.dto'; +import { WaypointDto } from './waypoint.dto'; import { intToFrequency } from './validators/frequency.mapping'; import { IsSchedule } from './validators/decorators/is-schedule.validator'; import { HasValidPositionIndexes } from './validators/decorators/valid-position-indexes.validator'; import { Frequency } from '@modules/ad/core/ad.types'; -export class CreateAdRequestDTO { +export class CreateAdRequestDto { @IsUUID(4) @AutoMap() userId: string; @@ -55,17 +55,17 @@ export class CreateAdRequestDTO { @AutoMap() toDate: string; - @Type(() => ScheduleDTO) + @Type(() => ScheduleDto) @IsSchedule() @ValidateNested({ each: true }) @AutoMap() - schedule: ScheduleDTO; + schedule: ScheduleDto; @IsOptional() - @Type(() => MarginDurationsDTO) + @Type(() => MarginDurationsDto) @ValidateNested({ each: true }) @AutoMap() - marginDurations?: MarginDurationsDTO; + marginDurations?: MarginDurationsDto; @IsOptional() @IsInt() @@ -82,10 +82,11 @@ export class CreateAdRequestDTO { @AutoMap() strict?: boolean; + @Type(() => WaypointDto) @IsArray() @ArrayMinSize(2) @HasValidPositionIndexes() @ValidateNested({ each: true }) @AutoMap() - waypoints: WaypointDTO[]; + waypoints: WaypointDto[]; } diff --git a/src/modules/ad/interface/queries/find-ad-by-id/dtos/find-ad-by-id.request.dto.ts b/src/modules/ad/interface/grpc-controllers/dtos/find-ad-by-id.request.dto.ts similarity index 74% rename from src/modules/ad/interface/queries/find-ad-by-id/dtos/find-ad-by-id.request.dto.ts rename to src/modules/ad/interface/grpc-controllers/dtos/find-ad-by-id.request.dto.ts index e2a0d17..ad485fc 100644 --- a/src/modules/ad/interface/queries/find-ad-by-id/dtos/find-ad-by-id.request.dto.ts +++ b/src/modules/ad/interface/grpc-controllers/dtos/find-ad-by-id.request.dto.ts @@ -1,6 +1,6 @@ import { IsNotEmpty, IsString } from 'class-validator'; -export class FindAdByIdRequestDTO { +export class FindAdByIdRequestDto { @IsString() @IsNotEmpty() id: string; diff --git a/src/modules/ad/interface/grpc-controllers/dtos/margin-durations.dto.ts b/src/modules/ad/interface/grpc-controllers/dtos/margin-durations.dto.ts index 5b0e439..564f23d 100644 --- a/src/modules/ad/interface/grpc-controllers/dtos/margin-durations.dto.ts +++ b/src/modules/ad/interface/grpc-controllers/dtos/margin-durations.dto.ts @@ -1,7 +1,7 @@ import { AutoMap } from '@automapper/classes'; import { IsInt, IsOptional } from 'class-validator'; -export class MarginDurationsDTO { +export class MarginDurationsDto { @IsOptional() @IsInt() @AutoMap() diff --git a/src/modules/ad/interface/grpc-controllers/dtos/schedule.dto.ts b/src/modules/ad/interface/grpc-controllers/dtos/schedule.dto.ts index 3918c57..9b9e9d0 100644 --- a/src/modules/ad/interface/grpc-controllers/dtos/schedule.dto.ts +++ b/src/modules/ad/interface/grpc-controllers/dtos/schedule.dto.ts @@ -1,7 +1,7 @@ import { AutoMap } from '@automapper/classes'; import { IsOptional, IsMilitaryTime } from 'class-validator'; -export class ScheduleDTO { +export class ScheduleDto { @IsOptional() @IsMilitaryTime() @AutoMap() diff --git a/src/modules/ad/interface/grpc-controllers/dtos/validators/decorators/valid-position-indexes.validator.ts b/src/modules/ad/interface/grpc-controllers/dtos/validators/decorators/valid-position-indexes.validator.ts index 06d178a..5dede34 100644 --- a/src/modules/ad/interface/grpc-controllers/dtos/validators/decorators/valid-position-indexes.validator.ts +++ b/src/modules/ad/interface/grpc-controllers/dtos/validators/decorators/valid-position-indexes.validator.ts @@ -1,6 +1,6 @@ import { ValidateBy, ValidationOptions, buildMessage } from 'class-validator'; import { hasValidPositionIndexes } from '../waypoint-position'; -import { WaypointDTO } from '../../waypoint.dto'; +import { WaypointDto } from '../../waypoint.dto'; export const HasValidPositionIndexes = ( validationOptions?: ValidationOptions, @@ -10,7 +10,7 @@ export const HasValidPositionIndexes = ( name: '', constraints: [], validator: { - validate: (waypoints: WaypointDTO[]): boolean => + validate: (waypoints: WaypointDto[]): boolean => hasValidPositionIndexes(waypoints), defaultMessage: buildMessage( () => `invalid waypoints positions`, diff --git a/src/modules/ad/interface/grpc-controllers/dtos/validators/to-precision.ts b/src/modules/ad/interface/grpc-controllers/dtos/validators/to-precision.ts new file mode 100644 index 0000000..997e89c --- /dev/null +++ b/src/modules/ad/interface/grpc-controllers/dtos/validators/to-precision.ts @@ -0,0 +1,4 @@ +export const toPrecision = (input: number, precision: number): number => { + const multiplier = 10 ** precision; + return Math.round((input + Number.EPSILON) * multiplier) / multiplier; +}; diff --git a/src/modules/ad/interface/grpc-controllers/dtos/validators/waypoint-position.ts b/src/modules/ad/interface/grpc-controllers/dtos/validators/waypoint-position.ts index 2bb02b1..efd7300 100644 --- a/src/modules/ad/interface/grpc-controllers/dtos/validators/waypoint-position.ts +++ b/src/modules/ad/interface/grpc-controllers/dtos/validators/waypoint-position.ts @@ -1,6 +1,6 @@ -import { WaypointDTO } from '../waypoint.dto'; +import { WaypointDto } from '../waypoint.dto'; -export const hasValidPositionIndexes = (waypoints: WaypointDTO[]): boolean => { +export const hasValidPositionIndexes = (waypoints: WaypointDto[]): boolean => { if (!waypoints) return; if (waypoints.every((waypoint) => waypoint.position === undefined)) return true; diff --git a/src/modules/ad/interface/grpc-controllers/dtos/waypoint.dto.ts b/src/modules/ad/interface/grpc-controllers/dtos/waypoint.dto.ts index cb61059..40c5b52 100644 --- a/src/modules/ad/interface/grpc-controllers/dtos/waypoint.dto.ts +++ b/src/modules/ad/interface/grpc-controllers/dtos/waypoint.dto.ts @@ -1,8 +1,8 @@ import { AutoMap } from '@automapper/classes'; import { IsInt, IsOptional } from 'class-validator'; -import { AddressDTO } from './address.dto'; +import { AddressDto } from './address.dto'; -export class WaypointDTO extends AddressDTO { +export class WaypointDto extends AddressDto { @IsOptional() @IsInt() @AutoMap() diff --git a/src/modules/ad/interface/grpc-controllers/find-ad-by-id.grpc.controller.ts b/src/modules/ad/interface/grpc-controllers/find-ad-by-id.grpc.controller.ts new file mode 100644 index 0000000..b60008f --- /dev/null +++ b/src/modules/ad/interface/grpc-controllers/find-ad-by-id.grpc.controller.ts @@ -0,0 +1,38 @@ +import { Controller, UsePipes } from '@nestjs/common'; +import { QueryBus } from '@nestjs/cqrs'; +import { GrpcMethod, RpcException } from '@nestjs/microservices'; +import { RpcValidationPipe } from '@utils/pipes/rpc.validation-pipe'; +import { FindAdByIdRequestDto } from './dtos/find-ad-by-id.request.dto'; +import { FindAdByIdQuery } from '@modules/ad/core/queries/find-ad-by-id/find-ad-by-id.query'; +import { AdResponseDto } from '../dtos/ad.response.dto'; +import { AdEntity } from '@modules/ad/core/ad.entity'; +import { AdMapper } from '@modules/ad/ad.mapper'; + +@UsePipes( + new RpcValidationPipe({ + whitelist: false, + forbidUnknownValues: false, + }), +) +@Controller() +export class FindAdByIdGrpcController { + constructor( + protected readonly mapper: AdMapper, + private readonly queryBus: QueryBus, + ) {} + + @GrpcMethod('AdsService', 'FindOneById') + async findOnebyId(data: FindAdByIdRequestDto): Promise { + try { + const ad: AdEntity = await this.queryBus.execute( + new FindAdByIdQuery(data.id), + ); + return this.mapper.toResponse(ad); + } catch (e) { + throw new RpcException({ + code: e.code, + message: e.message, + }); + } + } +} diff --git a/src/modules/ad/interface/queries/find-ad-by-id/find-ad-by-id.grpc.controller.ts b/src/modules/ad/interface/queries/find-ad-by-id/find-ad-by-id.grpc.controller.ts deleted file mode 100644 index e9427d3..0000000 --- a/src/modules/ad/interface/queries/find-ad-by-id/find-ad-by-id.grpc.controller.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { Mapper } from '@automapper/core'; -import { InjectMapper } from '@automapper/nestjs'; -import { Controller, UsePipes } from '@nestjs/common'; -import { QueryBus } from '@nestjs/cqrs'; -import { GrpcMethod, RpcException } from '@nestjs/microservices'; -import { RpcValidationPipe } from '../../../../../utils/pipes/rpc.validation-pipe'; -import { FindAdByIdRequestDTO } from './dtos/find-ad-by-id.request.dto'; -import { AdPresenter } from '../../ad.presenter'; -import { FindAdByIdQuery } from '../../../core/queries/find-ad-by-id/find-ad-by-id.query'; -import { AdEntity } from '../../../core/ad.entity'; - -@UsePipes( - new RpcValidationPipe({ - whitelist: false, - forbidUnknownValues: false, - }), -) -@Controller() -export class FindAdByIdGrpcController { - constructor( - private readonly queryBus: QueryBus, - @InjectMapper() private readonly mapper: Mapper, - ) {} - - @GrpcMethod('AdsService', 'FindOneById') - async findOnebyId(data: FindAdByIdRequestDTO): Promise { - try { - const ad = await this.queryBus.execute(new FindAdByIdQuery(data)); - return this.mapper.map(ad, AdEntity, AdPresenter); - } catch (e) { - throw new RpcException({ - code: e.code, - message: e.message, - }); - } - } -} diff --git a/src/modules/ad/tests/integration/ad.repository.spec.ts b/src/modules/ad/tests/integration/ad.repository.spec.ts index 319af8d..ffd1da3 100644 --- a/src/modules/ad/tests/integration/ad.repository.spec.ts +++ b/src/modules/ad/tests/integration/ad.repository.spec.ts @@ -3,10 +3,12 @@ import { AD_REPOSITORY, PARAMS_PROVIDER, TIMEZONE_FINDER, + TIME_CONVERTER, } from '@modules/ad/ad.di-tokens'; import { AdMapper } from '@modules/ad/ad.mapper'; import { AdRepository } from '@modules/ad/infrastructure/ad.repository'; import { DefaultParamsProvider } from '@modules/ad/infrastructure/default-params-provider'; +import { TimeConverter } from '@modules/ad/infrastructure/time-converter'; import { TimezoneFinder } from '@modules/ad/infrastructure/timezone-finder'; import { ConfigModule } from '@nestjs/config'; import { EventEmitterModule } from '@nestjs/event-emitter'; @@ -274,6 +276,10 @@ describe('Ad Repository', () => { provide: TIMEZONE_FINDER, useClass: TimezoneFinder, }, + { + provide: TIME_CONVERTER, + useClass: TimeConverter, + }, ], }).compile(); prismaService = module.get(PrismaService); @@ -416,7 +422,7 @@ describe('Ad Repository', () => { waypoints: true, }); - expect(result.unwrap().id).toBe(baseUuid.uuid); + expect(result.id).toBe(baseUuid.uuid); }); // it('should return null', async () => { diff --git a/src/modules/ad/tests/unit/ad.mapper.spec.ts b/src/modules/ad/tests/unit/ad.mapper.spec.ts index 78e659d..2a6a13d 100644 --- a/src/modules/ad/tests/unit/ad.mapper.spec.ts +++ b/src/modules/ad/tests/unit/ad.mapper.spec.ts @@ -45,8 +45,8 @@ const adEntity: AdEntity = new AdEntity({ postalCode: '54000', country: 'France', coordinates: { - lat: 48.68944505415954, - lon: 6.176510296462267, + lat: 48.689445, + lon: 6.1765102, }, }, }, @@ -103,8 +103,8 @@ const adReadModel: AdReadModel = { locality: 'Nancy', postalCode: '54000', country: 'France', - lat: 48.68944505415954, - lon: 6.176510296462267, + lat: 48.689445, + lon: 6.1765102, createdAt: now, updatedAt: now, }, @@ -163,12 +163,13 @@ const mockTimezoneFinder: TimezoneFinderPort = { }; const mockTimeConverter: TimeConverterPort = { - dateTimeToUtc: jest + localDateTimeToUtc: jest .fn() // eslint-disable-next-line @typescript-eslint/no-unused-vars .mockImplementation((datetime: Date, timezone: string, dst?: boolean) => { return datetime; }), + utcDatetimeToLocalTime: jest.fn(), }; describe('Ad Mapper', () => { @@ -208,13 +209,13 @@ describe('Ad Mapper', () => { it('should map persisted data to domain entity', async () => { const mapped: AdEntity = adMapper.toDomain(adReadModel); expect(mapped.getProps().waypoints[0].address.coordinates.lat).toBe( - 48.68944505415954, + 48.689445, ); expect(mapped.getProps().waypoints[1].address.coordinates.lon).toBe(2.3522); }); it('should map domain entity to response', async () => { const mapped: AdResponseDto = adMapper.toResponse(adEntity); - expect(mapped.uuid).toBe('c160cf8c-f057-4962-841f-3ad68346df44'); + expect(mapped.id).toBe('c160cf8c-f057-4962-841f-3ad68346df44'); }); }); diff --git a/src/modules/ad/tests/unit/core/create-ad.service.spec.ts b/src/modules/ad/tests/unit/core/create-ad.service.spec.ts index 149fff5..2080706 100644 --- a/src/modules/ad/tests/unit/core/create-ad.service.spec.ts +++ b/src/modules/ad/tests/unit/core/create-ad.service.spec.ts @@ -2,8 +2,8 @@ import { Test, TestingModule } from '@nestjs/testing'; import { CreateAdService } from '@modules/ad/core/commands/create-ad/create-ad.service'; import { AD_REPOSITORY, PARAMS_PROVIDER } from '@modules/ad/ad.di-tokens'; import { DefaultParamsProviderPort } from '@modules/ad/core/ports/default-params-provider.port'; -import { WaypointDTO } from '@modules/ad/interface/grpc-controllers/dtos/waypoint.dto'; -import { CreateAdRequestDTO } from '@modules/ad/interface/grpc-controllers/dtos/create-ad.request.dto'; +import { WaypointDto } from '@modules/ad/interface/grpc-controllers/dtos/waypoint.dto'; +import { CreateAdRequestDto } from '@modules/ad/interface/grpc-controllers/dtos/create-ad.request.dto'; import { Frequency } from '@modules/ad/core/ad.types'; import { CreateAdCommand } from '@modules/ad/core/commands/create-ad/create-ad.command'; import { Result } from 'oxide.ts'; @@ -12,7 +12,7 @@ import { AdAlreadyExistsError } from '@modules/ad/core/ad.errors'; import { AdEntity } from '@modules/ad/core/ad.entity'; import { ConflictException } from '@libs/exceptions'; -const originWaypoint: WaypointDTO = { +const originWaypoint: WaypointDto = { position: 0, lon: 48.68944505415954, lat: 6.176510296462267, @@ -22,7 +22,7 @@ const originWaypoint: WaypointDTO = { postalCode: '54000', country: 'France', }; -const destinationWaypoint: WaypointDTO = { +const destinationWaypoint: WaypointDto = { position: 1, lon: 48.8566, lat: 2.3522, @@ -30,7 +30,7 @@ const destinationWaypoint: WaypointDTO = { postalCode: '75000', country: 'France', }; -const punctualCreateAdRequest: CreateAdRequestDTO = { +const punctualCreateAdRequest: CreateAdRequestDto = { userId: '4eb6a6af-ecfd-41c3-9118-473a507014d4', fromDate: '2023-12-21', toDate: '2023-12-21', diff --git a/src/modules/ad/tests/unit/infrastructure/time-converter.spec.ts b/src/modules/ad/tests/unit/infrastructure/time-converter.spec.ts index 4bd8a4d..136e272 100644 --- a/src/modules/ad/tests/unit/infrastructure/time-converter.spec.ts +++ b/src/modules/ad/tests/unit/infrastructure/time-converter.spec.ts @@ -5,48 +5,81 @@ describe('Time Converter', () => { const timeConverter: TimeConverter = new TimeConverter(); expect(timeConverter).toBeDefined(); }); - it('should convert a paris datetime to utc', () => { - const timeConverter: TimeConverter = new TimeConverter(); - const parisDate = '2023-06-22'; - const parisTime = '08:00'; - const utcDatetime = timeConverter.dateTimeToUtc( - parisDate, - parisTime, - 'Europe/Paris', - ); - expect(utcDatetime.toISOString()).toEqual('2023-06-22T06:00:00.000Z'); + + describe('localDateTimeToUtc', () => { + it('should convert a paris datetime to utc', () => { + const timeConverter: TimeConverter = new TimeConverter(); + const parisDate = '2023-06-22'; + const parisTime = '08:00'; + const utcDatetime = timeConverter.localDateTimeToUtc( + parisDate, + parisTime, + 'Europe/Paris', + ); + expect(utcDatetime.toISOString()).toEqual('2023-06-22T06:00:00.000Z'); + }); + it('should return undefined if date is invalid', () => { + const timeConverter: TimeConverter = new TimeConverter(); + const parisDate = '2023-16-22'; + const parisTime = '08:00'; + const utcDatetime = timeConverter.localDateTimeToUtc( + parisDate, + parisTime, + 'Europe/Paris', + ); + expect(utcDatetime).toBeUndefined(); + }); + it('should return undefined if time is invalid', () => { + const timeConverter: TimeConverter = new TimeConverter(); + const parisDate = '2023-06-22'; + const parisTime = '28:00'; + const utcDatetime = timeConverter.localDateTimeToUtc( + parisDate, + parisTime, + 'Europe/Paris', + ); + expect(utcDatetime).toBeUndefined(); + }); + it('should return undefined if timezone is invalid', () => { + const timeConverter: TimeConverter = new TimeConverter(); + const parisDate = '2023-06-22'; + const parisTime = '08:00'; + const utcDatetime = timeConverter.localDateTimeToUtc( + parisDate, + parisTime, + 'Foo/Bar', + ); + expect(utcDatetime).toBeUndefined(); + }); }); - it('should return undefined if date is invalid', () => { - const timeConverter: TimeConverter = new TimeConverter(); - const parisDate = '2023-16-22'; - const parisTime = '08:00'; - const utcDatetime = timeConverter.dateTimeToUtc( - parisDate, - parisTime, - 'Europe/Paris', - ); - expect(utcDatetime).toBeUndefined(); - }); - it('should return undefined if time is invalid', () => { - const timeConverter: TimeConverter = new TimeConverter(); - const parisDate = '2023-06-22'; - const parisTime = '28:00'; - const utcDatetime = timeConverter.dateTimeToUtc( - parisDate, - parisTime, - 'Europe/Paris', - ); - expect(utcDatetime).toBeUndefined(); - }); - it('should return undefined if timezone is invalid', () => { - const timeConverter: TimeConverter = new TimeConverter(); - const parisDate = '2023-06-22'; - const parisTime = '08:00'; - const utcDatetime = timeConverter.dateTimeToUtc( - parisDate, - parisTime, - 'Foo/Bar', - ); - expect(utcDatetime).toBeUndefined(); + + describe('utcDatetimeToLocalTime', () => { + it('should convert an utc datetime isostring to a paris local time', () => { + const timeConverter: TimeConverter = new TimeConverter(); + const utcDatetimeIsostring = '2023-06-22T06:25:00.000Z'; + const parisTime = timeConverter.utcDatetimeToLocalTime( + utcDatetimeIsostring, + 'Europe/Paris', + ); + expect(parisTime).toBe('08:25'); + }); + it('should return undefined if isostring input is invalid', () => { + const timeConverter: TimeConverter = new TimeConverter(); + const utcDatetimeIsostring = 'not_an_isostring'; + const parisTime = timeConverter.utcDatetimeToLocalTime( + utcDatetimeIsostring, + 'Europe/Paris', + ); + expect(parisTime).toBeUndefined(); + }); + it('should return undefined if timezone input is invalid', () => { + const timeConverter: TimeConverter = new TimeConverter(); + const utcDatetimeIsostring = '2023-06-22T06:25:00.000Z'; + const parisTime = timeConverter.utcDatetimeToLocalTime( + utcDatetimeIsostring, + 'Foo/Bar', + ); + expect(parisTime).toBeUndefined(); + }); }); }); diff --git a/src/modules/ad/tests/unit/interface/valid-position-indexes.spec.ts b/src/modules/ad/tests/unit/interface/valid-position-indexes.spec.ts index d90f99e..f7d5272 100644 --- a/src/modules/ad/tests/unit/interface/valid-position-indexes.spec.ts +++ b/src/modules/ad/tests/unit/interface/valid-position-indexes.spec.ts @@ -1,8 +1,8 @@ import { hasValidPositionIndexes } from '@modules/ad/interface/grpc-controllers/dtos/validators/waypoint-position'; -import { WaypointDTO } from '@modules/ad/interface/grpc-controllers/dtos/waypoint.dto'; +import { WaypointDto } from '@modules/ad/interface/grpc-controllers/dtos/waypoint.dto'; describe('addresses position validator', () => { - const mockAddress1: WaypointDTO = { + const mockAddress1: WaypointDto = { lon: 48.68944505415954, lat: 6.176510296462267, houseNumber: '5', @@ -11,14 +11,14 @@ describe('addresses position validator', () => { postalCode: '54000', country: 'France', }; - const mockAddress2: WaypointDTO = { + const mockAddress2: WaypointDto = { lon: 48.8566, lat: 2.3522, locality: 'Paris', postalCode: '75000', country: 'France', }; - const mockAddress3: WaypointDTO = { + const mockAddress3: WaypointDto = { lon: 49.2628, lat: 4.0347, locality: 'Reims', diff --git a/src/utils/tests/unit/rpc-validation-pipe.usecase.spec.ts b/src/utils/tests/unit/rpc-validation-pipe.usecase.spec.ts index 6f0f365..5b56535 100644 --- a/src/utils/tests/unit/rpc-validation-pipe.usecase.spec.ts +++ b/src/utils/tests/unit/rpc-validation-pipe.usecase.spec.ts @@ -1,6 +1,6 @@ import { ArgumentMetadata } from '@nestjs/common'; import { RpcValidationPipe } from '../../pipes/rpc.validation-pipe'; -import { FindAdByIdRequestDTO } from '../../../modules/ad/interface/queries/find-ad-by-id/dtos/find-ad-by-id.request.dto'; +import { FindAdByIdRequestDto } from '../../../modules/ad/interface/grpc-controllers/dtos/find-ad-by-id.request.dto'; describe('RpcValidationPipe', () => { it('should not validate request', async () => { @@ -10,10 +10,10 @@ describe('RpcValidationPipe', () => { }); const metadata: ArgumentMetadata = { type: 'body', - metatype: FindAdByIdRequestDTO, + metatype: FindAdByIdRequestDto, data: '', }; - await target.transform({}, metadata).catch((err) => { + await target.transform({}, metadata).catch((err) => { expect(err.message).toEqual('Rpc Exception'); }); });