basic ad entity without direction

This commit is contained in:
sbriat 2023-08-18 12:47:51 +02:00
parent ce48890a66
commit db13f4d87e
47 changed files with 1122 additions and 88 deletions

View File

@ -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

View File

@ -15,7 +15,6 @@ datasource db {
model Ad {
uuid String @id @db.Uuid
userUuid String @db.Uuid
driver Boolean
passenger Boolean
frequency Frequency

View File

@ -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],

View File

@ -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');

View File

@ -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,
},

View File

@ -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 {}

View File

@ -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<CreateAdCommand>) {
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;
}
}

View File

@ -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<AggregateID> {
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;
}
}
}

View File

@ -1,3 +0,0 @@
export interface TimezoneFinderPort {
timezones(lon: number, lat: number, defaultTimezone?: string): string[];
}

View File

@ -0,0 +1,5 @@
export type ScheduleItem = {
day: number;
time: string;
margin: number;
};

View File

@ -0,0 +1,8 @@
import { PointContext } from '../../domain/ad.types';
export type Waypoint = {
position: number;
context?: PointContext;
lon: number;
lat: number;
};

View File

@ -6,8 +6,7 @@ export class AdEntity extends AggregateRoot<AdProps> {
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 {

View File

@ -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);
}
}

View File

@ -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',
}

View File

@ -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<ScheduleItemProps> {
@ -26,6 +30,19 @@ export class ScheduleItem extends ValueObject<ScheduleItemProps> {
// 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');
}
}

View File

@ -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<WaypointProps> {
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');
}
}

View File

@ -13,7 +13,6 @@ import { AdMapper } from '../ad.mapper';
export type AdBaseModel = {
uuid: string;
userUuid: string;
driver: boolean;
passenger: boolean;
frequency: string;

View File

@ -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;
};
}

View File

@ -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) {}
}
}

View File

@ -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;
};

View File

@ -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>(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();
});
});

View File

@ -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();
});
});

View File

@ -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>(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);
});
});
});

View File

@ -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);
}
});
});

View File

@ -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);
}
});
});

View File

@ -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>(PrismaService);
adMapper = module.get<AdMapper>(AdMapper);
eventEmitter = module.get<EventEmitter2>(EventEmitter2);
});
it('should be defined', () => {
expect(
new AdRepository(
prismaService,
adMapper,
eventEmitter,
mockMessagePublisher,
),
).toBeDefined();
});
});

View File

@ -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>(
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);
});
});

View File

@ -1,4 +1,4 @@
import { DefaultParams } from './default-params.type';
import { DefaultParams } from '../types/default-params.type';
export interface DefaultParamsProviderPort {
getParams(): DefaultParams;

View File

@ -0,0 +1,5 @@
import { Coordinates } from '../types/coordinates.type';
export interface DirectionEncoderPort {
encode(coordinates: Coordinates[]): string;
}

View File

@ -0,0 +1,11 @@
export interface GeodesicPort {
inverse(
lon1: number,
lat1: number,
lon2: number,
lat2: number,
): {
azimuth: number;
distance: number;
};
}

View File

@ -0,0 +1,5 @@
import { GeorouterPort } from './georouter.port';
export interface GeorouterCreatorPort {
create(type: string, url: string): GeorouterPort;
}

View File

@ -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<Route[]>;
}

View File

@ -0,0 +1,4 @@
export type Coordinates = {
lon: number;
lat: number;
};

View File

@ -1,5 +1,4 @@
export type DefaultParams = {
DEFAULT_TIMEZONE: string;
GEOROUTER_TYPE: string;
GEOROUTER_URL: string;
};

View File

@ -0,0 +1,5 @@
export type GeorouterSettings = {
withPoints: boolean;
withTime: boolean;
withDistance: boolean;
};

View File

@ -0,0 +1,7 @@
import { PathType } from '../../domain/route.types';
import { Point } from './point.type';
export type Path = {
type: PathType;
points: Point[];
};

View File

@ -0,0 +1,6 @@
import { PointContext } from '../../domain/route.types';
import { Coordinates } from './coordinates.type';
export type Point = Coordinates & {
context?: PointContext;
};

View File

@ -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[];
};

View File

@ -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<RouteProps> {
protected readonly _id: AggregateID;
static create = async (create: CreateRouteProps): Promise<RouteEntity> => {
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;
// };
// }

View File

@ -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',
}

View File

@ -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<SpacetimePointProps> {
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',
);
}
}

View File

@ -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<WaypointProps> {
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');
}
}

View File

@ -0,0 +1,2 @@
export const PARAMS_PROVIDER = Symbol('PARAMS_PROVIDER');
export const DIRECTION_ENCODER = Symbol('DIRECTION_ENCODER');

View File

@ -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 {}

View File

@ -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'),
});
}

View File

@ -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('');
}

View File

@ -17,6 +17,20 @@ const imports = [
uri: configService.get<string>('MESSAGE_BROKER_URI'),
exchange: configService.get<string>('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',
},
},
}),
}),
];