diff --git a/.env.dist b/.env.dist index 151c967..fa384ef 100644 --- a/.env.dist +++ b/.env.dist @@ -23,8 +23,6 @@ CACHE_TTL=5000 # default identifier used for match requests DEFAULT_UUID=00000000-0000-0000-0000-000000000000 -# default timezone -DEFAULT_TIMEZONE=Europe/Paris # default number of seats proposed as driver DEFAULT_SEATS=3 # algorithm type diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 06df3e5..17ea1a0 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -15,7 +15,6 @@ datasource db { model Ad { uuid String @id @db.Uuid - userUuid String @db.Uuid driver Boolean passenger Boolean frequency Frequency diff --git a/src/app.module.ts b/src/app.module.ts index e52d97c..2df816c 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -13,6 +13,7 @@ import { AD_REPOSITORY } from '@modules/ad/ad.di-tokens'; import { MESSAGE_PUBLISHER } from '@modules/messager/messager.di-tokens'; import { HealthModuleOptions } from '@mobicoop/health-module/dist/core/domain/types/health.types'; import { MessagePublisherPort } from '@mobicoop/ddd-library'; +import { GeographyModule } from '@modules/geography/geography.module'; @Module({ imports: [ @@ -59,6 +60,7 @@ import { MessagePublisherPort } from '@mobicoop/ddd-library'; }), }), AdModule, + GeographyModule, MessagerModule, ], exports: [AdModule, MessagerModule], diff --git a/src/modules/ad/ad.di-tokens.ts b/src/modules/ad/ad.di-tokens.ts index 1750029..d87cfdd 100644 --- a/src/modules/ad/ad.di-tokens.ts +++ b/src/modules/ad/ad.di-tokens.ts @@ -1,5 +1,3 @@ -export const AD_MESSAGE_PUBLISHER = Symbol('AD_MESSAGE_PUBLISHER'); -export const PARAMS_PROVIDER = Symbol('PARAMS_PROVIDER'); -export const TIMEZONE_FINDER = Symbol('TIMEZONE_FINDER'); -export const GEOROUTER_CREATOR = Symbol('GEOROUTER_CREATOR'); export const AD_REPOSITORY = Symbol('AD_REPOSITORY'); +export const GEOROUTER = Symbol('GEOROUTER'); +export const AD_MESSAGE_PUBLISHER = Symbol('AD_MESSAGE_PUBLISHER'); diff --git a/src/modules/ad/ad.mapper.ts b/src/modules/ad/ad.mapper.ts index 361e1f0..b4915c7 100644 --- a/src/modules/ad/ad.mapper.ts +++ b/src/modules/ad/ad.mapper.ts @@ -26,7 +26,6 @@ export class AdMapper const now = new Date(); const record: AdWriteModel = { uuid: copy.id, - userUuid: copy.userId, driver: copy.driver, passenger: copy.passenger, frequency: copy.frequency, @@ -55,8 +54,8 @@ export class AdMapper driverDistance: copy.driverDistance, passengerDuration: copy.passengerDuration, passengerDistance: copy.passengerDistance, - waypoints: copy.waypoints, - direction: copy.direction, + waypoints: '', + direction: '', fwdAzimuth: copy.fwdAzimuth, backAzimuth: copy.backAzimuth, createdAt: copy.createdAt, @@ -71,7 +70,6 @@ export class AdMapper createdAt: new Date(record.createdAt), updatedAt: new Date(record.updatedAt), props: { - userId: record.userUuid, driver: record.driver, passenger: record.passenger, frequency: Frequency[record.frequency], @@ -95,8 +93,7 @@ export class AdMapper driverDistance: record.driverDistance, passengerDuration: record.passengerDuration, passengerDistance: record.passengerDistance, - waypoints: record.waypoints, - direction: record.direction, + waypoints: [], fwdAzimuth: record.fwdAzimuth, backAzimuth: record.backAzimuth, }, diff --git a/src/modules/ad/ad.module.ts b/src/modules/ad/ad.module.ts index 1e85849..c846631 100644 --- a/src/modules/ad/ad.module.ts +++ b/src/modules/ad/ad.module.ts @@ -1,17 +1,13 @@ import { Module, Provider } from '@nestjs/common'; import { CqrsModule } from '@nestjs/cqrs'; -import { - AD_MESSAGE_PUBLISHER, - AD_REPOSITORY, - PARAMS_PROVIDER, - TIMEZONE_FINDER, -} from './ad.di-tokens'; +import { AD_MESSAGE_PUBLISHER, AD_REPOSITORY } from './ad.di-tokens'; import { MessageBrokerPublisher } from '@mobicoop/message-broker-module'; import { AdRepository } from './infrastructure/ad.repository'; import { PrismaService } from './infrastructure/prisma.service'; -import { DefaultParamsProvider } from './infrastructure/default-params-provider'; -import { TimezoneFinder } from './infrastructure/timezone-finder'; import { AdMapper } from './ad.mapper'; +import { AdCreatedMessageHandler } from './interface/message-handlers/ad-created.message-handler'; + +const messageHandlers = [AdCreatedMessageHandler]; const mappers: Provider[] = [AdMapper]; @@ -30,32 +26,15 @@ const messagePublishers: Provider[] = [ ]; const orms: Provider[] = [PrismaService]; -const adapters: Provider[] = [ - { - provide: PARAMS_PROVIDER, - useClass: DefaultParamsProvider, - }, - { - provide: TIMEZONE_FINDER, - useClass: TimezoneFinder, - }, -]; - @Module({ imports: [CqrsModule], providers: [ + ...messageHandlers, ...mappers, ...repositories, ...messagePublishers, ...orms, - ...adapters, - ], - exports: [ - PrismaService, - AdMapper, - AD_REPOSITORY, - PARAMS_PROVIDER, - TIMEZONE_FINDER, ], + exports: [PrismaService, AdMapper, AD_REPOSITORY], }) export class AdModule {} diff --git a/src/modules/ad/core/application/commands/create-ad/create-ad.command.ts b/src/modules/ad/core/application/commands/create-ad/create-ad.command.ts new file mode 100644 index 0000000..3b1e695 --- /dev/null +++ b/src/modules/ad/core/application/commands/create-ad/create-ad.command.ts @@ -0,0 +1,33 @@ +import { Frequency } from '@modules/ad/core/domain/ad.types'; +import { Command, CommandProps } from '@mobicoop/ddd-library'; +import { ScheduleItem } from '../../types/schedule-item.type'; +import { Waypoint } from '../../types/waypoint.type'; + +export class CreateAdCommand extends Command { + readonly id: string; + readonly driver: boolean; + readonly passenger: boolean; + readonly frequency: Frequency; + readonly fromDate: string; + readonly toDate: string; + readonly schedule: ScheduleItem[]; + readonly seatsProposed: number; + readonly seatsRequested: number; + readonly strict: boolean; + readonly waypoints: Waypoint[]; + + constructor(props: CommandProps) { + super(props); + this.id = props.id; + this.driver = props.driver; + this.passenger = props.passenger; + this.frequency = props.frequency; + this.fromDate = props.fromDate; + this.toDate = props.toDate; + this.schedule = props.schedule; + this.seatsProposed = props.seatsProposed; + this.seatsRequested = props.seatsRequested; + this.strict = props.strict; + this.waypoints = props.waypoints; + } +} diff --git a/src/modules/ad/core/application/commands/create-ad/create-ad.service.ts b/src/modules/ad/core/application/commands/create-ad/create-ad.service.ts new file mode 100644 index 0000000..776e43d --- /dev/null +++ b/src/modules/ad/core/application/commands/create-ad/create-ad.service.ts @@ -0,0 +1,42 @@ +import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; +import { CreateAdCommand } from './create-ad.command'; +import { Inject } from '@nestjs/common'; +import { AD_REPOSITORY } from '@modules/ad/ad.di-tokens'; +import { AdEntity } from '@modules/ad/core/domain/ad.entity'; +import { AdRepositoryPort } from '../../ports/ad.repository.port'; +import { AggregateID, ConflictException } from '@mobicoop/ddd-library'; +import { AdAlreadyExistsException } from '@modules/ad/core/domain/ad.errors'; + +@CommandHandler(CreateAdCommand) +export class CreateAdService implements ICommandHandler { + constructor( + @Inject(AD_REPOSITORY) + private readonly repository: AdRepositoryPort, + ) {} + + async execute(command: CreateAdCommand): Promise { + const ad = AdEntity.create({ + id: command.id, + driver: command.driver, + passenger: command.passenger, + frequency: command.frequency, + fromDate: command.fromDate, + toDate: command.toDate, + schedule: command.schedule, + seatsProposed: command.seatsProposed, + seatsRequested: command.seatsRequested, + strict: command.strict, + waypoints: command.waypoints, + }); + + try { + await this.repository.insert(ad); + return ad.id; + } catch (error: any) { + if (error instanceof ConflictException) { + throw new AdAlreadyExistsException(error); + } + throw error; + } + } +} diff --git a/src/modules/ad/core/application/ports/timezone-finder.port.ts b/src/modules/ad/core/application/ports/timezone-finder.port.ts deleted file mode 100644 index 72ba115..0000000 --- a/src/modules/ad/core/application/ports/timezone-finder.port.ts +++ /dev/null @@ -1,3 +0,0 @@ -export interface TimezoneFinderPort { - timezones(lon: number, lat: number, defaultTimezone?: string): string[]; -} diff --git a/src/modules/ad/core/application/types/schedule-item.type.ts b/src/modules/ad/core/application/types/schedule-item.type.ts new file mode 100644 index 0000000..92dab99 --- /dev/null +++ b/src/modules/ad/core/application/types/schedule-item.type.ts @@ -0,0 +1,5 @@ +export type ScheduleItem = { + day: number; + time: string; + margin: number; +}; diff --git a/src/modules/ad/core/application/types/waypoint.type.ts b/src/modules/ad/core/application/types/waypoint.type.ts new file mode 100644 index 0000000..f3e4a99 --- /dev/null +++ b/src/modules/ad/core/application/types/waypoint.type.ts @@ -0,0 +1,8 @@ +import { PointContext } from '../../domain/ad.types'; + +export type Waypoint = { + position: number; + context?: PointContext; + lon: number; + lat: number; +}; diff --git a/src/modules/ad/core/domain/ad.entity.ts b/src/modules/ad/core/domain/ad.entity.ts index fc51d13..011f7c7 100644 --- a/src/modules/ad/core/domain/ad.entity.ts +++ b/src/modules/ad/core/domain/ad.entity.ts @@ -6,8 +6,7 @@ export class AdEntity extends AggregateRoot { static create = (create: CreateAdProps): AdEntity => { const props: AdProps = { ...create }; - const ad = new AdEntity({ id: create.id, props }); - return ad; + return new AdEntity({ id: create.id, props }); }; validate(): void { diff --git a/src/modules/ad/core/domain/ad.errors.ts b/src/modules/ad/core/domain/ad.errors.ts new file mode 100644 index 0000000..7d14547 --- /dev/null +++ b/src/modules/ad/core/domain/ad.errors.ts @@ -0,0 +1,11 @@ +import { ExceptionBase } from '@mobicoop/ddd-library'; + +export class AdAlreadyExistsException extends ExceptionBase { + static readonly message = 'Ad already exists'; + + public readonly code = 'AD.ALREADY_EXISTS'; + + constructor(cause?: Error, metadata?: unknown) { + super(AdAlreadyExistsException.message, cause, metadata); + } +} diff --git a/src/modules/ad/core/domain/ad.types.ts b/src/modules/ad/core/domain/ad.types.ts index a3dbb42..8afef95 100644 --- a/src/modules/ad/core/domain/ad.types.ts +++ b/src/modules/ad/core/domain/ad.types.ts @@ -1,8 +1,8 @@ import { ScheduleItemProps } from './value-objects/schedule-item.value-object'; +import { WaypointProps } from './value-objects/waypoint.value-object'; // All properties that an Ad has export interface AdProps { - userId: string; driver: boolean; passenger: boolean; frequency: Frequency; @@ -12,20 +12,18 @@ export interface AdProps { seatsProposed: number; seatsRequested: number; strict: boolean; - driverDuration: number; - driverDistance: number; - passengerDuration: number; - passengerDistance: number; - waypoints: string; - direction: string; - fwdAzimuth: number; - backAzimuth: number; + driverDuration?: number; + driverDistance?: number; + passengerDuration?: number; + passengerDistance?: number; + waypoints: WaypointProps[]; + fwdAzimuth?: number; + backAzimuth?: number; } // Properties that are needed for an Ad creation export interface CreateAdProps { id: string; - userId: string; driver: boolean; passenger: boolean; frequency: Frequency; @@ -35,17 +33,18 @@ export interface CreateAdProps { seatsProposed: number; seatsRequested: number; strict: boolean; - driverDuration: number; - driverDistance: number; - passengerDuration: number; - passengerDistance: number; - waypoints: string; - direction: string; - fwdAzimuth: number; - backAzimuth: number; + waypoints: WaypointProps[]; } export enum Frequency { PUNCTUAL = 'PUNCTUAL', RECURRENT = 'RECURRENT', } + +export enum PointContext { + HOUSE_NUMBER = 'HOUSE_NUMBER', + STREET_ADDRESS = 'STREET_ADDRESS', + LOCALITY = 'LOCALITY', + VENUE = 'VENUE', + OTHER = 'OTHER', +} diff --git a/src/modules/ad/core/domain/value-objects/schedule-item.value-object.ts b/src/modules/ad/core/domain/value-objects/schedule-item.value-object.ts index 8303eeb..5f2d66b 100644 --- a/src/modules/ad/core/domain/value-objects/schedule-item.value-object.ts +++ b/src/modules/ad/core/domain/value-objects/schedule-item.value-object.ts @@ -1,4 +1,8 @@ -import { ValueObject } from '@mobicoop/ddd-library'; +import { + ArgumentInvalidException, + ArgumentOutOfRangeException, + ValueObject, +} from '@mobicoop/ddd-library'; /** Note: * Value Objects with multiple properties can contain @@ -6,9 +10,9 @@ import { ValueObject } from '@mobicoop/ddd-library'; * */ export interface ScheduleItemProps { - day?: number; + day: number; time: string; - margin?: number; + margin: number; } export class ScheduleItem extends ValueObject { @@ -26,6 +30,19 @@ export class ScheduleItem extends ValueObject { // eslint-disable-next-line @typescript-eslint/no-unused-vars protected validate(props: ScheduleItemProps): void { - return; + if (props.day < 0 || props.day > 6) + throw new ArgumentOutOfRangeException('day must be between 0 and 6'); + if (props.time.split(':').length != 2) + throw new ArgumentInvalidException('time is invalid'); + if ( + parseInt(props.time.split(':')[0]) < 0 || + parseInt(props.time.split(':')[0]) > 23 + ) + throw new ArgumentInvalidException('time is invalid'); + if ( + parseInt(props.time.split(':')[1]) < 0 || + parseInt(props.time.split(':')[1]) > 59 + ) + throw new ArgumentInvalidException('time is invalid'); } } diff --git a/src/modules/ad/core/domain/value-objects/waypoint.value-object.ts b/src/modules/ad/core/domain/value-objects/waypoint.value-object.ts new file mode 100644 index 0000000..7dcadc2 --- /dev/null +++ b/src/modules/ad/core/domain/value-objects/waypoint.value-object.ts @@ -0,0 +1,47 @@ +import { + ArgumentInvalidException, + ArgumentOutOfRangeException, + ValueObject, +} from '@mobicoop/ddd-library'; +import { PointContext } from '../ad.types'; + +/** Note: + * Value Objects with multiple properties can contain + * other Value Objects inside if needed. + * */ + +export interface WaypointProps { + position: number; + lon: number; + lat: number; + context?: PointContext; +} + +export class Waypoint extends ValueObject { + get position(): number { + return this.props.position; + } + + get lon(): number { + return this.props.lon; + } + + get lat(): number { + return this.props.lat; + } + + get context(): PointContext { + return this.props.context; + } + + protected validate(props: WaypointProps): void { + if (props.position < 0) + throw new ArgumentInvalidException( + 'position must be greater than or equal to 0', + ); + if (props.lon > 180 || props.lon < -180) + throw new ArgumentOutOfRangeException('lon must be between -180 and 180'); + if (props.lat > 90 || props.lat < -90) + throw new ArgumentOutOfRangeException('lat must be between -90 and 90'); + } +} diff --git a/src/modules/ad/infrastructure/ad.repository.ts b/src/modules/ad/infrastructure/ad.repository.ts index 5a8d4e5..afa5d7e 100644 --- a/src/modules/ad/infrastructure/ad.repository.ts +++ b/src/modules/ad/infrastructure/ad.repository.ts @@ -13,7 +13,6 @@ import { AdMapper } from '../ad.mapper'; export type AdBaseModel = { uuid: string; - userUuid: string; driver: boolean; passenger: boolean; frequency: string; diff --git a/src/modules/ad/infrastructure/timezone-finder.ts b/src/modules/ad/infrastructure/timezone-finder.ts deleted file mode 100644 index feb0b5a..0000000 --- a/src/modules/ad/infrastructure/timezone-finder.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { find } from 'geo-tz'; -import { TimezoneFinderPort } from '../core/application/ports/timezone-finder.port'; - -@Injectable() -export class TimezoneFinder implements TimezoneFinderPort { - timezones = ( - lon: number, - lat: number, - defaultTimezone?: string, - ): string[] => { - const foundTimezones = find(lat, lon); - if (defaultTimezone && foundTimezones.length == 0) return [defaultTimezone]; - return foundTimezones; - }; -} diff --git a/src/modules/ad/interface/message-handlers/ad-created.message-handler.ts b/src/modules/ad/interface/message-handlers/ad-created.message-handler.ts new file mode 100644 index 0000000..ed5dc61 --- /dev/null +++ b/src/modules/ad/interface/message-handlers/ad-created.message-handler.ts @@ -0,0 +1,34 @@ +import { Injectable } from '@nestjs/common'; +import { RabbitSubscribe } from '@mobicoop/message-broker-module'; +import { CommandBus } from '@nestjs/cqrs'; +import { CreateAdCommand } from '@modules/ad/core/application/commands/create-ad/create-ad.command'; +import { Ad } from './ad.types'; + +@Injectable() +export class AdCreatedMessageHandler { + constructor(private readonly commandBus: CommandBus) {} + + @RabbitSubscribe({ + name: 'adCreated', + }) + public async adCreated(message: string) { + const createdAd: Ad = JSON.parse(message); + try { + await this.commandBus.execute( + new CreateAdCommand({ + id: createdAd.id, + driver: createdAd.driver, + passenger: createdAd.passenger, + frequency: createdAd.frequency, + fromDate: createdAd.fromDate, + toDate: createdAd.toDate, + schedule: createdAd.schedule, + seatsProposed: createdAd.seatsProposed, + seatsRequested: createdAd.seatsRequested, + strict: createdAd.strict, + waypoints: createdAd.waypoints, + }), + ); + } catch (e: any) {} + } +} diff --git a/src/modules/ad/interface/message-handlers/ad.types.ts b/src/modules/ad/interface/message-handlers/ad.types.ts new file mode 100644 index 0000000..f795309 --- /dev/null +++ b/src/modules/ad/interface/message-handlers/ad.types.ts @@ -0,0 +1,35 @@ +import { Frequency, PointContext } from '@modules/ad/core/domain/ad.types'; + +export type Ad = { + id: string; + userId: string; + driver: boolean; + passenger: boolean; + frequency: Frequency; + fromDate: string; + toDate: string; + schedule: ScheduleItem[]; + seatsProposed: number; + seatsRequested: number; + strict: boolean; + waypoints: Waypoint[]; +}; + +export type ScheduleItem = { + day: number; + time: string; + margin: number; +}; + +export type Waypoint = { + position: number; + name?: string; + houseNumber?: string; + street?: string; + locality?: string; + postalCode?: string; + country: string; + lon: number; + lat: number; + context?: PointContext; +}; diff --git a/src/modules/ad/tests/unit/ad.mapper.spec.ts b/src/modules/ad/tests/unit/ad.mapper.spec.ts new file mode 100644 index 0000000..bbb1143 --- /dev/null +++ b/src/modules/ad/tests/unit/ad.mapper.spec.ts @@ -0,0 +1,107 @@ +import { AdMapper } from '@modules/ad/ad.mapper'; +import { AdEntity } from '@modules/ad/core/domain/ad.entity'; +import { Frequency } from '@modules/ad/core/domain/ad.types'; +import { + AdReadModel, + AdWriteModel, +} from '@modules/ad/infrastructure/ad.repository'; +import { Test } from '@nestjs/testing'; + +const now = new Date('2023-06-21 06:00:00'); +const adEntity: AdEntity = new AdEntity({ + id: 'c160cf8c-f057-4962-841f-3ad68346df44', + props: { + driver: true, + passenger: true, + frequency: Frequency.PUNCTUAL, + fromDate: '2023-06-21', + toDate: '2023-06-21', + schedule: [ + { + day: 3, + time: '07:15', + margin: 900, + }, + ], + waypoints: [ + { + position: 0, + lat: 48.689445, + lon: 6.1765102, + }, + { + position: 1, + lat: 48.8566, + lon: 2.3522, + }, + ], + strict: false, + seatsProposed: 3, + seatsRequested: 1, + }, + createdAt: now, + updatedAt: now, +}); + +const adReadModel: AdReadModel = { + uuid: 'c160cf8c-f057-4962-841f-3ad68346df44', + driver: true, + passenger: true, + frequency: Frequency.PUNCTUAL, + fromDate: new Date('2023-06-21'), + toDate: new Date('2023-06-21'), + schedule: [ + { + uuid: '3978f3d6-560f-4a8f-83ba-9bf5aa9a2d27', + day: 3, + time: new Date('2023-06-21T07:05:00Z'), + margin: 900, + createdAt: now, + updatedAt: now, + }, + ], + waypoints: "'LINESTRING(6.1765102 48.689445,2.3522 48.8566)'", + direction: + "'LINESTRING(6.1765102 48.689445,5.12345 48.76543,2.3522 48.8566)'", + driverDistance: 350000, + driverDuration: 14400, + passengerDistance: 350000, + passengerDuration: 14400, + fwdAzimuth: 273, + backAzimuth: 93, + strict: false, + seatsProposed: 3, + seatsRequested: 1, + createdAt: now, + updatedAt: now, +}; + +describe('Ad Mapper', () => { + let adMapper: AdMapper; + + beforeAll(async () => { + const module = await Test.createTestingModule({ + providers: [AdMapper], + }).compile(); + adMapper = module.get(AdMapper); + }); + + it('should be defined', () => { + expect(adMapper).toBeDefined(); + }); + + it('should map domain entity to persistence data', async () => { + const mapped: AdWriteModel = adMapper.toPersistence(adEntity); + expect(mapped.schedule.create.length).toBe(1); + }); + + it('should map persisted data to domain entity', async () => { + const mapped: AdEntity = adMapper.toDomain(adReadModel); + expect(mapped.getProps().schedule.length).toBe(1); + expect(mapped.getProps().schedule[0].time).toBe('07:05'); + }); + + it('should map domain entity to response', async () => { + expect(adMapper.toResponse(adEntity)).toBeUndefined(); + }); +}); diff --git a/src/modules/ad/tests/unit/core/ad.entity.spec.ts b/src/modules/ad/tests/unit/core/ad.entity.spec.ts new file mode 100644 index 0000000..899e3f0 --- /dev/null +++ b/src/modules/ad/tests/unit/core/ad.entity.spec.ts @@ -0,0 +1,47 @@ +import { AdEntity } from '@modules/ad/core/domain/ad.entity'; +import { CreateAdProps, Frequency } from '@modules/ad/core/domain/ad.types'; +import { WaypointProps } from '@modules/ad/core/domain/value-objects/waypoint.value-object'; + +const originWaypointProps: WaypointProps = { + position: 0, + lon: 48.689445, + lat: 6.17651, +}; +const destinationWaypointProps: WaypointProps = { + position: 1, + lon: 48.8566, + lat: 2.3522, +}; + +const createAdProps: CreateAdProps = { + id: 'e8fe64b1-4c33-49e1-9f69-4db48b21df36', + driver: true, + passenger: true, + fromDate: '2023-06-21', + toDate: '2023-06-21', + schedule: [ + { + day: 3, + time: '08:30', + margin: 900, + }, + ], + frequency: Frequency.PUNCTUAL, + seatsProposed: 3, + seatsRequested: 1, + strict: false, + waypoints: [originWaypointProps, destinationWaypointProps], +}; + +describe('Ad entity create', () => { + it('should create a new entity', async () => { + const ad: AdEntity = AdEntity.create(createAdProps); + expect(ad.id.length).toBe(36); + expect(ad.getProps().schedule.length).toBe(1); + expect(ad.getProps().schedule[0].day).toBe(3); + expect(ad.getProps().schedule[0].time).toBe('08:30'); + expect(ad.getProps().driver).toBeTruthy(); + expect(ad.getProps().passenger).toBeTruthy(); + expect(ad.getProps().driverDistance).toBeUndefined(); + }); +}); 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 new file mode 100644 index 0000000..74f26e6 --- /dev/null +++ b/src/modules/ad/tests/unit/core/create-ad.service.spec.ts @@ -0,0 +1,103 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { AD_REPOSITORY } from '@modules/ad/ad.di-tokens'; +import { AggregateID } from '@mobicoop/ddd-library'; +import { AdEntity } from '@modules/ad/core/domain/ad.entity'; +import { ConflictException } from '@mobicoop/ddd-library'; +import { CreateAdProps, Frequency } from '@modules/ad/core/domain/ad.types'; +import { CreateAdService } from '@modules/ad/core/application/commands/create-ad/create-ad.service'; +import { CreateAdCommand } from '@modules/ad/core/application/commands/create-ad/create-ad.command'; +import { AdAlreadyExistsException } from '@modules/ad/core/domain/ad.errors'; +import { WaypointProps } from '@modules/ad/core/domain/value-objects/waypoint.value-object'; + +const originWaypoint: WaypointProps = { + position: 0, + lon: 48.689445, + lat: 6.17651, +}; +const destinationWaypoint: WaypointProps = { + position: 1, + lon: 48.8566, + lat: 2.3522, +}; +const createAdProps: CreateAdProps = { + id: '4eb6a6af-ecfd-41c3-9118-473a507014d4', + fromDate: '2023-12-21', + toDate: '2023-12-21', + schedule: [ + { + day: 4, + time: '08:15', + margin: 900, + }, + ], + driver: true, + passenger: true, + seatsProposed: 3, + seatsRequested: 1, + strict: false, + frequency: Frequency.PUNCTUAL, + waypoints: [originWaypoint, destinationWaypoint], +}; + +const mockAdRepository = { + insert: jest + .fn() + .mockImplementationOnce(() => ({})) + .mockImplementationOnce(() => { + throw new Error(); + }) + .mockImplementationOnce(() => { + throw new ConflictException('already exists'); + }), +}; + +describe('create-ad.service', () => { + let createAdService: CreateAdService; + + beforeAll(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + { + provide: AD_REPOSITORY, + useValue: mockAdRepository, + }, + CreateAdService, + ], + }).compile(); + + createAdService = module.get(CreateAdService); + }); + + it('should be defined', () => { + expect(createAdService).toBeDefined(); + }); + + describe('execution', () => { + const createAdCommand = new CreateAdCommand(createAdProps); + it('should create a new ad', async () => { + AdEntity.create = jest.fn().mockReturnValue({ + id: '047a6ecf-23d4-4d3e-877c-3225d560a8da', + }); + const result: AggregateID = await createAdService.execute( + createAdCommand, + ); + expect(result).toBe('047a6ecf-23d4-4d3e-877c-3225d560a8da'); + }); + it('should throw an error if something bad happens', async () => { + AdEntity.create = jest.fn().mockReturnValue({ + id: '047a6ecf-23d4-4d3e-877c-3225d560a8da', + }); + await expect( + createAdService.execute(createAdCommand), + ).rejects.toBeInstanceOf(Error); + }); + it('should throw an exception if Ad already exists', async () => { + AdEntity.create = jest.fn().mockReturnValue({ + id: '047a6ecf-23d4-4d3e-877c-3225d560a8da', + }); + await expect( + createAdService.execute(createAdCommand), + ).rejects.toBeInstanceOf(AdAlreadyExistsException); + }); + }); +}); diff --git a/src/modules/ad/tests/unit/core/schedule-item.value-object.spec.ts b/src/modules/ad/tests/unit/core/schedule-item.value-object.spec.ts new file mode 100644 index 0000000..65bacc9 --- /dev/null +++ b/src/modules/ad/tests/unit/core/schedule-item.value-object.spec.ts @@ -0,0 +1,62 @@ +import { + ArgumentInvalidException, + ArgumentOutOfRangeException, +} from '@mobicoop/ddd-library'; +import { ScheduleItem } from '@modules/ad/core/domain/value-objects/schedule-item.value-object'; + +describe('Schedule item value object', () => { + it('should create a schedule item value object', () => { + const scheduleItemVO = new ScheduleItem({ + day: 0, + time: '07:00', + margin: 900, + }); + expect(scheduleItemVO.day).toBe(0); + expect(scheduleItemVO.time).toBe('07:00'); + expect(scheduleItemVO.margin).toBe(900); + }); + it('should throw an exception if day is invalid', () => { + try { + new ScheduleItem({ + day: 7, + time: '07:00', + margin: 900, + }); + } catch (e: any) { + expect(e).toBeInstanceOf(ArgumentOutOfRangeException); + } + }); + it('should throw an exception if time is invalid', () => { + try { + new ScheduleItem({ + day: 0, + time: '07,00', + margin: 900, + }); + } catch (e: any) { + expect(e).toBeInstanceOf(ArgumentInvalidException); + } + }); + it('should throw an exception if the hour of the time is invalid', () => { + try { + new ScheduleItem({ + day: 0, + time: '25:00', + margin: 900, + }); + } catch (e: any) { + expect(e).toBeInstanceOf(ArgumentInvalidException); + } + }); + it('should throw an exception if the minutes of the time are invalid', () => { + try { + new ScheduleItem({ + day: 0, + time: '07:63', + margin: 900, + }); + } catch (e: any) { + expect(e).toBeInstanceOf(ArgumentInvalidException); + } + }); +}); diff --git a/src/modules/ad/tests/unit/core/waypoint.value-object.spec.ts b/src/modules/ad/tests/unit/core/waypoint.value-object.spec.ts new file mode 100644 index 0000000..1d6e334 --- /dev/null +++ b/src/modules/ad/tests/unit/core/waypoint.value-object.spec.ts @@ -0,0 +1,83 @@ +import { + ArgumentInvalidException, + ArgumentOutOfRangeException, +} from '@mobicoop/ddd-library'; +import { PointContext } from '@modules/ad/core/domain/ad.types'; +import { Waypoint } from '@modules/ad/core/domain/value-objects/waypoint.value-object'; + +describe('Waypoint value object', () => { + it('should create a waypoint value object without context', () => { + const waypointVO = new Waypoint({ + position: 0, + lon: 48.689445, + lat: 6.17651, + }); + expect(waypointVO.position).toBe(0); + expect(waypointVO.lon).toBe(48.689445); + expect(waypointVO.lat).toBe(6.17651); + expect(waypointVO.context).toBeUndefined(); + }); + it('should create a waypoint value object with context', () => { + const waypointVO = new Waypoint({ + position: 0, + lon: 48.689445, + lat: 6.17651, + context: PointContext.HOUSE_NUMBER, + }); + expect(waypointVO.position).toBe(0); + expect(waypointVO.lon).toBe(48.689445); + expect(waypointVO.lat).toBe(6.17651); + expect(waypointVO.context).toBe(PointContext.HOUSE_NUMBER); + }); + it('should throw an exception if position is invalid', () => { + try { + new Waypoint({ + position: -1, + lon: 48.689445, + lat: 6.17651, + }); + } catch (e: any) { + expect(e).toBeInstanceOf(ArgumentInvalidException); + } + }); + it('should throw an exception if longitude is invalid', () => { + try { + new Waypoint({ + position: 0, + lon: 348.689445, + lat: 6.17651, + }); + } catch (e: any) { + expect(e).toBeInstanceOf(ArgumentOutOfRangeException); + } + try { + new Waypoint({ + position: 0, + lon: -348.689445, + lat: 6.17651, + }); + } catch (e: any) { + expect(e).toBeInstanceOf(ArgumentOutOfRangeException); + } + }); + it('should throw an exception if longitude is invalid', () => { + try { + new Waypoint({ + position: 0, + lon: 48.689445, + lat: 96.17651, + }); + } catch (e: any) { + expect(e).toBeInstanceOf(ArgumentOutOfRangeException); + } + try { + new Waypoint({ + position: 0, + lon: 48.689445, + lat: -96.17651, + }); + } catch (e: any) { + expect(e).toBeInstanceOf(ArgumentOutOfRangeException); + } + }); +}); diff --git a/src/modules/ad/tests/unit/infrastructure/ad.repository.spec.ts b/src/modules/ad/tests/unit/infrastructure/ad.repository.spec.ts new file mode 100644 index 0000000..43ed4ac --- /dev/null +++ b/src/modules/ad/tests/unit/infrastructure/ad.repository.spec.ts @@ -0,0 +1,36 @@ +import { AdMapper } from '@modules/ad/ad.mapper'; +import { AdRepository } from '@modules/ad/infrastructure/ad.repository'; +import { PrismaService } from '@modules/ad/infrastructure/prisma.service'; +import { EventEmitter2, EventEmitterModule } from '@nestjs/event-emitter'; +import { Test, TestingModule } from '@nestjs/testing'; + +const mockMessagePublisher = { + publish: jest.fn().mockImplementation(), +}; + +describe('Ad repository', () => { + let prismaService: PrismaService; + let adMapper: AdMapper; + let eventEmitter: EventEmitter2; + + beforeAll(async () => { + const module: TestingModule = await Test.createTestingModule({ + imports: [EventEmitterModule.forRoot()], + providers: [PrismaService, AdMapper], + }).compile(); + + prismaService = module.get(PrismaService); + adMapper = module.get(AdMapper); + eventEmitter = module.get(EventEmitter2); + }); + it('should be defined', () => { + expect( + new AdRepository( + prismaService, + adMapper, + eventEmitter, + mockMessagePublisher, + ), + ).toBeDefined(); + }); +}); diff --git a/src/modules/ad/tests/unit/interface/ad-created.message-handler.spec.ts b/src/modules/ad/tests/unit/interface/ad-created.message-handler.spec.ts new file mode 100644 index 0000000..df3e773 --- /dev/null +++ b/src/modules/ad/tests/unit/interface/ad-created.message-handler.spec.ts @@ -0,0 +1,44 @@ +import { AdCreatedMessageHandler } from '@modules/ad/interface/message-handlers/ad-created.message-handler'; +import { CommandBus } from '@nestjs/cqrs'; +import { Test, TestingModule } from '@nestjs/testing'; + +const adCreatedMessage = + '{"id":"4eb6a6af-ecfd-41c3-9118-473a507014d4","driver":"true","passenger":"true","frequency":"PUNCTUAL","fromDate":"2023-08-18","toDate":"2023-08-18","schedule":[{"day":"5","time":"10:00","margin":"900"}],"seatsProposed":"3","seatsRequested":"1","strict":"false","waypoints":[{"position":"0","houseNumber":"5","street":"rue de la monnaie","locality":"Nancy","postalCode":"54000","country":"France","lon":"48.689445","lat":"6.17651"},{"position":"1","locality":"Paris","postalCode":"75000","country":"France","lon":"48.8566","lat":"2.3522"}]}'; + +const mockCommandBus = { + execute: jest.fn(), +}; + +describe('Ad Created Message Handler', () => { + let adCreatedMessageHandler: AdCreatedMessageHandler; + + beforeAll(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + { + provide: CommandBus, + useValue: mockCommandBus, + }, + AdCreatedMessageHandler, + ], + }).compile(); + + adCreatedMessageHandler = module.get( + AdCreatedMessageHandler, + ); + }); + + afterEach(async () => { + jest.clearAllMocks(); + }); + + it('should be defined', () => { + expect(adCreatedMessageHandler).toBeDefined(); + }); + + it('should create an ad', async () => { + jest.spyOn(mockCommandBus, 'execute'); + await adCreatedMessageHandler.adCreated(adCreatedMessage); + expect(mockCommandBus.execute).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/modules/ad/core/application/ports/default-params-provider.port.ts b/src/modules/geography/core/application/ports/default-params-provider.port.ts similarity index 55% rename from src/modules/ad/core/application/ports/default-params-provider.port.ts rename to src/modules/geography/core/application/ports/default-params-provider.port.ts index e316b77..7f4ee14 100644 --- a/src/modules/ad/core/application/ports/default-params-provider.port.ts +++ b/src/modules/geography/core/application/ports/default-params-provider.port.ts @@ -1,4 +1,4 @@ -import { DefaultParams } from './default-params.type'; +import { DefaultParams } from '../types/default-params.type'; export interface DefaultParamsProviderPort { getParams(): DefaultParams; diff --git a/src/modules/geography/core/application/ports/direction-encoder.port.ts b/src/modules/geography/core/application/ports/direction-encoder.port.ts new file mode 100644 index 0000000..333f8ba --- /dev/null +++ b/src/modules/geography/core/application/ports/direction-encoder.port.ts @@ -0,0 +1,5 @@ +import { Coordinates } from '../types/coordinates.type'; + +export interface DirectionEncoderPort { + encode(coordinates: Coordinates[]): string; +} diff --git a/src/modules/geography/core/application/ports/geodesic.port.ts b/src/modules/geography/core/application/ports/geodesic.port.ts new file mode 100644 index 0000000..77b45ba --- /dev/null +++ b/src/modules/geography/core/application/ports/geodesic.port.ts @@ -0,0 +1,11 @@ +export interface GeodesicPort { + inverse( + lon1: number, + lat1: number, + lon2: number, + lat2: number, + ): { + azimuth: number; + distance: number; + }; +} diff --git a/src/modules/geography/core/application/ports/georouter-creator.port.ts b/src/modules/geography/core/application/ports/georouter-creator.port.ts new file mode 100644 index 0000000..0f3957d --- /dev/null +++ b/src/modules/geography/core/application/ports/georouter-creator.port.ts @@ -0,0 +1,5 @@ +import { GeorouterPort } from './georouter.port'; + +export interface GeorouterCreatorPort { + create(type: string, url: string): GeorouterPort; +} diff --git a/src/modules/geography/core/application/ports/georouter.port.ts b/src/modules/geography/core/application/ports/georouter.port.ts new file mode 100644 index 0000000..51e56f7 --- /dev/null +++ b/src/modules/geography/core/application/ports/georouter.port.ts @@ -0,0 +1,7 @@ +import { GeorouterSettings } from '../types/georouter-settings.type'; +import { Path } from '../types/path.type'; +import { Route } from '../types/route.type'; + +export interface GeorouterPort { + routes(paths: Path[], settings: GeorouterSettings): Promise; +} diff --git a/src/modules/geography/core/application/types/coordinates.type.ts b/src/modules/geography/core/application/types/coordinates.type.ts new file mode 100644 index 0000000..8e149ed --- /dev/null +++ b/src/modules/geography/core/application/types/coordinates.type.ts @@ -0,0 +1,4 @@ +export type Coordinates = { + lon: number; + lat: number; +}; diff --git a/src/modules/ad/core/application/ports/default-params.type.ts b/src/modules/geography/core/application/types/default-params.type.ts similarity index 75% rename from src/modules/ad/core/application/ports/default-params.type.ts rename to src/modules/geography/core/application/types/default-params.type.ts index bea841b..12ea88e 100644 --- a/src/modules/ad/core/application/ports/default-params.type.ts +++ b/src/modules/geography/core/application/types/default-params.type.ts @@ -1,5 +1,4 @@ export type DefaultParams = { - DEFAULT_TIMEZONE: string; GEOROUTER_TYPE: string; GEOROUTER_URL: string; }; diff --git a/src/modules/geography/core/application/types/georouter-settings.type.ts b/src/modules/geography/core/application/types/georouter-settings.type.ts new file mode 100644 index 0000000..d8f73ae --- /dev/null +++ b/src/modules/geography/core/application/types/georouter-settings.type.ts @@ -0,0 +1,5 @@ +export type GeorouterSettings = { + withPoints: boolean; + withTime: boolean; + withDistance: boolean; +}; diff --git a/src/modules/geography/core/application/types/path.type.ts b/src/modules/geography/core/application/types/path.type.ts new file mode 100644 index 0000000..1f4a6eb --- /dev/null +++ b/src/modules/geography/core/application/types/path.type.ts @@ -0,0 +1,7 @@ +import { PathType } from '../../domain/route.types'; +import { Point } from './point.type'; + +export type Path = { + type: PathType; + points: Point[]; +}; diff --git a/src/modules/geography/core/application/types/point.type.ts b/src/modules/geography/core/application/types/point.type.ts new file mode 100644 index 0000000..6f71f0f --- /dev/null +++ b/src/modules/geography/core/application/types/point.type.ts @@ -0,0 +1,6 @@ +import { PointContext } from '../../domain/route.types'; +import { Coordinates } from './coordinates.type'; + +export type Point = Coordinates & { + context?: PointContext; +}; diff --git a/src/modules/geography/core/application/types/route.type.ts b/src/modules/geography/core/application/types/route.type.ts new file mode 100644 index 0000000..f202e52 --- /dev/null +++ b/src/modules/geography/core/application/types/route.type.ts @@ -0,0 +1,11 @@ +import { Point } from './point.type'; + +export type Route = { + name: string; + distance: number; + duration: number; + fwdAzimuth: number; + backAzimuth: number; + distanceAzimuth: number; + points: Point[]; +}; diff --git a/src/modules/geography/core/domain/route.entity.ts b/src/modules/geography/core/domain/route.entity.ts new file mode 100644 index 0000000..d5bf02f --- /dev/null +++ b/src/modules/geography/core/domain/route.entity.ts @@ -0,0 +1,117 @@ +import { AggregateRoot, AggregateID } from '@mobicoop/ddd-library'; +import { + CreateRouteProps, + Path, + Role, + RouteProps, + PathType, +} from './route.types'; +import { WaypointProps } from './value-objects/waypoint.value-object'; + +export class RouteEntity extends AggregateRoot { + protected readonly _id: AggregateID; + + static create = async (create: CreateRouteProps): Promise => { + const props: RouteProps = await create.georouter.routes( + this.getPaths(create.roles, create.waypoints), + create.georouterSettings, + ); + const route = new RouteEntity({ props }); + return route; + }; + + validate(): void { + // entity business rules validation to protect it's invariant before saving entity to a database + } + + private static getPaths = ( + roles: Role[], + waypoints: WaypointProps[], + ): Path[] => { + const paths: Path[] = []; + if (roles.includes(Role.DRIVER) && roles.includes(Role.PASSENGER)) { + if (waypoints.length == 2) { + // 2 points => same route for driver and passenger + const commonPath: Path = { + type: PathType.COMMON, + points: waypoints, + }; + paths.push(commonPath); + } else { + const driverPath: Path = RouteEntity.createDriverPath(waypoints); + const passengerPath: Path = RouteEntity.createPassengerPath(waypoints); + paths.push(driverPath, passengerPath); + } + } else if (roles.includes(Role.DRIVER)) { + const driverPath: Path = RouteEntity.createDriverPath(waypoints); + paths.push(driverPath); + } else if (roles.includes(Role.PASSENGER)) { + const passengerPath: Path = RouteEntity.createPassengerPath(waypoints); + paths.push(passengerPath); + } + return paths; + }; + + private static createDriverPath = (waypoints: WaypointProps[]): Path => { + return { + type: PathType.DRIVER, + points: waypoints, + }; + }; + + private static createPassengerPath = (waypoints: WaypointProps[]): Path => { + return { + type: PathType.PASSENGER, + points: [waypoints[0], waypoints[waypoints.length - 1]], + }; + }; +} + +// import { IGeodesic } from '../interfaces/geodesic.interface'; +// import { Point } from '../types/point.type'; +// import { SpacetimePoint } from './spacetime-point'; + +// export class Route { +// distance: number; +// duration: number; +// fwdAzimuth: number; +// backAzimuth: number; +// distanceAzimuth: number; +// points: Point[]; +// spacetimePoints: SpacetimePoint[]; +// private geodesic: IGeodesic; + +// constructor(geodesic: IGeodesic) { +// this.distance = undefined; +// this.duration = undefined; +// this.fwdAzimuth = undefined; +// this.backAzimuth = undefined; +// this.distanceAzimuth = undefined; +// this.points = []; +// this.spacetimePoints = []; +// this.geodesic = geodesic; +// } + +// setPoints = (points: Point[]): void => { +// this.points = points; +// this.setAzimuth(points); +// }; + +// setSpacetimePoints = (spacetimePoints: SpacetimePoint[]): void => { +// this.spacetimePoints = spacetimePoints; +// }; + +// protected setAzimuth = (points: Point[]): void => { +// const inverse = this.geodesic.inverse( +// points[0].lon, +// points[0].lat, +// points[points.length - 1].lon, +// points[points.length - 1].lat, +// ); +// this.fwdAzimuth = +// inverse.azimuth >= 0 ? inverse.azimuth : 360 - Math.abs(inverse.azimuth); +// this.backAzimuth = +// this.fwdAzimuth > 180 ? this.fwdAzimuth - 180 : this.fwdAzimuth + 180; +// this.distanceAzimuth = inverse.distance; +// }; +// } diff --git a/src/modules/geography/core/domain/route.types.ts b/src/modules/geography/core/domain/route.types.ts new file mode 100644 index 0000000..d9d3674 --- /dev/null +++ b/src/modules/geography/core/domain/route.types.ts @@ -0,0 +1,54 @@ +import { GeorouterPort } from '../application/ports/georouter.port'; +import { GeorouterSettings } from '../application/types/georouter-settings.type'; +import { SpacetimePointProps } from './value-objects/timepoint.value-object'; +import { WaypointProps } from './value-objects/waypoint.value-object'; + +// All properties that a Route has +export interface RouteProps { + name: string; + distance: number; + duration: number; + fwdAzimuth: number; + backAzimuth: number; + distanceAzimuth: number; + waypoints: WaypointProps[]; + spacetimePoints: SpacetimePointProps[]; +} + +// Properties that are needed for a Route creation +export interface CreateRouteProps { + roles: Role[]; + waypoints: WaypointProps[]; + georouter: GeorouterPort; + georouterSettings: GeorouterSettings; +} + +export type Path = { + type: PathType; + points: Point[]; +}; + +export type Point = { + lon: number; + lat: number; + context?: PointContext; +}; + +export enum Role { + DRIVER = 'DRIVER', + PASSENGER = 'PASSENGER', +} + +export enum PointContext { + HOUSE_NUMBER = 'HOUSE_NUMBER', + STREET_ADDRESS = 'STREET_ADDRESS', + LOCALITY = 'LOCALITY', + VENUE = 'VENUE', + OTHER = 'OTHER', +} + +export enum PathType { + COMMON = 'common', + DRIVER = 'driver', + PASSENGER = 'passenger', +} diff --git a/src/modules/geography/core/domain/value-objects/timepoint.value-object.ts b/src/modules/geography/core/domain/value-objects/timepoint.value-object.ts new file mode 100644 index 0000000..b6c7971 --- /dev/null +++ b/src/modules/geography/core/domain/value-objects/timepoint.value-object.ts @@ -0,0 +1,42 @@ +import { ArgumentInvalidException, ValueObject } from '@mobicoop/ddd-library'; + +/** Note: + * Value Objects with multiple properties can contain + * other Value Objects inside if needed. + * */ + +export interface SpacetimePointProps { + lon: number; + lat: number; + duration: number; + distance: number; +} + +export class SpacetimePoint extends ValueObject { + get lon(): number { + return this.props.lon; + } + + get lat(): number { + return this.props.lat; + } + + get duration(): number { + return this.props.duration; + } + + get distance(): number { + return this.props.distance; + } + + protected validate(props: SpacetimePointProps): void { + if (props.duration < 0) + throw new ArgumentInvalidException( + 'duration must be greater than or equal to 0', + ); + if (props.distance < 0) + throw new ArgumentInvalidException( + 'distance must be greater than or equal to 0', + ); + } +} diff --git a/src/modules/geography/core/domain/value-objects/waypoint.value-object.ts b/src/modules/geography/core/domain/value-objects/waypoint.value-object.ts new file mode 100644 index 0000000..9f6b493 --- /dev/null +++ b/src/modules/geography/core/domain/value-objects/waypoint.value-object.ts @@ -0,0 +1,47 @@ +import { + ArgumentInvalidException, + ArgumentOutOfRangeException, + ValueObject, +} from '@mobicoop/ddd-library'; +import { PointContext } from '../route.types'; + +/** Note: + * Value Objects with multiple properties can contain + * other Value Objects inside if needed. + * */ + +export interface WaypointProps { + position: number; + lon: number; + lat: number; + context?: PointContext; +} + +export class Waypoint extends ValueObject { + get position(): number { + return this.props.position; + } + + get lon(): number { + return this.props.lon; + } + + get lat(): number { + return this.props.lat; + } + + get context(): PointContext { + return this.props.context; + } + + protected validate(props: WaypointProps): void { + if (props.position < 0) + throw new ArgumentInvalidException( + 'position must be greater than or equal to 0', + ); + if (props.lon > 180 || props.lon < -180) + throw new ArgumentOutOfRangeException('lon must be between -180 and 180'); + if (props.lat > 90 || props.lat < -90) + throw new ArgumentOutOfRangeException('lat must be between -90 and 90'); + } +} diff --git a/src/modules/geography/geography.di-tokens.ts b/src/modules/geography/geography.di-tokens.ts new file mode 100644 index 0000000..647e261 --- /dev/null +++ b/src/modules/geography/geography.di-tokens.ts @@ -0,0 +1,2 @@ +export const PARAMS_PROVIDER = Symbol('PARAMS_PROVIDER'); +export const DIRECTION_ENCODER = Symbol('DIRECTION_ENCODER'); diff --git a/src/modules/geography/geography.module.ts b/src/modules/geography/geography.module.ts new file mode 100644 index 0000000..c1aff74 --- /dev/null +++ b/src/modules/geography/geography.module.ts @@ -0,0 +1,23 @@ +import { Module, Provider } from '@nestjs/common'; +import { CqrsModule } from '@nestjs/cqrs'; +import { DIRECTION_ENCODER, PARAMS_PROVIDER } from './geography.di-tokens'; +import { DefaultParamsProvider } from './infrastructure/default-params-provider'; +import { PostgresDirectionEncoder } from './infrastructure/postgres-direction-encoder'; + +const adapters: Provider[] = [ + { + provide: PARAMS_PROVIDER, + useClass: DefaultParamsProvider, + }, + { + provide: DIRECTION_ENCODER, + useClass: PostgresDirectionEncoder, + }, +]; + +@Module({ + imports: [CqrsModule], + providers: [...adapters], + exports: [DIRECTION_ENCODER], +}) +export class GeographyModule {} diff --git a/src/modules/ad/infrastructure/default-params-provider.ts b/src/modules/geography/infrastructure/default-params-provider.ts similarity index 80% rename from src/modules/ad/infrastructure/default-params-provider.ts rename to src/modules/geography/infrastructure/default-params-provider.ts index 305987b..962468b 100644 --- a/src/modules/ad/infrastructure/default-params-provider.ts +++ b/src/modules/geography/infrastructure/default-params-provider.ts @@ -1,7 +1,7 @@ import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { DefaultParamsProviderPort } from '../core/application/ports/default-params-provider.port'; -import { DefaultParams } from '../core/application/ports/default-params.type'; +import { DefaultParams } from '../core/application/types/default-params.type'; @Injectable() export class DefaultParamsProvider implements DefaultParamsProviderPort { @@ -9,6 +9,5 @@ export class DefaultParamsProvider implements DefaultParamsProviderPort { getParams = (): DefaultParams => ({ GEOROUTER_TYPE: this._configService.get('GEOROUTER_TYPE'), GEOROUTER_URL: this._configService.get('GEOROUTER_URL'), - DEFAULT_TIMEZONE: this._configService.get('DEFAULT_TIMEZONE'), }); } diff --git a/src/modules/geography/infrastructure/postgres-direction-encoder.ts b/src/modules/geography/infrastructure/postgres-direction-encoder.ts new file mode 100644 index 0000000..9ffbcba --- /dev/null +++ b/src/modules/geography/infrastructure/postgres-direction-encoder.ts @@ -0,0 +1,11 @@ +import { Coordinates } from '../core/application/types/coordinates.type'; +import { DirectionEncoderPort } from '../core/application/ports/direction-encoder.port'; + +export class PostgresDirectionEncoder implements DirectionEncoderPort { + encode = (coordinates: Coordinates[]): string => + [ + "'LINESTRING(", + coordinates.map((point) => [point.lon, point.lat].join(' ')).join(), + ")'", + ].join(''); +} diff --git a/src/modules/messager/messager.module.ts b/src/modules/messager/messager.module.ts index 896c300..64bafed 100644 --- a/src/modules/messager/messager.module.ts +++ b/src/modules/messager/messager.module.ts @@ -17,6 +17,20 @@ const imports = [ uri: configService.get('MESSAGE_BROKER_URI'), exchange: configService.get('MESSAGE_BROKER_EXCHANGE'), name: 'matcher', + handlers: { + adCreated: { + routingKey: 'ad.created', + queue: 'matcher-ad-created', + }, + adUpdated: { + routingKey: 'ad.updated', + queue: 'matcher-ad-updated', + }, + adDeleted: { + routingKey: 'ad.deleted', + queue: 'matcher-ad-deleted', + }, + }, }), }), ];