Merge branch 'addAd' into 'main'

Add ad

See merge request v3/service/matcher!4
This commit is contained in:
Sylvain Briat
2023-05-23 14:11:36 +00:00
152 changed files with 4118 additions and 766 deletions
+7
View File
@@ -0,0 +1,7 @@
# SERVICE
SERVICE_URL=0.0.0.0
SERVICE_PORT=5005
SERVICE_CONFIGURATION_DOMAIN=MATCHER
# PRISMA
DATABASE_URL="postgresql://mobicoop:mobicoop@localhost:5432/mobicoop-test?schema=matcher"
+15 -1
View File
@@ -36,7 +36,8 @@
"got": "^11.8.6", "got": "^11.8.6",
"ioredis": "^5.3.1", "ioredis": "^5.3.1",
"reflect-metadata": "^0.1.13", "reflect-metadata": "^0.1.13",
"rxjs": "^7.2.0" "rxjs": "^7.2.0",
"timezonecomplete": "^5.12.4"
}, },
"devDependencies": { "devDependencies": {
"@nestjs/cli": "^9.0.0", "@nestjs/cli": "^9.0.0",
@@ -8576,6 +8577,14 @@
"integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==",
"dev": true "dev": true
}, },
"node_modules/timezonecomplete": {
"version": "5.12.4",
"resolved": "https://registry.npmjs.org/timezonecomplete/-/timezonecomplete-5.12.4.tgz",
"integrity": "sha512-K+ocagBAl5wu9Ifh5oHKhRRLb0wP7j0VjAzjboZsT6bnVmtJNRe3Wnk2IPp0C4Uc8HpLly3gbfUrTlJ3M7vCPA==",
"dependencies": {
"tzdata": "^1.0.25"
}
},
"node_modules/tmp": { "node_modules/tmp": {
"version": "0.0.33", "version": "0.0.33",
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz",
@@ -8867,6 +8876,11 @@
"node": ">=4.2.0" "node": ">=4.2.0"
} }
}, },
"node_modules/tzdata": {
"version": "1.0.38",
"resolved": "https://registry.npmjs.org/tzdata/-/tzdata-1.0.38.tgz",
"integrity": "sha512-KIgVvZTLt+DWzr3MOENNLCLdsNB+usedRYYHCVfVbA7TDewj8mfjlWmj3Mv6FfdrvfeE6Oprt+qE47YiL90duQ=="
},
"node_modules/uid": { "node_modules/uid": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/uid/-/uid-2.0.2.tgz", "resolved": "https://registry.npmjs.org/uid/-/uid-2.0.2.tgz",
+2 -1
View File
@@ -59,7 +59,8 @@
"got": "^11.8.6", "got": "^11.8.6",
"ioredis": "^5.3.1", "ioredis": "^5.3.1",
"reflect-metadata": "^0.1.13", "reflect-metadata": "^0.1.13",
"rxjs": "^7.2.0" "rxjs": "^7.2.0",
"timezonecomplete": "^5.12.4"
}, },
"devDependencies": { "devDependencies": {
"@nestjs/cli": "^9.0.0", "@nestjs/cli": "^9.0.0",
@@ -1,65 +0,0 @@
-- CreateExtension
CREATE EXTENSION IF NOT EXISTS "postgis";
-- Required to use postgis extension :
-- set the search_path to both public and territory (where is postgis) AND the current schema
SET search_path TO matcher, territory, public;
-- CreateTable
CREATE TABLE "ad" (
"uuid" UUID NOT NULL,
"driver" BOOLEAN NOT NULL,
"passenger" BOOLEAN NOT NULL,
"frequency" INTEGER NOT NULL,
"from_date" DATE NOT NULL,
"to_date" DATE NOT NULL,
"mon_time" TIMESTAMPTZ NOT NULL,
"tue_time" TIMESTAMPTZ NOT NULL,
"wed_time" TIMESTAMPTZ NOT NULL,
"thu_time" TIMESTAMPTZ NOT NULL,
"fri_time" TIMESTAMPTZ NOT NULL,
"sat_time" TIMESTAMPTZ NOT NULL,
"sun_time" TIMESTAMPTZ NOT NULL,
"mon_margin" INTEGER NOT NULL,
"tue_margin" INTEGER NOT NULL,
"wed_margin" INTEGER NOT NULL,
"thu_margin" INTEGER NOT NULL,
"fri_margin" INTEGER NOT NULL,
"sat_margin" INTEGER NOT NULL,
"sun_margin" INTEGER NOT NULL,
"driver_duration" INTEGER NOT NULL,
"driver_distance" INTEGER NOT NULL,
"passenger_duration" INTEGER NOT NULL,
"passenger_distance" INTEGER NOT NULL,
"origin_type" SMALLINT NOT NULL,
"destination_type" SMALLINT NOT NULL,
"waypoints" geography(LINESTRING) NOT NULL,
"direction" geography(LINESTRING) NOT NULL,
"fwd_azimuth" INTEGER NOT NULL,
"back_azimuth" INTEGER NOT NULL,
"seats_driver" SMALLINT NOT NULL,
"seats_passenger" SMALLINT NOT NULL,
"seats_used" SMALLINT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ad_pkey" PRIMARY KEY ("uuid")
);
-- CreateIndex
CREATE INDEX "ad_driver_idx" ON "ad"("driver");
-- CreateIndex
CREATE INDEX "ad_passenger_idx" ON "ad"("passenger");
-- CreateIndex
CREATE INDEX "ad_from_date_idx" ON "ad"("from_date");
-- CreateIndex
CREATE INDEX "ad_to_date_idx" ON "ad"("to_date");
-- CreateIndex
CREATE INDEX "ad_fwd_azimuth_idx" ON "ad"("fwd_azimuth");
-- CreateIndex
CREATE INDEX "direction_idx" ON "ad" USING GIST ("direction");
@@ -0,0 +1,68 @@
-- CreateExtension
CREATE EXTENSION IF NOT EXISTS "postgis";
-- Required to use postgis extension :
-- set the search_path to both public (where is postgis) AND the current schema
SET search_path TO matcher, public;
-- CreateEnum
CREATE TYPE "Frequency" AS ENUM ('PUNCTUAL', 'RECURRENT');
-- CreateTable
CREATE TABLE "ad" (
"uuid" UUID NOT NULL,
"userUuid" UUID NOT NULL,
"driver" BOOLEAN NOT NULL,
"passenger" BOOLEAN NOT NULL,
"frequency" "Frequency" NOT NULL,
"fromDate" DATE NOT NULL,
"toDate" DATE NOT NULL,
"monTime" TIMESTAMPTZ,
"tueTime" TIMESTAMPTZ,
"wedTime" TIMESTAMPTZ,
"thuTime" TIMESTAMPTZ,
"friTime" TIMESTAMPTZ,
"satTime" TIMESTAMPTZ,
"sunTime" TIMESTAMPTZ,
"monMargin" INTEGER NOT NULL,
"tueMargin" INTEGER NOT NULL,
"wedMargin" INTEGER NOT NULL,
"thuMargin" INTEGER NOT NULL,
"friMargin" INTEGER NOT NULL,
"satMargin" INTEGER NOT NULL,
"sunMargin" INTEGER NOT NULL,
"driverDuration" INTEGER,
"driverDistance" INTEGER,
"passengerDuration" INTEGER,
"passengerDistance" INTEGER,
"waypoints" geography(LINESTRING),
"direction" geography(LINESTRING),
"fwdAzimuth" INTEGER NOT NULL,
"backAzimuth" INTEGER NOT NULL,
"seatsDriver" SMALLINT NOT NULL,
"seatsPassenger" SMALLINT NOT NULL,
"seatsUsed" SMALLINT NOT NULL,
"strict" BOOLEAN NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ad_pkey" PRIMARY KEY ("uuid")
);
-- CreateIndex
CREATE INDEX "ad_driver_idx" ON "ad"("driver");
-- CreateIndex
CREATE INDEX "ad_passenger_idx" ON "ad"("passenger");
-- CreateIndex
CREATE INDEX "ad_fromDate_idx" ON "ad"("fromDate");
-- CreateIndex
CREATE INDEX "ad_toDate_idx" ON "ad"("toDate");
-- CreateIndex
CREATE INDEX "ad_fwdAzimuth_idx" ON "ad"("fwdAzimuth");
-- CreateIndex
CREATE INDEX "direction_idx" ON "ad" USING GIST ("direction");
+44 -38
View File
@@ -3,6 +3,7 @@
generator client { generator client {
provider = "prisma-client-js" provider = "prisma-client-js"
binaryTargets = ["linux-musl", "debian-openssl-3.0.x"]
previewFeatures = ["postgresqlExtensions"] previewFeatures = ["postgresqlExtensions"]
} }
@@ -13,47 +14,52 @@ datasource db {
} }
model Ad { model Ad {
uuid String @id @default(uuid()) @db.Uuid uuid String @id @db.Uuid
driver Boolean userUuid String @db.Uuid
passenger Boolean driver Boolean
frequency Int passenger Boolean
from_date DateTime @db.Date frequency Frequency
to_date DateTime @db.Date fromDate DateTime @db.Date
mon_time DateTime @db.Timestamptz() toDate DateTime @db.Date
tue_time DateTime @db.Timestamptz() monTime DateTime? @db.Timestamptz()
wed_time DateTime @db.Timestamptz() tueTime DateTime? @db.Timestamptz()
thu_time DateTime @db.Timestamptz() wedTime DateTime? @db.Timestamptz()
fri_time DateTime @db.Timestamptz() thuTime DateTime? @db.Timestamptz()
sat_time DateTime @db.Timestamptz() friTime DateTime? @db.Timestamptz()
sun_time DateTime @db.Timestamptz() satTime DateTime? @db.Timestamptz()
mon_margin Int sunTime DateTime? @db.Timestamptz()
tue_margin Int monMargin Int
wed_margin Int tueMargin Int
thu_margin Int wedMargin Int
fri_margin Int thuMargin Int
sat_margin Int friMargin Int
sun_margin Int satMargin Int
driver_duration Int sunMargin Int
driver_distance Int driverDuration Int?
passenger_duration Int driverDistance Int?
passenger_distance Int passengerDuration Int?
origin_type Int @db.SmallInt passengerDistance Int?
destination_type Int @db.SmallInt waypoints Unsupported("geography(LINESTRING)")?
waypoints Unsupported("geography(LINESTRING)") direction Unsupported("geography(LINESTRING)")?
direction Unsupported("geography(LINESTRING)") fwdAzimuth Int
fwd_azimuth Int backAzimuth Int
back_azimuth Int seatsDriver Int @db.SmallInt
seats_driver Int @db.SmallInt seatsPassenger Int @db.SmallInt
seats_passenger Int @db.SmallInt seatsUsed Int @db.SmallInt
seats_used Int @db.SmallInt strict Boolean
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt updatedAt DateTime @default(now()) @updatedAt
@@index([driver]) @@index([driver])
@@index([passenger]) @@index([passenger])
@@index([from_date]) @@index([fromDate])
@@index([to_date]) @@index([toDate])
@@index([fwd_azimuth]) @@index([fwdAzimuth])
@@index([direction], name: "direction_idx", type: Gist) @@index([direction], name: "direction_idx", type: Gist)
@@map("ad") @@map("ad")
} }
enum Frequency {
PUNCTUAL
RECURRENT
}
+2
View File
@@ -5,6 +5,7 @@ import { ConfigModule } from '@nestjs/config';
import { ConfigurationModule } from './modules/configuration/configuration.module'; import { ConfigurationModule } from './modules/configuration/configuration.module';
import { HealthModule } from './modules/health/health.module'; import { HealthModule } from './modules/health/health.module';
import { MatcherModule } from './modules/matcher/matcher.module'; import { MatcherModule } from './modules/matcher/matcher.module';
import { AdModule } from './modules/ad/ad.module';
@Module({ @Module({
imports: [ imports: [
@@ -13,6 +14,7 @@ import { MatcherModule } from './modules/matcher/matcher.module';
ConfigurationModule, ConfigurationModule,
HealthModule, HealthModule,
MatcherModule, MatcherModule,
AdModule,
], ],
controllers: [], controllers: [],
providers: [], providers: [],
+72
View File
@@ -0,0 +1,72 @@
import { RabbitMQModule } from '@golevelup/nestjs-rabbitmq';
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { AdMessagerController } from './adapters/primaries/ad-messager.controller';
import { AdProfile } from './mappers/ad.profile';
import { CreateAdUseCase } from './domain/usecases/create-ad.usecase';
import { AdRepository } from './adapters/secondaries/ad.repository';
import { DatabaseModule } from '../database/database.module';
import { CqrsModule } from '@nestjs/cqrs';
import { Messager } from './adapters/secondaries/messager';
import { GeoTimezoneFinder } from '../geography/adapters/secondaries/geo-timezone-finder';
import { DefaultParamsProvider } from './adapters/secondaries/default-params.provider';
import { GeorouterCreator } from '../geography/adapters/secondaries/georouter-creator';
import { GeographyModule } from '../geography/geography.module';
import { HttpModule } from '@nestjs/axios';
import { PostgresDirectionEncoder } from '../geography/adapters/secondaries/postgres-direction-encoder';
@Module({
imports: [
GeographyModule,
DatabaseModule,
CqrsModule,
HttpModule,
RabbitMQModule.forRootAsync(RabbitMQModule, {
imports: [ConfigModule],
useFactory: async (configService: ConfigService) => ({
exchanges: [
{
name: configService.get<string>('RMQ_EXCHANGE'),
type: 'topic',
},
],
handlers: {
adCreated: {
exchange: configService.get<string>('RMQ_EXCHANGE'),
routingKey: 'ad.created',
queue: 'matcher-ad-created',
},
},
uri: configService.get<string>('RMQ_URI'),
connectionInitOptions: { wait: false },
enableControllerDiscovery: true,
}),
inject: [ConfigService],
}),
],
controllers: [AdMessagerController],
providers: [
{
provide: 'ParamsProvider',
useClass: DefaultParamsProvider,
},
{
provide: 'GeorouterCreator',
useClass: GeorouterCreator,
},
{
provide: 'TimezoneFinder',
useClass: GeoTimezoneFinder,
},
{
provide: 'DirectionEncoder',
useClass: PostgresDirectionEncoder,
},
AdProfile,
Messager,
AdRepository,
CreateAdUseCase,
],
exports: [],
})
export class AdModule {}
@@ -0,0 +1,48 @@
import { RabbitSubscribe } from '@golevelup/nestjs-rabbitmq';
import { Controller } from '@nestjs/common';
import { CommandBus } from '@nestjs/cqrs';
import { CreateAdCommand } from '../../commands/create-ad.command';
import { CreateAdRequest } from '../../domain/dtos/create-ad.request';
import { validateOrReject } from 'class-validator';
import { Messager } from '../secondaries/messager';
import { plainToInstance } from 'class-transformer';
@Controller()
export class AdMessagerController {
constructor(
private readonly messager: Messager,
private readonly commandBus: CommandBus,
) {}
@RabbitSubscribe({
name: 'adCreated',
})
async adCreatedHandler(message: string): Promise<void> {
try {
// parse message to request instance
const createAdRequest: CreateAdRequest = plainToInstance(
CreateAdRequest,
JSON.parse(message),
);
// validate instance
await validateOrReject(createAdRequest);
// validate nested objects (fixes direct nested validation bug)
for (const waypoint of createAdRequest.waypoints) {
try {
await validateOrReject(waypoint);
} catch (e) {
throw e;
}
}
await this.commandBus.execute(new CreateAdCommand(createAdRequest));
} catch (e) {
this.messager.publish(
'logging.matcher.ad.crit',
JSON.stringify({
message,
error: e,
}),
);
}
}
}
@@ -0,0 +1,131 @@
import { Injectable } from '@nestjs/common';
import { MatcherRepository } from '../../../database/domain/matcher-repository';
import { Ad } from '../../domain/entities/ad';
import { DatabaseException } from '../../../database/exceptions/database.exception';
@Injectable()
export class AdRepository extends MatcherRepository<Ad> {
protected model = 'ad';
async createAd(ad: Partial<Ad>): Promise<Ad> {
try {
const affectedRowNumber = await this.createWithFields(
this.createFields(ad),
);
if (affectedRowNumber == 1) {
return this.findOneByUuid(ad.uuid);
}
throw new DatabaseException();
} catch (e) {
throw e;
}
}
private createFields(ad: Partial<Ad>): Partial<AdFields> {
return {
uuid: `'${ad.uuid}'`,
userUuid: `'${ad.userUuid}'`,
driver: ad.driver ? 'true' : 'false',
passenger: ad.passenger ? 'true' : 'false',
frequency: `'${ad.frequency}'`,
fromDate: `'${ad.fromDate.getFullYear()}-${
ad.fromDate.getMonth() + 1
}-${ad.fromDate.getDate()}'`,
toDate: `'${ad.toDate.getFullYear()}-${
ad.toDate.getMonth() + 1
}-${ad.toDate.getDate()}'`,
monTime: ad.monTime
? `'${ad.monTime.getFullYear()}-${
ad.monTime.getMonth() + 1
}-${ad.monTime.getDate()}T${ad.monTime.getHours()}:${ad.monTime.getMinutes()}Z'`
: 'NULL',
tueTime: ad.tueTime
? `'${ad.tueTime.getFullYear()}-${
ad.tueTime.getMonth() + 1
}-${ad.tueTime.getDate()}T${ad.tueTime.getHours()}:${ad.tueTime.getMinutes()}Z'`
: 'NULL',
wedTime: ad.wedTime
? `'${ad.wedTime.getFullYear()}-${
ad.wedTime.getMonth() + 1
}-${ad.wedTime.getDate()}T${ad.wedTime.getHours()}:${ad.wedTime.getMinutes()}Z'`
: 'NULL',
thuTime: ad.thuTime
? `'${ad.thuTime.getFullYear()}-${
ad.thuTime.getMonth() + 1
}-${ad.thuTime.getDate()}T${ad.thuTime.getHours()}:${ad.thuTime.getMinutes()}Z'`
: 'NULL',
friTime: ad.friTime
? `'${ad.friTime.getFullYear()}-${
ad.friTime.getMonth() + 1
}-${ad.friTime.getDate()}T${ad.friTime.getHours()}:${ad.friTime.getMinutes()}Z'`
: 'NULL',
satTime: ad.satTime
? `'${ad.satTime.getFullYear()}-${
ad.satTime.getMonth() + 1
}-${ad.satTime.getDate()}T${ad.satTime.getHours()}:${ad.satTime.getMinutes()}Z'`
: 'NULL',
sunTime: ad.sunTime
? `'${ad.sunTime.getFullYear()}-${
ad.sunTime.getMonth() + 1
}-${ad.sunTime.getDate()}T${ad.sunTime.getHours()}:${ad.sunTime.getMinutes()}Z'`
: 'NULL',
monMargin: ad.monMargin,
tueMargin: ad.tueMargin,
wedMargin: ad.wedMargin,
thuMargin: ad.thuMargin,
friMargin: ad.friMargin,
satMargin: ad.satMargin,
sunMargin: ad.sunMargin,
fwdAzimuth: ad.fwdAzimuth,
backAzimuth: ad.backAzimuth,
driverDuration: ad.driverDuration ?? 'NULL',
driverDistance: ad.driverDistance ?? 'NULL',
passengerDuration: ad.passengerDuration ?? 'NULL',
passengerDistance: ad.passengerDistance ?? 'NULL',
waypoints: ad.waypoints,
direction: ad.direction,
seatsDriver: ad.seatsDriver,
seatsPassenger: ad.seatsPassenger,
seatsUsed: ad.seatsUsed ?? 0,
strict: ad.strict,
};
}
}
type AdFields = {
uuid: string;
userUuid: string;
driver: string;
passenger: string;
frequency: string;
fromDate: string;
toDate: string;
monTime: string;
tueTime: string;
wedTime: string;
thuTime: string;
friTime: string;
satTime: string;
sunTime: string;
monMargin: number;
tueMargin: number;
wedMargin: number;
thuMargin: number;
friMargin: number;
satMargin: number;
sunMargin: number;
driverDuration?: number | 'NULL';
driverDistance?: number | 'NULL';
passengerDuration?: number | 'NULL';
passengerDistance?: number | 'NULL';
waypoints: string;
direction: string;
fwdAzimuth: number;
backAzimuth: number;
seatsDriver?: number;
seatsPassenger?: number;
seatsUsed?: number;
strict: boolean;
createdAt: string;
updatedAt: string;
};
@@ -0,0 +1,17 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { DefaultParams } from '../../domain/types/default-params.type';
import { IProvideParams } from '../../domain/interfaces/params-provider.interface';
@Injectable()
export class DefaultParamsProvider implements IProvideParams {
constructor(private readonly configService: ConfigService) {}
getParams = (): DefaultParams => {
return {
DEFAULT_TIMEZONE: this.configService.get('DEFAULT_TIMEZONE'),
GEOROUTER_TYPE: this.configService.get('GEOROUTER_TYPE'),
GEOROUTER_URL: this.configService.get('GEOROUTER_URL'),
};
};
}
@@ -0,0 +1,12 @@
import { Injectable } from '@nestjs/common';
@Injectable()
export abstract class MessageBroker {
exchange: string;
constructor(exchange: string) {
this.exchange = exchange;
}
abstract publish(routingKey: string, message: string): void;
}
@@ -0,0 +1,18 @@
import { AmqpConnection } from '@golevelup/nestjs-rabbitmq';
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { MessageBroker } from './message-broker';
@Injectable()
export class Messager extends MessageBroker {
constructor(
private readonly amqpConnection: AmqpConnection,
configService: ConfigService,
) {
super(configService.get<string>('RMQ_EXCHANGE'));
}
publish = (routingKey: string, message: string): void => {
this.amqpConnection.publish(this.exchange, routingKey, message);
};
}
@@ -0,0 +1,11 @@
import { Injectable } from '@nestjs/common';
import { GeoTimezoneFinder } from '../../../geography/adapters/secondaries/geo-timezone-finder';
import { IFindTimezone } from '../../../geography/domain/interfaces/timezone-finder.interface';
@Injectable()
export class TimezoneFinder implements IFindTimezone {
constructor(private readonly geoTimezoneFinder: GeoTimezoneFinder) {}
timezones = (lon: number, lat: number): string[] =>
this.geoTimezoneFinder.timezones(lon, lat);
}
@@ -0,0 +1,9 @@
import { CreateAdRequest } from '../domain/dtos/create-ad.request';
export class CreateAdCommand {
readonly createAdRequest: CreateAdRequest;
constructor(request: CreateAdRequest) {
this.createAdRequest = request;
}
}
@@ -0,0 +1,140 @@
import { AutoMap } from '@automapper/classes';
import {
ArrayMinSize,
IsArray,
IsBoolean,
IsDate,
IsEnum,
IsMilitaryTime,
IsNotEmpty,
IsNumber,
IsOptional,
IsString,
} from 'class-validator';
import { Frequency } from '../types/frequency.enum';
import { Coordinate } from '../../../geography/domain/entities/coordinate';
import { Type } from 'class-transformer';
import { HasTruthyWith } from './has-truthy-with.validator';
export class CreateAdRequest {
@IsString()
@IsNotEmpty()
@AutoMap()
uuid: string;
@IsString()
@IsNotEmpty()
@AutoMap()
userUuid: string;
@HasTruthyWith('passenger', {
message: 'A role (driver or passenger) must be set to true',
})
@IsBoolean()
@AutoMap()
driver: boolean;
@IsBoolean()
@AutoMap()
passenger: boolean;
@IsEnum(Frequency)
@AutoMap()
frequency: Frequency;
@Type(() => Date)
@IsDate()
@AutoMap()
fromDate: Date;
@Type(() => Date)
@IsDate()
@AutoMap()
toDate: Date;
@IsOptional()
@IsMilitaryTime()
@AutoMap()
monTime?: string;
@IsOptional()
@IsMilitaryTime()
@AutoMap()
tueTime?: string;
@IsOptional()
@IsMilitaryTime()
@AutoMap()
wedTime?: string;
@IsOptional()
@IsMilitaryTime()
@AutoMap()
thuTime?: string;
@IsOptional()
@IsMilitaryTime()
@AutoMap()
friTime?: string;
@IsOptional()
@IsMilitaryTime()
@AutoMap()
satTime?: string;
@IsOptional()
@IsMilitaryTime()
@AutoMap()
sunTime?: string;
@IsNumber()
@AutoMap()
monMargin: number;
@IsNumber()
@AutoMap()
tueMargin: number;
@IsNumber()
@AutoMap()
wedMargin: number;
@IsNumber()
@AutoMap()
thuMargin: number;
@IsNumber()
@AutoMap()
friMargin: number;
@IsNumber()
@AutoMap()
satMargin: number;
@IsNumber()
@AutoMap()
sunMargin: number;
@Type(() => Coordinate)
@IsArray()
@ArrayMinSize(2)
@AutoMap(() => [Coordinate])
waypoints: Coordinate[];
@IsNumber()
@AutoMap()
seatsDriver: number;
@IsNumber()
@AutoMap()
seatsPassenger: number;
@IsOptional()
@IsNumber()
@AutoMap()
seatsUsed?: number;
@IsBoolean()
@AutoMap()
strict: boolean;
}
@@ -0,0 +1,32 @@
import {
registerDecorator,
ValidationOptions,
ValidationArguments,
} from 'class-validator';
export function HasTruthyWith(
property: string,
validationOptions?: ValidationOptions,
) {
// eslint-disable-next-line @typescript-eslint/ban-types
return function (object: Object, propertyName: string) {
registerDecorator({
name: 'hasTruthyWith',
target: object.constructor,
propertyName: propertyName,
constraints: [property],
options: validationOptions,
validator: {
validate(value: any, args: ValidationArguments) {
const [relatedPropertyName] = args.constraints;
const relatedValue = (args.object as any)[relatedPropertyName];
return (
typeof value === 'boolean' &&
typeof relatedValue === 'boolean' &&
(value || relatedValue)
); // you can return a Promise<boolean> here as well, if you want to make async validation
},
},
});
};
}
+109
View File
@@ -0,0 +1,109 @@
import { AutoMap } from '@automapper/classes';
import { Frequency } from '../types/frequency.enum';
export class Ad {
@AutoMap()
uuid: string;
@AutoMap()
userUuid: string;
@AutoMap()
driver: boolean;
@AutoMap()
passenger: boolean;
@AutoMap()
frequency: Frequency;
@AutoMap()
fromDate: Date;
@AutoMap()
toDate: Date;
@AutoMap()
monTime: Date;
@AutoMap()
tueTime: Date;
@AutoMap()
wedTime: Date;
@AutoMap()
thuTime: Date;
@AutoMap()
friTime: Date;
@AutoMap()
satTime: Date;
@AutoMap()
sunTime: Date;
@AutoMap()
monMargin: number;
@AutoMap()
tueMargin: number;
@AutoMap()
wedMargin: number;
@AutoMap()
thuMargin: number;
@AutoMap()
friMargin: number;
@AutoMap()
satMargin: number;
@AutoMap()
sunMargin: number;
@AutoMap()
driverDuration?: number;
@AutoMap()
driverDistance?: number;
@AutoMap()
passengerDuration?: number;
@AutoMap()
passengerDistance?: number;
@AutoMap()
waypoints: string;
@AutoMap()
direction: string;
@AutoMap()
fwdAzimuth: number;
@AutoMap()
backAzimuth: number;
@AutoMap()
seatsDriver: number;
@AutoMap()
seatsPassenger: number;
@AutoMap()
seatsUsed: number;
@AutoMap()
strict: boolean;
@AutoMap()
createdAt: Date;
@AutoMap()
updatedAt: Date;
}
@@ -0,0 +1,92 @@
import { Coordinate } from '../../../geography/domain/entities/coordinate';
import { Route } from '../../../geography/domain/entities/route';
import { Role } from '../types/role.enum';
import { IGeorouter } from '../../../geography/domain/interfaces/georouter.interface';
import { Path } from '../../../geography/domain/types/path.type';
import { GeorouterSettings } from '../../../geography/domain/types/georouter-settings.type';
export class Geography {
private coordinates: Coordinate[];
driverRoute: Route;
passengerRoute: Route;
constructor(coordinates: Coordinate[]) {
this.coordinates = coordinates;
}
createRoutes = async (
roles: Role[],
georouter: IGeorouter,
settings: GeorouterSettings,
): Promise<void> => {
const paths: Path[] = this.getPaths(roles);
const routes = await georouter.route(paths, settings);
if (routes.some((route) => route.key == RouteKey.COMMON)) {
this.driverRoute = routes.find(
(route) => route.key == RouteKey.COMMON,
).route;
this.passengerRoute = routes.find(
(route) => route.key == RouteKey.COMMON,
).route;
} else {
if (routes.some((route) => route.key == RouteKey.DRIVER)) {
this.driverRoute = routes.find(
(route) => route.key == RouteKey.DRIVER,
).route;
}
if (routes.some((route) => route.key == RouteKey.PASSENGER)) {
this.passengerRoute = routes.find(
(route) => route.key == RouteKey.PASSENGER,
).route;
}
}
};
private getPaths = (roles: Role[]): Path[] => {
const paths: Path[] = [];
if (roles.includes(Role.DRIVER) && roles.includes(Role.PASSENGER)) {
if (this.coordinates.length == 2) {
// 2 points => same route for driver and passenger
const commonPath: Path = {
key: RouteKey.COMMON,
points: this.coordinates,
};
paths.push(commonPath);
} else {
const driverPath: Path = this.createDriverPath();
const passengerPath: Path = this.createPassengerPath();
paths.push(driverPath, passengerPath);
}
} else if (roles.includes(Role.DRIVER)) {
const driverPath: Path = this.createDriverPath();
paths.push(driverPath);
} else if (roles.includes(Role.PASSENGER)) {
const passengerPath: Path = this.createPassengerPath();
paths.push(passengerPath);
}
return paths;
};
private createDriverPath = (): Path => {
return {
key: RouteKey.DRIVER,
points: this.coordinates,
};
};
private createPassengerPath = (): Path => {
return {
key: RouteKey.PASSENGER,
points: [
this.coordinates[0],
this.coordinates[this.coordinates.length - 1],
],
};
};
}
export enum RouteKey {
COMMON = 'common',
DRIVER = 'driver',
PASSENGER = 'passenger',
}
@@ -0,0 +1,19 @@
import { DateTime, TimeZone } from 'timezonecomplete';
export class TimeConverter {
static toUtcDatetime = (date: Date, time: string, timezone: string): Date => {
try {
if (!date || !time || !timezone) throw new Error();
return new Date(
new DateTime(
`${date.toISOString().split('T')[0]}T${time}:00`,
TimeZone.zone(timezone, false),
)
.convert(TimeZone.zone('UTC'))
.toIsoString(),
);
} catch (e) {
return undefined;
}
};
}
@@ -0,0 +1,5 @@
import { DefaultParams } from '../types/default-params.type';
export interface IProvideParams {
getParams(): DefaultParams;
}
@@ -0,0 +1,5 @@
export type DefaultParams = {
DEFAULT_TIMEZONE: string;
GEOROUTER_TYPE: string;
GEOROUTER_URL: string;
};
@@ -0,0 +1,4 @@
export enum Frequency {
PUNCTUAL = 'PUNCTUAL',
RECURRENT = 'RECURRENT',
}
+4
View File
@@ -0,0 +1,4 @@
export enum Role {
DRIVER = 'DRIVER',
PASSENGER = 'PASSENGER',
}
@@ -0,0 +1,144 @@
import { CommandHandler } from '@nestjs/cqrs';
import { CreateAdCommand } from '../../commands/create-ad.command';
import { Ad } from '../entities/ad';
import { AdRepository } from '../../adapters/secondaries/ad.repository';
import { InjectMapper } from '@automapper/nestjs';
import { Mapper } from '@automapper/core';
import { CreateAdRequest } from '../dtos/create-ad.request';
import { Inject } from '@nestjs/common';
import { IProvideParams } from '../interfaces/params-provider.interface';
import { ICreateGeorouter } from '../../../geography/domain/interfaces/georouter-creator.interface';
import { IFindTimezone } from '../../../geography/domain/interfaces/timezone-finder.interface';
import { IGeorouter } from '../../../geography/domain/interfaces/georouter.interface';
import { DefaultParams } from '../types/default-params.type';
import { Role } from '../types/role.enum';
import { Geography } from '../entities/geography';
import { IEncodeDirection } from '../../../geography/domain/interfaces/direction-encoder.interface';
import { TimeConverter } from '../entities/time-converter';
import { Coordinate } from '../../../geography/domain/entities/coordinate';
@CommandHandler(CreateAdCommand)
export class CreateAdUseCase {
private readonly georouter: IGeorouter;
private readonly defaultParams: DefaultParams;
private timezone: string;
private roles: Role[];
private geography: Geography;
private ad: Ad;
constructor(
@InjectMapper() private readonly mapper: Mapper,
private readonly adRepository: AdRepository,
@Inject('ParamsProvider')
private readonly defaultParamsProvider: IProvideParams,
@Inject('GeorouterCreator')
private readonly georouterCreator: ICreateGeorouter,
@Inject('TimezoneFinder')
private readonly timezoneFinder: IFindTimezone,
@Inject('DirectionEncoder')
private readonly directionEncoder: IEncodeDirection,
) {
this.defaultParams = defaultParamsProvider.getParams();
this.georouter = georouterCreator.create(
this.defaultParams.GEOROUTER_TYPE,
this.defaultParams.GEOROUTER_URL,
);
}
async execute(command: CreateAdCommand): Promise<Ad> {
try {
this.ad = this.mapper.map(command.createAdRequest, CreateAdRequest, Ad);
this.setTimezone(command.createAdRequest.waypoints);
this.setGeography(command.createAdRequest.waypoints);
this.setRoles(command.createAdRequest);
await this.geography.createRoutes(this.roles, this.georouter, {
withDistance: false,
withPoints: true,
withTime: false,
});
this.setAdGeography(command);
this.setAdSchedule(command);
return await this.adRepository.createAd(this.ad);
} catch (error) {
throw error;
}
}
private setTimezone = (coordinates: Coordinate[]): void => {
this.timezone = this.defaultParams.DEFAULT_TIMEZONE;
try {
const timezones = this.timezoneFinder.timezones(
coordinates[0].lon,
coordinates[0].lat,
);
if (timezones.length > 0) this.timezone = timezones[0];
} catch (e) {}
};
private setRoles = (createAdRequest: CreateAdRequest): void => {
this.roles = [];
if (createAdRequest.driver) this.roles.push(Role.DRIVER);
if (createAdRequest.passenger) this.roles.push(Role.PASSENGER);
};
private setGeography = (coordinates: Coordinate[]): void => {
this.geography = new Geography(coordinates);
};
private setAdGeography = (command: CreateAdCommand): void => {
this.ad.driverDistance = this.geography.driverRoute?.distance;
this.ad.driverDuration = this.geography.driverRoute?.duration;
this.ad.passengerDistance = this.geography.passengerRoute?.distance;
this.ad.passengerDuration = this.geography.passengerRoute?.duration;
this.ad.fwdAzimuth = this.geography.driverRoute
? this.geography.driverRoute.fwdAzimuth
: this.geography.passengerRoute.fwdAzimuth;
this.ad.backAzimuth = this.geography.driverRoute
? this.geography.driverRoute.backAzimuth
: this.geography.passengerRoute.backAzimuth;
this.ad.waypoints = this.directionEncoder.encode(
command.createAdRequest.waypoints,
);
this.ad.direction = this.geography.driverRoute
? this.directionEncoder.encode(this.geography.driverRoute.points)
: undefined;
};
private setAdSchedule = (command: CreateAdCommand): void => {
this.ad.monTime = TimeConverter.toUtcDatetime(
this.ad.fromDate,
command.createAdRequest.monTime,
this.timezone,
);
this.ad.tueTime = TimeConverter.toUtcDatetime(
this.ad.fromDate,
command.createAdRequest.tueTime,
this.timezone,
);
this.ad.wedTime = TimeConverter.toUtcDatetime(
this.ad.fromDate,
command.createAdRequest.wedTime,
this.timezone,
);
this.ad.thuTime = TimeConverter.toUtcDatetime(
this.ad.fromDate,
command.createAdRequest.thuTime,
this.timezone,
);
this.ad.friTime = TimeConverter.toUtcDatetime(
this.ad.fromDate,
command.createAdRequest.friTime,
this.timezone,
);
this.ad.satTime = TimeConverter.toUtcDatetime(
this.ad.fromDate,
command.createAdRequest.satTime,
this.timezone,
);
this.ad.sunTime = TimeConverter.toUtcDatetime(
this.ad.fromDate,
command.createAdRequest.sunTime,
this.timezone,
);
};
}
+18
View File
@@ -0,0 +1,18 @@
import { createMap, Mapper } from '@automapper/core';
import { AutomapperProfile, InjectMapper } from '@automapper/nestjs';
import { Injectable } from '@nestjs/common';
import { Ad } from '../domain/entities/ad';
import { CreateAdRequest } from '../domain/dtos/create-ad.request';
@Injectable()
export class AdProfile extends AutomapperProfile {
constructor(@InjectMapper() mapper: Mapper) {
super(mapper);
}
override get profile() {
return (mapper: any) => {
createMap(mapper, CreateAdRequest, Ad);
};
}
}
@@ -0,0 +1,402 @@
import { TestingModule, Test } from '@nestjs/testing';
import { DatabaseModule } from '../../../database/database.module';
import { PrismaService } from '../../../database/adapters/secondaries/prisma-service';
import { AdRepository } from '../../adapters/secondaries/ad.repository';
import { Ad } from '../../domain/entities/ad';
import { Frequency } from '../../domain/types/frequency.enum';
describe('AdRepository', () => {
let prismaService: PrismaService;
let adRepository: AdRepository;
const baseUuid = {
uuid: 'be459a29-7a41-4c0b-b371-abe90bfb6f00',
};
const baseUserUuid = {
userUuid: '4e52b54d-a729-4dbd-9283-f84a11bb2200',
};
const driverAd = {
driver: 'true',
passenger: 'false',
fwdAzimuth: 0,
backAzimuth: 180,
waypoints: "'LINESTRING(6 47,6.1 47.1,6.2 47.2)'",
direction: "'LINESTRING(6 47,6.05 47.05,6.1 47.1,6.15 47.15,6.2 47.2)'",
seatsDriver: 3,
seatsPassenger: 1,
seatsUsed: 0,
strict: 'false',
};
const passengerAd = {
driver: 'false',
passenger: 'true',
fwdAzimuth: 0,
backAzimuth: 180,
waypoints: "'LINESTRING(6 47,6.2 47.2)'",
direction: "'LINESTRING(6 47,6.05 47.05,6.15 47.15,6.2 47.2)'",
seatsDriver: 3,
seatsPassenger: 1,
seatsUsed: 0,
strict: 'false',
};
const driverAndPassengerAd = {
driver: 'true',
passenger: 'true',
fwdAzimuth: 0,
backAzimuth: 180,
waypoints: "'LINESTRING(6 47,6.1 47.1,6.2 47.2)'",
direction: "'LINESTRING(6 47,6.05 47.05,6.1 47.1,6.15 47.15,6.2 47.2)'",
seatsDriver: 3,
seatsPassenger: 1,
seatsUsed: 0,
strict: 'false',
};
const punctualAd = {
frequency: `'PUNCTUAL'`,
fromDate: `'2023-01-01'`,
toDate: `'2023-01-01'`,
monTime: 'NULL',
tueTime: 'NULL',
wedTime: 'NULL',
thuTime: 'NULL',
friTime: 'NULL',
satTime: 'NULL',
sunTime: `'2023-01-01T07:00Z'`,
monMargin: 900,
tueMargin: 900,
wedMargin: 900,
thuMargin: 900,
friMargin: 900,
satMargin: 900,
sunMargin: 900,
};
const recurrentAd = {
frequency: `'RECURRENT'`,
fromDate: `'2023-01-01'`,
toDate: `'2023-12-31'`,
monTime: `'2023-01-01T07:00Z'`,
tueTime: `'2023-01-01T07:00Z'`,
wedTime: `'2023-01-01T07:00Z'`,
thuTime: `'2023-01-01T07:00Z'`,
friTime: `'2023-01-01T07:00Z'`,
satTime: 'NULL',
sunTime: 'NULL',
monMargin: 900,
tueMargin: 900,
wedMargin: 900,
thuMargin: 900,
friMargin: 900,
satMargin: 900,
sunMargin: 900,
};
const createPunctualDriverAds = async (nbToCreate = 10) => {
const adToCreate = {
...baseUuid,
...baseUserUuid,
...driverAd,
...punctualAd,
};
for (let i = 0; i < nbToCreate; i++) {
adToCreate.uuid = `'${baseUuid.uuid.slice(0, -2)}${i
.toString(16)
.padStart(2, '0')}'`;
adToCreate.userUuid = `'${baseUserUuid.userUuid.slice(0, -2)}${i
.toString(16)
.padStart(2, '0')}'`;
await executeInsertCommand(adToCreate);
}
};
const createRecurrentDriverAds = async (nbToCreate = 10) => {
const adToCreate = {
...baseUuid,
...baseUserUuid,
...driverAd,
...recurrentAd,
};
for (let i = 0; i < nbToCreate; i++) {
adToCreate.uuid = `'${baseUuid.uuid.slice(0, -2)}${i
.toString(16)
.padStart(2, '0')}'`;
adToCreate.userUuid = `'${baseUserUuid.userUuid.slice(0, -2)}${i
.toString(16)
.padStart(2, '0')}'`;
await executeInsertCommand(adToCreate);
}
};
const createPunctualPassengerAds = async (nbToCreate = 10) => {
const adToCreate = {
...baseUuid,
...baseUserUuid,
...passengerAd,
...punctualAd,
};
for (let i = 0; i < nbToCreate; i++) {
adToCreate.uuid = `'${baseUuid.uuid.slice(0, -2)}${i
.toString(16)
.padStart(2, '0')}'`;
adToCreate.userUuid = `'${baseUserUuid.userUuid.slice(0, -2)}${i
.toString(16)
.padStart(2, '0')}'`;
await executeInsertCommand(adToCreate);
}
};
const createRecurrentPassengerAds = async (nbToCreate = 10) => {
const adToCreate = {
...baseUuid,
...baseUserUuid,
...passengerAd,
...recurrentAd,
};
for (let i = 0; i < nbToCreate; i++) {
adToCreate.uuid = `'${baseUuid.uuid.slice(0, -2)}${i
.toString(16)
.padStart(2, '0')}'`;
adToCreate.userUuid = `'${baseUserUuid.userUuid.slice(0, -2)}${i
.toString(16)
.padStart(2, '0')}'`;
await executeInsertCommand(adToCreate);
}
};
const createPunctualDriverPassengerAds = async (nbToCreate = 10) => {
const adToCreate = {
...baseUuid,
...baseUserUuid,
...driverAndPassengerAd,
...punctualAd,
};
for (let i = 0; i < nbToCreate; i++) {
adToCreate.uuid = `'${baseUuid.uuid.slice(0, -2)}${i
.toString(16)
.padStart(2, '0')}'`;
adToCreate.userUuid = `'${baseUserUuid.userUuid.slice(0, -2)}${i
.toString(16)
.padStart(2, '0')}'`;
await executeInsertCommand(adToCreate);
}
};
const createRecurrentDriverPassengerAds = async (nbToCreate = 10) => {
const adToCreate = {
...baseUuid,
...baseUserUuid,
...driverAndPassengerAd,
...recurrentAd,
};
for (let i = 0; i < nbToCreate; i++) {
adToCreate.uuid = `'${baseUuid.uuid.slice(0, -2)}${i
.toString(16)
.padStart(2, '0')}'`;
adToCreate.userUuid = `'${baseUserUuid.userUuid.slice(0, -2)}${i
.toString(16)
.padStart(2, '0')}'`;
await executeInsertCommand(adToCreate);
}
};
const executeInsertCommand = async (object: any) => {
const command = `INSERT INTO ad ("${Object.keys(object).join(
'","',
)}") VALUES (${Object.values(object).join(',')})`;
await prismaService.$executeRawUnsafe(command);
};
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
imports: [DatabaseModule],
providers: [AdRepository, PrismaService],
}).compile();
prismaService = module.get<PrismaService>(PrismaService);
adRepository = module.get<AdRepository>(AdRepository);
});
afterAll(async () => {
await prismaService.$disconnect();
});
beforeEach(async () => {
await prismaService.ad.deleteMany();
});
describe('findAll', () => {
it('should return an empty data array', async () => {
const res = await adRepository.findAll();
expect(res).toEqual({
data: [],
total: 0,
});
});
describe('drivers', () => {
it('should return a data array with 8 punctual driver ads', async () => {
await createPunctualDriverAds(8);
const ads = await adRepository.findAll();
expect(ads.data.length).toBe(8);
expect(ads.total).toBe(8);
expect(ads.data[0].driver).toBeTruthy();
expect(ads.data[0].passenger).toBeFalsy();
});
it('should return a data array limited to 10 punctual driver ads', async () => {
await createPunctualDriverAds(20);
const ads = await adRepository.findAll();
expect(ads.data.length).toBe(10);
expect(ads.total).toBe(20);
expect(ads.data[1].driver).toBeTruthy();
expect(ads.data[1].passenger).toBeFalsy();
});
it('should return a data array with 8 recurrent driver ads', async () => {
await createRecurrentDriverAds(8);
const ads = await adRepository.findAll();
expect(ads.data.length).toBe(8);
expect(ads.total).toBe(8);
expect(ads.data[2].driver).toBeTruthy();
expect(ads.data[2].passenger).toBeFalsy();
});
it('should return a data array limited to 10 recurrent driver ads', async () => {
await createRecurrentDriverAds(20);
const ads = await adRepository.findAll();
expect(ads.data.length).toBe(10);
expect(ads.total).toBe(20);
expect(ads.data[3].driver).toBeTruthy();
expect(ads.data[3].passenger).toBeFalsy();
});
});
describe('passengers', () => {
it('should return a data array with 7 punctual passenger ads', async () => {
await createPunctualPassengerAds(7);
const ads = await adRepository.findAll();
expect(ads.data.length).toBe(7);
expect(ads.total).toBe(7);
expect(ads.data[0].passenger).toBeTruthy();
expect(ads.data[0].driver).toBeFalsy();
});
it('should return a data array limited to 10 punctual passenger ads', async () => {
await createPunctualPassengerAds(15);
const ads = await adRepository.findAll();
expect(ads.data.length).toBe(10);
expect(ads.total).toBe(15);
expect(ads.data[1].passenger).toBeTruthy();
expect(ads.data[1].driver).toBeFalsy();
});
it('should return a data array with 7 recurrent passenger ads', async () => {
await createRecurrentPassengerAds(7);
const ads = await adRepository.findAll();
expect(ads.data.length).toBe(7);
expect(ads.total).toBe(7);
expect(ads.data[2].passenger).toBeTruthy();
expect(ads.data[2].driver).toBeFalsy();
});
it('should return a data array limited to 10 recurrent passenger ads', async () => {
await createRecurrentPassengerAds(15);
const ads = await adRepository.findAll();
expect(ads.data.length).toBe(10);
expect(ads.total).toBe(15);
expect(ads.data[3].passenger).toBeTruthy();
expect(ads.data[3].driver).toBeFalsy();
});
});
describe('drivers and passengers', () => {
it('should return a data array with 6 punctual driver and passenger ads', async () => {
await createPunctualDriverPassengerAds(6);
const ads = await adRepository.findAll();
expect(ads.data.length).toBe(6);
expect(ads.total).toBe(6);
expect(ads.data[0].passenger).toBeTruthy();
expect(ads.data[0].driver).toBeTruthy();
});
it('should return a data array limited to 10 punctual driver and passenger ads', async () => {
await createPunctualDriverPassengerAds(16);
const ads = await adRepository.findAll();
expect(ads.data.length).toBe(10);
expect(ads.total).toBe(16);
expect(ads.data[1].passenger).toBeTruthy();
expect(ads.data[1].driver).toBeTruthy();
});
it('should return a data array with 6 recurrent driver and passenger ads', async () => {
await createRecurrentDriverPassengerAds(6);
const ads = await adRepository.findAll();
expect(ads.data.length).toBe(6);
expect(ads.total).toBe(6);
expect(ads.data[2].passenger).toBeTruthy();
expect(ads.data[2].driver).toBeTruthy();
});
it('should return a data array limited to 10 recurrent driver and passenger ads', async () => {
await createRecurrentDriverPassengerAds(16);
const ads = await adRepository.findAll();
expect(ads.data.length).toBe(10);
expect(ads.total).toBe(16);
expect(ads.data[3].passenger).toBeTruthy();
expect(ads.data[3].driver).toBeTruthy();
});
});
});
describe('findOneByUuid', () => {
it('should return an ad', async () => {
await createPunctualDriverAds(1);
const ad = await adRepository.findOneByUuid(baseUuid.uuid);
expect(ad.uuid).toBe(baseUuid.uuid);
});
it('should return null', async () => {
const ad = await adRepository.findOneByUuid(
'544572be-11fb-4244-8235-587221fc9104',
);
expect(ad).toBeNull();
});
});
describe('create', () => {
it('should create an ad', async () => {
const beforeCount = await prismaService.ad.count();
const adToCreate: Ad = new Ad();
adToCreate.uuid = 'be459a29-7a41-4c0b-b371-abe90bfb6f00';
adToCreate.userUuid = '4e52b54d-a729-4dbd-9283-f84a11bb2200';
adToCreate.driver = true;
adToCreate.passenger = false;
adToCreate.fwdAzimuth = 0;
adToCreate.backAzimuth = 180;
adToCreate.waypoints = "'LINESTRING(6 47,6.1 47.1,6.2 47.2)'";
adToCreate.direction =
"'LINESTRING(6 47,6.05 47.05,6.1 47.1,6.15 47.15,6.2 47.2)'";
adToCreate.seatsDriver = 3;
adToCreate.seatsPassenger = 1;
adToCreate.seatsUsed = 0;
adToCreate.strict = false;
adToCreate.frequency = Frequency.PUNCTUAL;
adToCreate.fromDate = new Date(2023, 0, 1);
adToCreate.toDate = new Date(2023, 0, 1);
adToCreate.sunTime = new Date(2023, 0, 1, 6, 0, 0);
adToCreate.monMargin = 900;
adToCreate.tueMargin = 900;
adToCreate.wedMargin = 900;
adToCreate.thuMargin = 900;
adToCreate.friMargin = 900;
adToCreate.satMargin = 900;
adToCreate.sunMargin = 900;
const ad = await adRepository.createAd(adToCreate);
const afterCount = await prismaService.ad.count();
expect(afterCount - beforeCount).toBe(1);
expect(ad.uuid).toBe('be459a29-7a41-4c0b-b371-abe90bfb6f00');
});
});
});
@@ -0,0 +1,38 @@
import { ConfigService } from '@nestjs/config';
import { Test, TestingModule } from '@nestjs/testing';
import { DefaultParamsProvider } from '../../../../adapters/secondaries/default-params.provider';
import { DefaultParams } from '../../../../domain/types/default-params.type';
const mockConfigService = {
get: jest.fn().mockImplementation(() => 'some_default_value'),
};
describe('DefaultParamsProvider', () => {
let defaultParamsProvider: DefaultParamsProvider;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
imports: [],
providers: [
DefaultParamsProvider,
{
provide: ConfigService,
useValue: mockConfigService,
},
],
}).compile();
defaultParamsProvider = module.get<DefaultParamsProvider>(
DefaultParamsProvider,
);
});
it('should be defined', () => {
expect(defaultParamsProvider).toBeDefined();
});
it('should provide default params', async () => {
const params: DefaultParams = defaultParamsProvider.getParams();
expect(params.GEOROUTER_URL).toBe('some_default_value');
});
});
@@ -0,0 +1,47 @@
import { AmqpConnection } from '@golevelup/nestjs-rabbitmq';
import { ConfigService } from '@nestjs/config';
import { Test, TestingModule } from '@nestjs/testing';
import { Messager } from '../../../../adapters/secondaries/messager';
const mockAmqpConnection = {
publish: jest.fn().mockImplementation(),
};
const mockConfigService = {
get: jest.fn().mockResolvedValue({
RMQ_EXCHANGE: 'mobicoop',
}),
};
describe('Messager', () => {
let messager: Messager;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
imports: [],
providers: [
Messager,
{
provide: AmqpConnection,
useValue: mockAmqpConnection,
},
{
provide: ConfigService,
useValue: mockConfigService,
},
],
}).compile();
messager = module.get<Messager>(Messager);
});
it('should be defined', () => {
expect(messager).toBeDefined();
});
it('should publish a message', async () => {
jest.spyOn(mockAmqpConnection, 'publish');
messager.publish('test.create.info', 'my-test');
expect(mockAmqpConnection.publish).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,35 @@
import { Test, TestingModule } from '@nestjs/testing';
import { TimezoneFinder } from '../../../../adapters/secondaries/timezone-finder';
import { GeoTimezoneFinder } from '../../../../../geography/adapters/secondaries/geo-timezone-finder';
const mockGeoTimezoneFinder = {
timezones: jest.fn().mockImplementation(() => ['Europe/Paris']),
};
describe('Timezone Finder', () => {
let timezoneFinder: TimezoneFinder;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
imports: [],
providers: [
TimezoneFinder,
{
provide: GeoTimezoneFinder,
useValue: mockGeoTimezoneFinder,
},
],
}).compile();
timezoneFinder = module.get<TimezoneFinder>(TimezoneFinder);
});
it('should be defined', () => {
expect(timezoneFinder).toBeDefined();
});
it('should get timezone for Nancy(France) as Europe/Paris', () => {
const timezones = timezoneFinder.timezones(6.179373, 48.687913);
expect(timezones.length).toBe(1);
expect(timezones[0]).toBe('Europe/Paris');
});
});
@@ -0,0 +1,176 @@
import { CreateAdRequest } from '../../../domain/dtos/create-ad.request';
import { CreateAdUseCase } from '../../../domain/usecases/create-ad.usecase';
import { Test, TestingModule } from '@nestjs/testing';
import { AutomapperModule } from '@automapper/nestjs';
import { classes } from '@automapper/classes';
import { AdRepository } from '../../../adapters/secondaries/ad.repository';
import { CreateAdCommand } from '../../../commands/create-ad.command';
import { Ad } from '../../../domain/entities/ad';
import { AdProfile } from '../../../mappers/ad.profile';
import { Frequency } from '../../../domain/types/frequency.enum';
import { RouteKey } from '../../../domain/entities/geography';
import { DatabaseException } from '../../../../database/exceptions/database.exception';
import { Route } from '../../../../geography/domain/entities/route';
const mockAdRepository = {
createAd: jest.fn().mockImplementation((ad) => {
if (ad.uuid == '00000000-0000-0000-0000-000000000000')
throw new DatabaseException();
return new Ad();
}),
};
const mockGeorouterCreator = {
create: jest.fn().mockImplementation(() => ({
route: jest.fn().mockImplementation(() => [
{
key: RouteKey.DRIVER,
route: <Route>{
points: [],
fwdAzimuth: 0,
backAzimuth: 180,
distance: 20000,
duration: 1800,
},
},
{
key: RouteKey.PASSENGER,
route: <Route>{
points: [],
fwdAzimuth: 0,
backAzimuth: 180,
distance: 20000,
duration: 1800,
},
},
{
key: RouteKey.COMMON,
route: <Route>{
points: [],
fwdAzimuth: 0,
backAzimuth: 180,
distance: 20000,
duration: 1800,
},
},
]),
})),
};
const mockParamsProvider = {
getParams: jest.fn().mockImplementation(() => ({
DEFAULT_TIMEZONE: 'Europe/Paris',
GEOROUTER_TYPE: 'graphhopper',
GEOROUTER_URL: 'localhost',
})),
};
const mockTimezoneFinder = {
timezones: jest.fn().mockImplementation(() => ['Europe/Paris']),
};
const mockDirectionEncoder = {
encode: jest.fn(),
};
const createAdRequest: CreateAdRequest = {
uuid: '77c55dfc-c28b-4026-942e-f94e95401fb1',
userUuid: 'dfd993f6-7889-4876-9570-5e1d7b6e3f42',
driver: true,
passenger: false,
frequency: Frequency.RECURRENT,
fromDate: new Date('2023-04-26'),
toDate: new Date('2024-04-25'),
monTime: '07:00',
tueTime: '07:00',
wedTime: '07:00',
thuTime: '07:00',
friTime: '07:00',
satTime: null,
sunTime: null,
monMargin: 900,
tueMargin: 900,
wedMargin: 900,
thuMargin: 900,
friMargin: 900,
satMargin: 900,
sunMargin: 900,
seatsDriver: 3,
seatsPassenger: 1,
strict: false,
waypoints: [
{ lon: 6, lat: 45 },
{ lon: 6.5, lat: 45.5 },
],
};
const setUuid = async (uuid: string): Promise<void> => {
createAdRequest.uuid = uuid;
};
const setIsDriver = async (isDriver: boolean): Promise<void> => {
createAdRequest.driver = isDriver;
};
const setIsPassenger = async (isPassenger: boolean): Promise<void> => {
createAdRequest.passenger = isPassenger;
};
describe('CreateAdUseCase', () => {
let createAdUseCase: CreateAdUseCase;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
imports: [AutomapperModule.forRoot({ strategyInitializer: classes() })],
providers: [
{
provide: AdRepository,
useValue: mockAdRepository,
},
{
provide: 'GeorouterCreator',
useValue: mockGeorouterCreator,
},
{
provide: 'ParamsProvider',
useValue: mockParamsProvider,
},
{
provide: 'TimezoneFinder',
useValue: mockTimezoneFinder,
},
{
provide: 'DirectionEncoder',
useValue: mockDirectionEncoder,
},
AdProfile,
CreateAdUseCase,
],
}).compile();
createAdUseCase = module.get<CreateAdUseCase>(CreateAdUseCase);
});
it('should be defined', () => {
expect(createAdUseCase).toBeDefined();
});
describe('execute', () => {
it('should create an ad as driver', async () => {
const ad = await createAdUseCase.execute(
new CreateAdCommand(createAdRequest),
);
expect(ad).toBeInstanceOf(Ad);
});
it('should create an ad as passenger', async () => {
await setIsDriver(false);
await setIsPassenger(true);
const ad = await createAdUseCase.execute(
new CreateAdCommand(createAdRequest),
);
expect(ad).toBeInstanceOf(Ad);
});
it('should throw an exception if repository fails', async () => {
await setUuid('00000000-0000-0000-0000-000000000000');
await expect(
createAdUseCase.execute(new CreateAdCommand(createAdRequest)),
).rejects.toBeInstanceOf(DatabaseException);
});
});
});
@@ -0,0 +1,138 @@
import { Role } from '../../../domain/types/role.enum';
import { Geography } from '../../../domain/entities/geography';
import { Coordinate } from '../../../../geography/domain/entities/coordinate';
import { IGeorouter } from '../../../../geography/domain/interfaces/georouter.interface';
import { GeorouterSettings } from '../../../../geography/domain/types/georouter-settings.type';
import { Route } from '../../../../geography/domain/entities/route';
import { IGeodesic } from '../../../../geography/domain/interfaces/geodesic.interface';
const simpleCoordinates: Coordinate[] = [
{
lon: 6,
lat: 47,
},
{
lon: 6.1,
lat: 47.1,
},
];
const complexCoordinates: Coordinate[] = [
{
lon: 6,
lat: 47,
},
{
lon: 6.1,
lat: 47.1,
},
{
lon: 6.2,
lat: 47.2,
},
];
const mockGeodesic: IGeodesic = {
inverse: jest.fn(),
};
const driverRoute: Route = new Route(mockGeodesic);
driverRoute.distance = 25000;
const commonRoute: Route = new Route(mockGeodesic);
commonRoute.distance = 20000;
const mockGeorouter: IGeorouter = {
route: jest
.fn()
.mockResolvedValueOnce([
{
key: 'driver',
route: driverRoute,
},
])
.mockResolvedValueOnce([
{
key: 'passenger',
route: commonRoute,
},
])
.mockResolvedValueOnce([
{
key: 'common',
route: commonRoute,
},
])
.mockResolvedValueOnce([
{
key: 'driver',
route: driverRoute,
},
{
key: 'passenger',
route: commonRoute,
},
]),
};
const georouterSettings: GeorouterSettings = {
withDistance: false,
withPoints: true,
withTime: false,
};
describe('Geography entity', () => {
it('should be defined', () => {
expect(new Geography(simpleCoordinates)).toBeDefined();
});
it('should create a route as driver', async () => {
const geography = new Geography(complexCoordinates);
await geography.createRoutes(
[Role.DRIVER],
mockGeorouter,
georouterSettings,
);
expect(geography.driverRoute).toBeDefined();
expect(geography.passengerRoute).toBeUndefined();
expect(geography.driverRoute.distance).toBe(25000);
});
it('should create a route as passenger', async () => {
const geography = new Geography(simpleCoordinates);
await geography.createRoutes(
[Role.PASSENGER],
mockGeorouter,
georouterSettings,
);
expect(geography.driverRoute).toBeUndefined();
expect(geography.passengerRoute).toBeDefined();
expect(geography.passengerRoute.distance).toBe(20000);
});
it('should create routes as driver and passenger with simple coordinates', async () => {
const geography = new Geography(simpleCoordinates);
await geography.createRoutes(
[Role.DRIVER, Role.PASSENGER],
mockGeorouter,
georouterSettings,
);
expect(geography.driverRoute).toBeDefined();
expect(geography.passengerRoute).toBeDefined();
expect(geography.driverRoute.distance).toBe(20000);
expect(geography.passengerRoute.distance).toBe(20000);
});
it('should create routes as driver and passenger with complex coordinates', async () => {
const geography = new Geography(complexCoordinates);
await geography.createRoutes(
[Role.DRIVER, Role.PASSENGER],
mockGeorouter,
georouterSettings,
);
expect(geography.driverRoute).toBeDefined();
expect(geography.passengerRoute).toBeDefined();
expect(geography.driverRoute.distance).toBe(25000);
expect(geography.passengerRoute.distance).toBe(20000);
});
});
@@ -0,0 +1,53 @@
import { TimeConverter } from '../../../domain/entities/time-converter';
describe('TimeConverter', () => {
it('should be defined', () => {
expect(new TimeConverter()).toBeDefined();
});
it('should convert a Europe/Paris datetime to utc datetime', () => {
expect(
TimeConverter.toUtcDatetime(
new Date('2023-05-01'),
'07:00',
'Europe/Paris',
).getUTCHours(),
).toBe(6);
});
it('should return undefined when trying to convert a Europe/Paris datetime to utc datetime without a valid date', () => {
expect(
TimeConverter.toUtcDatetime(undefined, '07:00', 'Europe/Paris'),
).toBeUndefined();
expect(
TimeConverter.toUtcDatetime(
new Date('2023-13-01'),
'07:00',
'Europe/Paris',
),
).toBeUndefined();
});
it('should return undefined when trying to convert a Europe/Paris datetime to utc datetime without a valid time', () => {
expect(
TimeConverter.toUtcDatetime(
new Date('2023-05-01'),
undefined,
'Europe/Paris',
),
).toBeUndefined();
expect(
TimeConverter.toUtcDatetime(new Date('2023-05-01'), 'a', 'Europe/Paris'),
).toBeUndefined();
});
it('should return undefined when trying to convert a datetime to utc datetime without a valid timezone', () => {
expect(
TimeConverter.toUtcDatetime(
new Date('2023-12-01'),
'07:00',
'OlympusMons/Mars',
),
).toBeUndefined();
});
});
@@ -57,7 +57,7 @@ export class ConfigurationMessagerController {
name: 'propagateConfiguration', name: 'propagateConfiguration',
}) })
public async propagateConfigurationsHandler(message: string) { public async propagateConfigurationsHandler(message: string) {
const configurations: Array<Configuration> = JSON.parse(message); const configurations: Configuration[] = JSON.parse(message);
configurations.forEach(async (configuration) => { configurations.forEach(async (configuration) => {
if ( if (
configuration.domain == configuration.domain ==
@@ -10,7 +10,7 @@ import { PrismaService } from './prisma-service';
*/ */
@Injectable() @Injectable()
export abstract class PrismaRepository<T> implements IRepository<T> { export abstract class PrismaRepository<T> implements IRepository<T> {
protected _model: string; protected model: string;
constructor(protected readonly _prisma: PrismaService) {} constructor(protected readonly _prisma: PrismaService) {}
@@ -21,13 +21,13 @@ export abstract class PrismaRepository<T> implements IRepository<T> {
include?: any, include?: any,
): Promise<ICollection<T>> { ): Promise<ICollection<T>> {
const [data, total] = await this._prisma.$transaction([ const [data, total] = await this._prisma.$transaction([
this._prisma[this._model].findMany({ this._prisma[this.model].findMany({
where, where,
include, include,
skip: (page - 1) * perPage, skip: (page - 1) * perPage,
take: perPage, take: perPage,
}), }),
this._prisma[this._model].count({ this._prisma[this.model].count({
where, where,
}), }),
]); ]);
@@ -39,7 +39,7 @@ export abstract class PrismaRepository<T> implements IRepository<T> {
async findOneByUuid(uuid: string): Promise<T> { async findOneByUuid(uuid: string): Promise<T> {
try { try {
const entity = await this._prisma[this._model].findUnique({ const entity = await this._prisma[this.model].findUnique({
where: { uuid }, where: { uuid },
}); });
@@ -59,7 +59,7 @@ export abstract class PrismaRepository<T> implements IRepository<T> {
async findOne(where: any, include?: any): Promise<T> { async findOne(where: any, include?: any): Promise<T> {
try { try {
const entity = await this._prisma[this._model].findFirst({ const entity = await this._prisma[this.model].findFirst({
where: where, where: where,
include: include, include: include,
}); });
@@ -81,7 +81,7 @@ export abstract class PrismaRepository<T> implements IRepository<T> {
// TODO : Refactor for good clean architecture ? // TODO : Refactor for good clean architecture ?
async create(entity: Partial<T> | any, include?: any): Promise<T> { async create(entity: Partial<T> | any, include?: any): Promise<T> {
try { try {
const res = await this._prisma[this._model].create({ const res = await this._prisma[this.model].create({
data: entity, data: entity,
include: include, include: include,
}); });
@@ -102,7 +102,7 @@ export abstract class PrismaRepository<T> implements IRepository<T> {
async update(uuid: string, entity: Partial<T>): Promise<T> { async update(uuid: string, entity: Partial<T>): Promise<T> {
try { try {
const updatedEntity = await this._prisma[this._model].update({ const updatedEntity = await this._prisma[this.model].update({
where: { uuid }, where: { uuid },
data: entity, data: entity,
}); });
@@ -126,7 +126,7 @@ export abstract class PrismaRepository<T> implements IRepository<T> {
include?: any, include?: any,
): Promise<T> { ): Promise<T> {
try { try {
const updatedEntity = await this._prisma[this._model].update({ const updatedEntity = await this._prisma[this.model].update({
where: where, where: where,
data: entity, data: entity,
include: include, include: include,
@@ -148,7 +148,7 @@ export abstract class PrismaRepository<T> implements IRepository<T> {
async delete(uuid: string): Promise<T> { async delete(uuid: string): Promise<T> {
try { try {
const entity = await this._prisma[this._model].delete({ const entity = await this._prisma[this.model].delete({
where: { uuid }, where: { uuid },
}); });
@@ -168,7 +168,7 @@ export abstract class PrismaRepository<T> implements IRepository<T> {
async deleteMany(where: any): Promise<void> { async deleteMany(where: any): Promise<void> {
try { try {
const entity = await this._prisma[this._model].deleteMany({ const entity = await this._prisma[this.model].deleteMany({
where: where, where: where,
}); });
@@ -187,13 +187,13 @@ export abstract class PrismaRepository<T> implements IRepository<T> {
} }
async findAllByQuery( async findAllByQuery(
include: Array<string>, include: string[],
where: Array<string>, where: string[],
): Promise<ICollection<T>> { ): Promise<ICollection<T>> {
const query = `SELECT ${include.join(',')} FROM ${ const query = `SELECT ${include.join(',')} FROM ${
this._model this.model
} WHERE ${where.join(' AND ')}`; } WHERE ${where.join(' AND ')}`;
const data: Array<T> = await this._prisma.$queryRawUnsafe(query); const data: T[] = await this._prisma.$queryRawUnsafe(query);
return Promise.resolve({ return Promise.resolve({
data, data,
total: data.length, total: data.length,
@@ -202,9 +202,9 @@ export abstract class PrismaRepository<T> implements IRepository<T> {
async createWithFields(fields: object): Promise<number> { async createWithFields(fields: object): Promise<number> {
try { try {
const command = `INSERT INTO ${this._model} (${Object.keys(fields).join( const command = `INSERT INTO ${this.model} ("${Object.keys(fields).join(
',', '","',
)}) VALUES (${Object.values(fields).join(',')})`; )}") VALUES (${Object.values(fields).join(',')})`;
return await this._prisma.$executeRawUnsafe(command); return await this._prisma.$executeRawUnsafe(command);
} catch (e) { } catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError) { if (e instanceof Prisma.PrismaClientKnownRequestError) {
@@ -219,11 +219,11 @@ export abstract class PrismaRepository<T> implements IRepository<T> {
} }
} }
async updateWithFields(uuid: string, entity: Partial<T>): Promise<number> { async updateWithFields(uuid: string, entity: object): Promise<number> {
entity['"updatedAt"'] = `to_timestamp(${Date.now()} / 1000.0)`; entity['"updatedAt"'] = `to_timestamp(${Date.now()} / 1000.0)`;
const values = Object.keys(entity).map((key) => `${key} = ${entity[key]}`); const values = Object.keys(entity).map((key) => `${key} = ${entity[key]}`);
try { try {
const command = `UPDATE ${this._model} SET ${values.join( const command = `UPDATE ${this.model} SET ${values.join(
', ', ', ',
)} WHERE uuid = '${uuid}'`; )} WHERE uuid = '${uuid}'`;
return await this._prisma.$executeRawUnsafe(command); return await this._prisma.$executeRawUnsafe(command);
+4 -4
View File
@@ -1,9 +1,9 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { PrismaService } from './src/adapters/secondaries/prisma-service'; import { PrismaService } from './adapters/secondaries/prisma-service';
import { MatcherRepository } from './src/domain/matcher-repository'; import { AdRepository } from '../ad/adapters/secondaries/ad.repository';
@Module({ @Module({
providers: [PrismaService, MatcherRepository], providers: [PrismaService, AdRepository],
exports: [PrismaService, MatcherRepository], exports: [PrismaService, AdRepository],
}) })
export class DatabaseModule {} export class DatabaseModule {}
@@ -1,8 +1,8 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing'; import { Test, TestingModule } from '@nestjs/testing';
import { PrismaService } from '../../src/adapters/secondaries/prisma-service'; import { PrismaService } from '../../adapters/secondaries/prisma-service';
import { PrismaRepository } from '../../src/adapters/secondaries/prisma-repository.abstract'; import { PrismaRepository } from '../../adapters/secondaries/prisma-repository.abstract';
import { DatabaseException } from '../../src/exceptions/database.exception'; import { DatabaseException } from '../../exceptions/database.exception';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
class FakeEntity { class FakeEntity {
@@ -41,7 +41,7 @@ Array.from({ length: 10 }).forEach(() => {
@Injectable() @Injectable()
class FakePrismaRepository extends PrismaRepository<FakeEntity> { class FakePrismaRepository extends PrismaRepository<FakeEntity> {
protected _model = 'fake'; protected model = 'fake';
} }
class FakePrismaService extends PrismaService { class FakePrismaService extends PrismaService {
@@ -0,0 +1,8 @@
import { Injectable } from '@nestjs/common';
import { IFindTimezone } from '../../domain/interfaces/timezone-finder.interface';
import { find } from 'geo-tz';
@Injectable()
export class GeoTimezoneFinder implements IFindTimezone {
timezones = (lon: number, lat: number): string[] => find(lat, lon);
}
@@ -0,0 +1,27 @@
import { Injectable } from '@nestjs/common';
import { Geodesic as Geolib, GeodesicClass } from 'geographiclib-geodesic';
import { IGeodesic } from '../../domain/interfaces/geodesic.interface';
@Injectable()
export class Geodesic implements IGeodesic {
private geod: GeodesicClass;
constructor() {
this.geod = Geolib.WGS84;
}
inverse = (
lon1: number,
lat1: number,
lon2: number,
lat2: number,
): { azimuth: number; distance: number } => {
const { azi2: azimuth, s12: distance } = this.geod.Inverse(
lat1,
lon1,
lat2,
lon2,
);
return { azimuth, distance };
};
}
@@ -0,0 +1,28 @@
import { Injectable } from '@nestjs/common';
import { ICreateGeorouter } from '../../domain/interfaces/georouter-creator.interface';
import { IGeorouter } from '../../domain/interfaces/georouter.interface';
import { GraphhopperGeorouter } from './graphhopper-georouter';
import { HttpService } from '@nestjs/axios';
import { Geodesic } from './geodesic';
import { GeographyException } from '../../exceptions/geography.exception';
import { ExceptionCode } from '../../..//utils/exception-code.enum';
@Injectable()
export class GeorouterCreator implements ICreateGeorouter {
constructor(
private readonly httpService: HttpService,
private readonly geodesic: Geodesic,
) {}
create = (type: string, url: string): IGeorouter => {
switch (type) {
case 'graphhopper':
return new GraphhopperGeorouter(url, this.httpService, this.geodesic);
default:
throw new GeographyException(
ExceptionCode.INVALID_ARGUMENT,
'Unknown geocoder',
);
}
};
}
@@ -0,0 +1,330 @@
import { HttpService } from '@nestjs/axios';
import { IGeorouter } from '../../domain/interfaces/georouter.interface';
import { Injectable } from '@nestjs/common';
import { catchError, lastValueFrom, map } from 'rxjs';
import { AxiosError, AxiosResponse } from 'axios';
import { IGeodesic } from '../../../geography/domain/interfaces/geodesic.interface';
import { GeorouterSettings } from '../../domain/types/georouter-settings.type';
import { Path } from '../../domain/types/path.type';
import { NamedRoute } from '../../domain/types/named-route';
import { GeographyException } from '../../exceptions/geography.exception';
import { ExceptionCode } from '../../..//utils/exception-code.enum';
import { Route } from '../../domain/entities/route';
import { SpacetimePoint } from '../../domain/entities/spacetime-point';
@Injectable()
export class GraphhopperGeorouter implements IGeorouter {
private url: string;
private urlArgs: string[];
private withTime: boolean;
private withPoints: boolean;
private withDistance: boolean;
private paths: Path[];
private httpService: HttpService;
private geodesic: IGeodesic;
constructor(url: string, httpService: HttpService, geodesic: IGeodesic) {
this.url = url + '/route?';
this.httpService = httpService;
this.geodesic = geodesic;
}
route = async (
paths: Path[],
settings: GeorouterSettings,
): Promise<NamedRoute[]> => {
this.setDefaultUrlArgs();
this.setWithTime(settings.withTime);
this.setWithPoints(settings.withPoints);
this.setWithDistance(settings.withDistance);
this.paths = paths;
return await this.getRoutes();
};
private setDefaultUrlArgs = (): void => {
this.urlArgs = ['vehicle=car', 'weighting=fastest', 'points_encoded=false'];
};
private setWithTime = (withTime: boolean): void => {
this.withTime = withTime;
if (withTime) {
this.urlArgs.push('details=time');
}
};
private setWithPoints = (withPoints: boolean): void => {
this.withPoints = withPoints;
if (!withPoints) {
this.urlArgs.push('calc_points=false');
}
};
private setWithDistance = (withDistance: boolean): void => {
this.withDistance = withDistance;
if (withDistance) {
this.urlArgs.push('instructions=true');
} else {
this.urlArgs.push('instructions=false');
}
};
private getRoutes = async (): Promise<NamedRoute[]> => {
const routes = Promise.all(
this.paths.map(async (path) => {
const url: string = [
this.getUrl(),
'&point=',
path.points
.map((point) => [point.lat, point.lon].join('%2C'))
.join('&point='),
].join('');
const route = await lastValueFrom(
this.httpService.get(url).pipe(
map((res) => (res.data ? this.createRoute(res) : undefined)),
catchError((error: AxiosError) => {
if (error.code == AxiosError.ERR_BAD_REQUEST) {
throw new GeographyException(
ExceptionCode.OUT_OF_RANGE,
'No route found for given coordinates',
);
}
throw new GeographyException(
ExceptionCode.UNAVAILABLE,
'Georouter unavailable : ' + error.message,
);
}),
),
);
return <NamedRoute>{
key: path.key,
route,
};
}),
);
return routes;
};
private getUrl = (): string => {
return [this.url, this.urlArgs.join('&')].join('');
};
private createRoute = (
response: AxiosResponse<GraphhopperResponse>,
): Route => {
const route = new Route(this.geodesic);
if (response.data.paths && response.data.paths[0]) {
const shortestPath = response.data.paths[0];
route.distance = shortestPath.distance ?? 0;
route.duration = shortestPath.time ? shortestPath.time / 1000 : 0;
if (shortestPath.points && shortestPath.points.coordinates) {
route.setPoints(
shortestPath.points.coordinates.map((coordinate) => ({
lon: coordinate[0],
lat: coordinate[1],
})),
);
if (
shortestPath.details &&
shortestPath.details.time &&
shortestPath.snapped_waypoints &&
shortestPath.snapped_waypoints.coordinates
) {
let instructions: GraphhopperInstruction[] = [];
if (shortestPath.instructions)
instructions = shortestPath.instructions;
route.setSpacetimePoints(
this.generateSpacetimePoints(
shortestPath.points.coordinates,
shortestPath.snapped_waypoints.coordinates,
shortestPath.details.time,
instructions,
),
);
}
}
}
return route;
};
private generateSpacetimePoints = (
points: Array<number[]>,
snappedWaypoints: Array<number[]>,
durations: Array<number[]>,
instructions: GraphhopperInstruction[],
): SpacetimePoint[] => {
const indices = this.getIndices(points, snappedWaypoints);
const times = this.getTimes(durations, indices);
const distances = this.getDistances(instructions, indices);
return indices.map(
(index) =>
new SpacetimePoint(
{ lon: points[index][1], lat: points[index][0] },
times.find((time) => time.index == index)?.duration,
distances.find((distance) => distance.index == index)?.distance,
),
);
};
private getIndices = (
points: Array<number[]>,
snappedWaypoints: Array<number[]>,
): number[] => {
const indices = snappedWaypoints.map((waypoint) =>
points.findIndex(
(point) => point[0] == waypoint[0] && point[1] == waypoint[1],
),
);
if (indices.find((index) => index == -1) === undefined) return indices;
const missedWaypoints = indices
.map(
(value, index) =>
<
{
index: number;
originIndex: number;
waypoint: number[];
nearest: number;
distance: number;
}
>{
index: value,
originIndex: index,
waypoint: snappedWaypoints[index],
nearest: undefined,
distance: 999999999,
},
)
.filter((element) => element.index == -1);
for (const index in points) {
for (const missedWaypoint of missedWaypoints) {
const inverse = this.geodesic.inverse(
missedWaypoint.waypoint[0],
missedWaypoint.waypoint[1],
points[index][0],
points[index][1],
);
if (inverse.distance < missedWaypoint.distance) {
missedWaypoint.distance = inverse.distance;
missedWaypoint.nearest = parseInt(index);
}
}
}
for (const missedWaypoint of missedWaypoints) {
indices[missedWaypoint.originIndex] = missedWaypoint.nearest;
}
return indices;
};
private getTimes = (
durations: Array<number[]>,
indices: number[],
): Array<{ index: number; duration: number }> => {
const times: Array<{ index: number; duration: number }> = [];
let duration = 0;
for (const [origin, destination, stepDuration] of durations) {
let indexFound = false;
const indexAsOrigin = indices.find((index) => index == origin);
if (
indexAsOrigin !== undefined &&
times.find((time) => origin == time.index) == undefined
) {
times.push({
index: indexAsOrigin,
duration: Math.round(stepDuration / 1000),
});
indexFound = true;
}
if (!indexFound) {
const indexAsDestination = indices.find(
(index) => index == destination,
);
if (
indexAsDestination !== undefined &&
times.find((time) => destination == time.index) == undefined
) {
times.push({
index: indexAsDestination,
duration: Math.round((duration + stepDuration) / 1000),
});
indexFound = true;
}
}
if (!indexFound) {
const indexInBetween = indices.find(
(index) => origin < index && index < destination,
);
if (indexInBetween !== undefined) {
times.push({
index: indexInBetween,
duration: Math.round((duration + stepDuration / 2) / 1000),
});
}
}
duration += stepDuration;
}
return times;
};
private getDistances = (
instructions: GraphhopperInstruction[],
indices: number[],
): Array<{ index: number; distance: number }> => {
let distance = 0;
const distances: Array<{ index: number; distance: number }> = [
{
index: 0,
distance,
},
];
for (const instruction of instructions) {
distance += instruction.distance;
if (
(instruction.sign == GraphhopperSign.SIGN_WAYPOINT ||
instruction.sign == GraphhopperSign.SIGN_FINISH) &&
indices.find((index) => index == instruction.interval[0]) !== undefined
) {
distances.push({
index: instruction.interval[0],
distance: Math.round(distance),
});
}
}
return distances;
};
}
type GraphhopperResponse = {
paths: [
{
distance: number;
weight: number;
time: number;
points_encoded: boolean;
bbox: number[];
points: GraphhopperCoordinates;
snapped_waypoints: GraphhopperCoordinates;
details: {
time: Array<number[]>;
};
instructions: GraphhopperInstruction[];
},
];
};
type GraphhopperCoordinates = {
coordinates: Array<number[]>;
};
type GraphhopperInstruction = {
distance: number;
heading: number;
sign: GraphhopperSign;
interval: number[];
text: string;
};
enum GraphhopperSign {
SIGN_START = 0,
SIGN_FINISH = 4,
SIGN_WAYPOINT = 5,
}
@@ -0,0 +1,11 @@
import { Coordinate } from '../../domain/entities/coordinate';
import { IEncodeDirection } from '../../domain/interfaces/direction-encoder.interface';
export class PostgresDirectionEncoder implements IEncodeDirection {
encode = (coordinates: Coordinate[]): string =>
[
"'LINESTRING(",
coordinates.map((point) => [point.lon, point.lat].join(' ')).join(),
")'",
].join('');
}
@@ -0,0 +1,19 @@
import { AutoMap } from '@automapper/classes';
import { IsLatitude, IsLongitude, IsNumber } from 'class-validator';
export class Coordinate {
constructor(lon: number, lat: number) {
this.lon = lon;
this.lat = lat;
}
@IsNumber()
@IsLongitude()
@AutoMap()
lon: number;
@IsNumber()
@IsLatitude()
@AutoMap()
lat: number;
}
@@ -1,7 +1,6 @@
import { IGeodesic } from '../../interfaces/geodesic.interface'; import { IGeodesic } from '../interfaces/geodesic.interface';
import { Point } from '../../types/point.type'; import { Point } from '../types/point.type';
import { SpacetimePoint } from './spacetime-point'; import { SpacetimePoint } from './spacetime-point';
import { Waypoint } from './waypoint';
export class Route { export class Route {
distance: number; distance: number;
@@ -9,9 +8,8 @@ export class Route {
fwdAzimuth: number; fwdAzimuth: number;
backAzimuth: number; backAzimuth: number;
distanceAzimuth: number; distanceAzimuth: number;
waypoints: Array<Waypoint>; points: Point[];
points: Array<Point>; spacetimePoints: SpacetimePoint[];
spacetimePoints: Array<SpacetimePoint>;
private geodesic: IGeodesic; private geodesic: IGeodesic;
constructor(geodesic: IGeodesic) { constructor(geodesic: IGeodesic) {
@@ -20,27 +18,21 @@ export class Route {
this.fwdAzimuth = undefined; this.fwdAzimuth = undefined;
this.backAzimuth = undefined; this.backAzimuth = undefined;
this.distanceAzimuth = undefined; this.distanceAzimuth = undefined;
this.waypoints = [];
this.points = []; this.points = [];
this.spacetimePoints = []; this.spacetimePoints = [];
this.geodesic = geodesic; this.geodesic = geodesic;
} }
setWaypoints = (waypoints: Array<Waypoint>): void => { setPoints = (points: Point[]): void => {
this.waypoints = waypoints;
this.setAzimuth(waypoints.map((waypoint) => waypoint.point));
};
setPoints = (points: Array<Point>): void => {
this.points = points; this.points = points;
this.setAzimuth(points); this.setAzimuth(points);
}; };
setSpacetimePoints = (spacetimePoints: Array<SpacetimePoint>): void => { setSpacetimePoints = (spacetimePoints: SpacetimePoint[]): void => {
this.spacetimePoints = spacetimePoints; this.spacetimePoints = spacetimePoints;
}; };
private setAzimuth = (points: Array<Point>): void => { protected setAzimuth = (points: Point[]): void => {
const inverse = this.geodesic.inverse( const inverse = this.geodesic.inverse(
points[0].lon, points[0].lon,
points[0].lat, points[0].lat,
@@ -0,0 +1,13 @@
import { Coordinate } from './coordinate';
export class SpacetimePoint {
coordinate: Coordinate;
duration: number;
distance: number;
constructor(coordinate: Coordinate, duration: number, distance: number) {
this.coordinate = coordinate;
this.duration = duration;
this.distance = distance;
}
}
@@ -0,0 +1,5 @@
import { Coordinate } from '../entities/coordinate';
export interface IEncodeDirection {
encode(coordinates: Coordinate[]): string;
}
@@ -0,0 +1,5 @@
import { IGeorouter } from './georouter.interface';
export interface ICreateGeorouter {
create(type: string, url: string): IGeorouter;
}
@@ -0,0 +1,7 @@
import { GeorouterSettings } from '../types/georouter-settings.type';
import { NamedRoute } from '../types/named-route';
import { Path } from '../types/path.type';
export interface IGeorouter {
route(paths: Path[], settings: GeorouterSettings): Promise<NamedRoute[]>;
}
@@ -0,0 +1,3 @@
export interface IFindTimezone {
timezones(lon: number, lat: number): string[];
}
@@ -0,0 +1,5 @@
export type GeorouterSettings = {
withPoints: boolean;
withTime: boolean;
withDistance: boolean;
};
@@ -0,0 +1,6 @@
import { Route } from '../entities/route';
export type NamedRoute = {
key: string;
route: Route;
};
@@ -0,0 +1,6 @@
import { Point } from '../../../geography/domain/types/point.type';
export type Path = {
key: string;
points: Point[];
};
@@ -0,0 +1,6 @@
import { PointType } from './point-type.enum';
import { Coordinate } from '../entities/coordinate';
export type Point = Coordinate & {
type?: PointType;
};
@@ -0,0 +1,6 @@
import { IFindTimezone } from '../interfaces/timezone-finder.interface';
export type Timezoner = {
timezone: string;
finder: IFindTimezone;
};
@@ -0,0 +1,11 @@
export class GeographyException implements Error {
name: string;
code: number;
message: string;
constructor(code: number, message: string) {
this.name = 'GeographyException';
this.code = code;
this.message = message;
}
}
@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { GeoTimezoneFinder } from './adapters/secondaries/geo-timezone-finder';
import { Geodesic } from './adapters/secondaries/geodesic';
@Module({
providers: [GeoTimezoneFinder, Geodesic],
exports: [GeoTimezoneFinder, Geodesic],
})
export class GeographyModule {}
@@ -0,0 +1,8 @@
import { Coordinate } from '../../domain/entities/coordinate';
describe('Coordinate entity', () => {
it('should be defined', () => {
const coordinate: Coordinate = new Coordinate(6, 47);
expect(coordinate).toBeDefined();
});
});
@@ -0,0 +1,14 @@
import { GeoTimezoneFinder } from '../../adapters/secondaries/geo-timezone-finder';
describe('Geo TZ Finder', () => {
it('should be defined', () => {
const timezoneFinder: GeoTimezoneFinder = new GeoTimezoneFinder();
expect(timezoneFinder).toBeDefined();
});
it('should get timezone for Nancy(France) as Europe/Paris', () => {
const timezoneFinder: GeoTimezoneFinder = new GeoTimezoneFinder();
const timezones = timezoneFinder.timezones(6.179373, 48.687913);
expect(timezones.length).toBe(1);
expect(timezones[0]).toBe('Europe/Paris');
});
});
@@ -0,0 +1,14 @@
import { Geodesic } from '../../adapters/secondaries/geodesic';
describe('Matcher geodesic', () => {
it('should be defined', () => {
const geodesic: Geodesic = new Geodesic();
expect(geodesic).toBeDefined();
});
it('should get inverse values', () => {
const geodesic: Geodesic = new Geodesic();
const inv = geodesic.inverse(0, 0, 1, 1);
expect(Math.round(inv.azimuth)).toBe(45);
expect(Math.round(inv.distance)).toBe(156900);
});
});
@@ -0,0 +1,47 @@
import { Test, TestingModule } from '@nestjs/testing';
import { HttpService } from '@nestjs/axios';
import { GeorouterCreator } from '../../adapters/secondaries/georouter-creator';
import { Geodesic } from '../../adapters/secondaries/geodesic';
import { GraphhopperGeorouter } from '../../adapters/secondaries/graphhopper-georouter';
const mockHttpService = jest.fn();
const mockGeodesic = jest.fn();
describe('Georouter creator', () => {
let georouterCreator: GeorouterCreator;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
imports: [],
providers: [
GeorouterCreator,
{
provide: HttpService,
useValue: mockHttpService,
},
{
provide: Geodesic,
useValue: mockGeodesic,
},
],
}).compile();
georouterCreator = module.get<GeorouterCreator>(GeorouterCreator);
});
it('should be defined', () => {
expect(georouterCreator).toBeDefined();
});
it('should create a graphhopper georouter', () => {
const georouter = georouterCreator.create(
'graphhopper',
'http://localhost',
);
expect(georouter).toBeInstanceOf(GraphhopperGeorouter);
});
it('should throw an exception if georouter type is unknown', () => {
expect(() =>
georouterCreator.create('unknown', 'http://localhost'),
).toThrow();
});
});
@@ -0,0 +1,456 @@
import { Test, TestingModule } from '@nestjs/testing';
import { HttpService } from '@nestjs/axios';
import { of } from 'rxjs';
import { AxiosError } from 'axios';
import { GeorouterCreator } from '../../adapters/secondaries/georouter-creator';
import { IGeorouter } from '../../domain/interfaces/georouter.interface';
import { Geodesic } from '../../adapters/secondaries/geodesic';
const mockHttpService = {
get: jest
.fn()
.mockImplementationOnce(() => {
throw new AxiosError('Axios error !');
})
.mockImplementationOnce(() => {
return of({
status: 200,
data: {
paths: [
{
distance: 50000,
time: 1800000,
snapped_waypoints: {
coordinates: [
[0, 0],
[10, 10],
],
},
},
],
},
});
})
.mockImplementationOnce(() => {
return of({
status: 200,
data: {
paths: [
{
distance: 50000,
time: 1800000,
points: {
coordinates: [
[0, 0],
[1, 1],
[2, 2],
[3, 3],
[4, 4],
[5, 5],
[6, 6],
[7, 7],
[8, 8],
[9, 9],
[10, 10],
],
},
snapped_waypoints: {
coordinates: [
[0, 0],
[10, 10],
],
},
},
],
},
});
})
.mockImplementationOnce(() => {
return of({
status: 200,
data: {
paths: [
{
distance: 50000,
time: 1800000,
points: {
coordinates: [
[0, 0],
[1, 1],
[2, 2],
[3, 3],
[4, 4],
[5, 5],
[6, 6],
[7, 7],
[8, 8],
[9, 9],
[10, 10],
],
},
details: {
time: [
[0, 1, 180000],
[1, 2, 180000],
[2, 3, 180000],
[3, 4, 180000],
[4, 5, 180000],
[5, 6, 180000],
[6, 7, 180000],
[7, 9, 360000],
[9, 10, 180000],
],
},
snapped_waypoints: {
coordinates: [
[0, 0],
[10, 10],
],
},
},
],
},
});
})
.mockImplementationOnce(() => {
return of({
status: 200,
data: {
paths: [
{
distance: 50000,
time: 1800000,
points: {
coordinates: [
[0, 0],
[1, 1],
[2, 2],
[3, 3],
[4, 4],
[7, 7],
[8, 8],
[9, 9],
[10, 10],
],
},
snapped_waypoints: {
coordinates: [
[0, 0],
[5, 5],
[10, 10],
],
},
details: {
time: [
[0, 1, 180000],
[1, 2, 180000],
[2, 3, 180000],
[3, 4, 180000],
[4, 7, 540000],
[7, 9, 360000],
[9, 10, 180000],
],
},
},
],
},
});
})
.mockImplementationOnce(() => {
return of({
status: 200,
data: {
paths: [
{
distance: 50000,
time: 1800000,
points: {
coordinates: [
[0, 0],
[1, 1],
[2, 2],
[3, 3],
[4, 4],
[5, 5],
[6, 6],
[7, 7],
[8, 8],
[9, 9],
[10, 10],
],
},
snapped_waypoints: {
coordinates: [
[0, 0],
[5, 5],
[10, 10],
],
},
details: {
time: [
[0, 1, 180000],
[1, 2, 180000],
[2, 3, 180000],
[3, 4, 180000],
[4, 7, 540000],
[7, 9, 360000],
[9, 10, 180000],
],
},
instructions: [
{
distance: 25000,
sign: 0,
interval: [0, 5],
text: 'Some instructions',
time: 900000,
},
{
distance: 0,
sign: 5,
interval: [5, 5],
text: 'Waypoint 1',
time: 0,
},
{
distance: 25000,
sign: 2,
interval: [5, 10],
text: 'Some instructions',
time: 900000,
},
{
distance: 0.0,
sign: 4,
interval: [10, 10],
text: 'Arrive at destination',
time: 0,
},
],
},
],
},
});
}),
};
const mockGeodesic = {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
inverse: jest.fn().mockImplementation(() => ({
azimuth: 45,
distance: 50000,
})),
};
describe('Graphhopper Georouter', () => {
let georouterCreator: GeorouterCreator;
let graphhopperGeorouter: IGeorouter;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
imports: [],
providers: [
GeorouterCreator,
{
provide: HttpService,
useValue: mockHttpService,
},
{
provide: Geodesic,
useValue: mockGeodesic,
},
],
}).compile();
georouterCreator = module.get<GeorouterCreator>(GeorouterCreator);
graphhopperGeorouter = georouterCreator.create(
'graphhopper',
'http://localhost',
);
});
it('should be defined', () => {
expect(graphhopperGeorouter).toBeDefined();
});
describe('route function', () => {
it('should fail on axios error', async () => {
await expect(
graphhopperGeorouter.route(
[
{
key: 'route1',
points: [
{
lat: 0,
lon: 0,
},
{
lat: 1,
lon: 1,
},
],
},
],
{
withDistance: false,
withPoints: false,
withTime: false,
},
),
).rejects.toBeInstanceOf(Error);
});
it('should create one route with all settings to false', async () => {
const routes = await graphhopperGeorouter.route(
[
{
key: 'route1',
points: [
{
lat: 0,
lon: 0,
},
{
lat: 10,
lon: 10,
},
],
},
],
{
withDistance: false,
withPoints: false,
withTime: false,
},
);
expect(routes).toHaveLength(1);
expect(routes[0].route.distance).toBe(50000);
});
it('should create one route with points', async () => {
const routes = await graphhopperGeorouter.route(
[
{
key: 'route1',
points: [
{
lat: 0,
lon: 0,
},
{
lat: 10,
lon: 10,
},
],
},
],
{
withDistance: false,
withPoints: true,
withTime: false,
},
);
expect(routes).toHaveLength(1);
expect(routes[0].route.distance).toBe(50000);
expect(routes[0].route.duration).toBe(1800);
expect(routes[0].route.fwdAzimuth).toBe(45);
expect(routes[0].route.backAzimuth).toBe(225);
expect(routes[0].route.points.length).toBe(11);
});
it('should create one route with points and time', async () => {
const routes = await graphhopperGeorouter.route(
[
{
key: 'route1',
points: [
{
lat: 0,
lon: 0,
},
{
lat: 10,
lon: 10,
},
],
},
],
{
withDistance: false,
withPoints: true,
withTime: true,
},
);
expect(routes).toHaveLength(1);
expect(routes[0].route.spacetimePoints.length).toBe(2);
expect(routes[0].route.spacetimePoints[1].duration).toBe(1800);
expect(routes[0].route.spacetimePoints[1].distance).toBeUndefined();
});
it('should create one route with points and missed waypoints extrapolations', async () => {
const routes = await graphhopperGeorouter.route(
[
{
key: 'route1',
points: [
{
lat: 0,
lon: 0,
},
{
lat: 5,
lon: 5,
},
{
lat: 10,
lon: 10,
},
],
},
],
{
withDistance: false,
withPoints: true,
withTime: true,
},
);
expect(routes).toHaveLength(1);
expect(routes[0].route.spacetimePoints.length).toBe(3);
expect(routes[0].route.distance).toBe(50000);
expect(routes[0].route.duration).toBe(1800);
expect(routes[0].route.fwdAzimuth).toBe(45);
expect(routes[0].route.backAzimuth).toBe(225);
expect(routes[0].route.points.length).toBe(9);
});
it('should create one route with points, time and distance', async () => {
const routes = await graphhopperGeorouter.route(
[
{
key: 'route1',
points: [
{
lat: 0,
lon: 0,
},
{
lat: 10,
lon: 10,
},
],
},
],
{
withDistance: true,
withPoints: true,
withTime: true,
},
);
expect(routes).toHaveLength(1);
expect(routes[0].route.spacetimePoints.length).toBe(3);
expect(routes[0].route.spacetimePoints[1].duration).toBe(990);
expect(routes[0].route.spacetimePoints[1].distance).toBe(25000);
});
});
});
@@ -0,0 +1,30 @@
import { PostgresDirectionEncoder } from '../../adapters/secondaries/postgres-direction-encoder';
import { Coordinate } from '../../domain/entities/coordinate';
describe('Postgres direction encoder', () => {
it('should be defined', () => {
const postgresDirectionEncoder: PostgresDirectionEncoder =
new PostgresDirectionEncoder();
expect(postgresDirectionEncoder).toBeDefined();
});
it('should encode coordinates to a postgres direction', () => {
const postgresDirectionEncoder: PostgresDirectionEncoder =
new PostgresDirectionEncoder();
const coordinates: Coordinate[] = [
{
lon: 6,
lat: 47,
},
{
lon: 6.1,
lat: 47.1,
},
{
lon: 6.2,
lat: 47.2,
},
];
const direction = postgresDirectionEncoder.encode(coordinates);
expect(direction).toBe("'LINESTRING(6 47,6.1 47.1,6.2 47.2)'");
});
});
@@ -0,0 +1,48 @@
import { Route } from '../../domain/entities/route';
import { SpacetimePoint } from '../../domain/entities/spacetime-point';
const mockGeodesic = {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
inverse: jest.fn().mockImplementation((lon1, lat1, lon2, lat2) => {
return lon1 == 0
? {
azimuth: 45,
distance: 50000,
}
: {
azimuth: -45,
distance: 60000,
};
}),
};
describe('Route entity', () => {
it('should be defined', () => {
const route = new Route(mockGeodesic);
expect(route).toBeDefined();
});
it('should set points and geodesic values for a route', () => {
const route = new Route(mockGeodesic);
route.setPoints([
{
lon: 10,
lat: 10,
},
{
lon: 20,
lat: 20,
},
]);
expect(route.points.length).toBe(2);
expect(route.fwdAzimuth).toBe(315);
expect(route.backAzimuth).toBe(135);
expect(route.distanceAzimuth).toBe(60000);
});
it('should set spacetimePoints for a route', () => {
const route = new Route(mockGeodesic);
const spacetimePoint1 = new SpacetimePoint({ lon: 0, lat: 0 }, 0, 0);
const spacetimePoint2 = new SpacetimePoint({ lon: 10, lat: 10 }, 500, 5000);
route.setSpacetimePoints([spacetimePoint1, spacetimePoint2]);
expect(route.spacetimePoints.length).toBe(2);
});
});
@@ -4,7 +4,7 @@ import {
HealthIndicator, HealthIndicator,
HealthIndicatorResult, HealthIndicatorResult,
} from '@nestjs/terminus'; } from '@nestjs/terminus';
import { AdRepository } from '../../../matcher/adapters/secondaries/ad.repository'; import { AdRepository } from '../../../ad/adapters/secondaries/ad.repository';
@Injectable() @Injectable()
export class PrismaHealthIndicatorUseCase extends HealthIndicator { export class PrismaHealthIndicatorUseCase extends HealthIndicator {
+1 -1
View File
@@ -7,7 +7,7 @@ import { TerminusModule } from '@nestjs/terminus';
import { RabbitMQModule } from '@golevelup/nestjs-rabbitmq'; import { RabbitMQModule } from '@golevelup/nestjs-rabbitmq';
import { ConfigModule, ConfigService } from '@nestjs/config'; import { ConfigModule, ConfigService } from '@nestjs/config';
import { Messager } from './adapters/secondaries/messager'; import { Messager } from './adapters/secondaries/messager';
import { AdRepository } from '../matcher/adapters/secondaries/ad.repository'; import { AdRepository } from '../ad/adapters/secondaries/ad.repository';
@Module({ @Module({
imports: [ imports: [
@@ -1,7 +1,7 @@
import { Test, TestingModule } from '@nestjs/testing'; import { Test, TestingModule } from '@nestjs/testing';
import { PrismaHealthIndicatorUseCase } from '../../domain/usecases/prisma.health-indicator.usecase'; import { PrismaHealthIndicatorUseCase } from '../../domain/usecases/prisma.health-indicator.usecase';
import { HealthCheckError, HealthIndicatorResult } from '@nestjs/terminus'; import { HealthCheckError, HealthIndicatorResult } from '@nestjs/terminus';
import { AdRepository } from '../../../matcher/adapters/secondaries/ad.repository'; import { AdRepository } from '../../../ad/adapters/secondaries/ad.repository';
import { PrismaClientKnownRequestError } from '@prisma/client/runtime/library'; import { PrismaClientKnownRequestError } from '@prisma/client/runtime/library';
const mockAdRepository = { const mockAdRepository = {
@@ -3,14 +3,16 @@ import { InjectMapper } from '@automapper/nestjs';
import { Controller, UsePipes } from '@nestjs/common'; import { Controller, UsePipes } from '@nestjs/common';
import { QueryBus } from '@nestjs/cqrs'; import { QueryBus } from '@nestjs/cqrs';
import { GrpcMethod, RpcException } from '@nestjs/microservices'; import { GrpcMethod, RpcException } from '@nestjs/microservices';
import { RpcValidationPipe } from 'src/modules/utils/pipes/rpc.validation-pipe'; import { RpcValidationPipe } from '../../../utils/pipes/rpc.validation-pipe';
import { MatchRequest } from '../../domain/dtos/match.request'; import { MatchRequest } from '../../domain/dtos/match.request';
import { ICollection } from 'src/modules/database/src/interfaces/collection.interface'; import { ICollection } from '../../../database/interfaces/collection.interface';
import { MatchQuery } from '../../queries/match.query'; import { MatchQuery } from '../../queries/match.query';
import { MatchPresenter } from '../secondaries/match.presenter'; import { MatchPresenter } from '../secondaries/match.presenter';
import { DefaultParamsProvider } from '../secondaries/default-params.provider'; import { DefaultParamsProvider } from '../secondaries/default-params.provider';
import { GeorouterCreator } from '../secondaries/georouter-creator'; import { GeorouterCreator } from '../secondaries/georouter-creator';
import { Match } from '../../domain/entities/ecosystem/match'; import { Match } from '../../domain/entities/ecosystem/match';
import { GeoTimezoneFinder } from '../../../geography/adapters/secondaries/geo-timezone-finder';
import { TimeConverter } from '../secondaries/time-converter';
@UsePipes( @UsePipes(
new RpcValidationPipe({ new RpcValidationPipe({
@@ -21,25 +23,29 @@ import { Match } from '../../domain/entities/ecosystem/match';
@Controller() @Controller()
export class MatcherController { export class MatcherController {
constructor( constructor(
private readonly _queryBus: QueryBus, private readonly queryBus: QueryBus,
private readonly _defaultParamsProvider: DefaultParamsProvider, private readonly defaultParamsProvider: DefaultParamsProvider,
@InjectMapper() private readonly _mapper: Mapper, @InjectMapper() private readonly mapper: Mapper,
private readonly _georouterCreator: GeorouterCreator, private readonly georouterCreator: GeorouterCreator,
private readonly timezoneFinder: GeoTimezoneFinder,
private readonly timeConverter: TimeConverter,
) {} ) {}
@GrpcMethod('MatcherService', 'Match') @GrpcMethod('MatcherService', 'Match')
async match(data: MatchRequest): Promise<ICollection<Match>> { async match(data: MatchRequest): Promise<ICollection<Match>> {
try { try {
const matchCollection = await this._queryBus.execute( const matchCollection = await this.queryBus.execute(
new MatchQuery( new MatchQuery(
data, data,
this._defaultParamsProvider.getParams(), this.defaultParamsProvider.getParams(),
this._georouterCreator, this.georouterCreator,
this.timezoneFinder,
this.timeConverter,
), ),
); );
return Promise.resolve({ return Promise.resolve({
data: matchCollection.data.map((match: Match) => data: matchCollection.data.map((match: Match) =>
this._mapper.map(match, Match, MatchPresenter), this.mapper.map(match, Match, MatchPresenter),
), ),
total: matchCollection.total, total: matchCollection.total,
}); });
@@ -7,31 +7,31 @@ service MatcherService {
} }
message MatchRequest { message MatchRequest {
repeated Point waypoints = 1; string uuid = 1;
string departure = 2; repeated Coordinates waypoints = 2;
string fromDate = 3; string departure = 3;
Schedule schedule = 4; string fromDate = 4;
bool driver = 5; Schedule schedule = 5;
bool passenger = 6; bool driver = 6;
string toDate = 7; bool passenger = 7;
int32 marginDuration = 8; string toDate = 8;
MarginDurations marginDurations = 9; int32 marginDuration = 9;
int32 seatsPassenger = 10; MarginDurations marginDurations = 10;
int32 seatsDriver = 11; int32 seatsPassenger = 11;
bool strict = 12; int32 seatsDriver = 12;
Algorithm algorithm = 13; bool strict = 13;
int32 remoteness = 14; Algorithm algorithm = 14;
bool useProportion = 15; int32 remoteness = 15;
int32 proportion = 16; bool useProportion = 16;
bool useAzimuth = 17; int32 proportion = 17;
int32 azimuthMargin = 18; bool useAzimuth = 18;
float maxDetourDistanceRatio = 19; int32 azimuthMargin = 19;
float maxDetourDurationRatio = 20; float maxDetourDistanceRatio = 20;
repeated int32 exclusions = 21; float maxDetourDurationRatio = 21;
int32 identifier = 22; repeated int32 exclusions = 22;
} }
message Point { message Coordinates {
float lon = 1; float lon = 1;
float lat = 2; float lat = 2;
} }
@@ -1,8 +0,0 @@
import { Injectable } from '@nestjs/common';
import { MatcherRepository } from '../../../database/src/domain/matcher-repository';
import { Ad } from '../../domain/entities/ecosystem/ad';
@Injectable()
export class AdRepository extends MatcherRepository<Ad> {
protected _model = 'ad';
}
@@ -8,29 +8,27 @@ export class DefaultParamsProvider {
getParams = (): IDefaultParams => { getParams = (): IDefaultParams => {
return { return {
DEFAULT_IDENTIFIER: parseInt( DEFAULT_UUID: this.configService.get('DEFAULT_UUID'),
this.configService.get('DEFAULT_IDENTIFIER'),
),
MARGIN_DURATION: parseInt(this.configService.get('MARGIN_DURATION')), MARGIN_DURATION: parseInt(this.configService.get('MARGIN_DURATION')),
VALIDITY_DURATION: parseInt(this.configService.get('VALIDITY_DURATION')), VALIDITY_DURATION: parseInt(this.configService.get('VALIDITY_DURATION')),
DEFAULT_TIMEZONE: this.configService.get('DEFAULT_TIMEZONE'), DEFAULT_TIMEZONE: this.configService.get('DEFAULT_TIMEZONE'),
DEFAULT_SEATS: parseInt(this.configService.get('DEFAULT_SEATS')), DEFAULT_SEATS: parseInt(this.configService.get('DEFAULT_SEATS')),
DEFAULT_ALGORITHM_SETTINGS: { DEFAULT_ALGORITHM_SETTINGS: {
algorithm: this.configService.get('ALGORITHM'), ALGORITHM: this.configService.get('ALGORITHM'),
strict: !!parseInt(this.configService.get('STRICT_ALGORITHM')), STRICT: !!parseInt(this.configService.get('STRICT_ALGORITHM')),
remoteness: parseInt(this.configService.get('REMOTENESS')), REMOTENESS: parseInt(this.configService.get('REMOTENESS')),
useProportion: !!parseInt(this.configService.get('USE_PROPORTION')), USE_PROPORTION: !!parseInt(this.configService.get('USE_PROPORTION')),
proportion: parseInt(this.configService.get('PROPORTION')), PROPORTION: parseInt(this.configService.get('PROPORTION')),
useAzimuth: !!parseInt(this.configService.get('USE_AZIMUTH')), USE_AZIMUTH: !!parseInt(this.configService.get('USE_AZIMUTH')),
azimuthMargin: parseInt(this.configService.get('AZIMUTH_MARGIN')), AZIMUTH_MARGIN: parseInt(this.configService.get('AZIMUTH_MARGIN')),
maxDetourDistanceRatio: parseFloat( MAX_DETOUR_DISTANCE_RATIO: parseFloat(
this.configService.get('MAX_DETOUR_DISTANCE_RATIO'), this.configService.get('MAX_DETOUR_DISTANCE_RATIO'),
), ),
maxDetourDurationRatio: parseFloat( MAX_DETOUR_DURATION_RATIO: parseFloat(
this.configService.get('MAX_DETOUR_DURATION_RATIO'), this.configService.get('MAX_DETOUR_DURATION_RATIO'),
), ),
georouterType: this.configService.get('GEOROUTER_TYPE'), GEOROUTER_TYPE: this.configService.get('GEOROUTER_TYPE'),
georouterUrl: this.configService.get('GEOROUTER_URL'), GEOROUTER_URL: this.configService.get('GEOROUTER_URL'),
}, },
}; };
}; };
@@ -1,27 +1,16 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { IGeodesic } from '../../domain/interfaces/geodesic.interface'; import { Geodesic } from '../../../geography/adapters/secondaries/geodesic';
import { Geodesic, GeodesicClass } from 'geographiclib-geodesic'; import { IGeodesic } from '../../../geography/domain/interfaces/geodesic.interface';
@Injectable() @Injectable()
export class MatcherGeodesic implements IGeodesic { export class MatcherGeodesic implements IGeodesic {
private geod: GeodesicClass; constructor(private readonly geodesic: Geodesic) {}
constructor() {
this.geod = Geodesic.WGS84;
}
inverse = ( inverse = (
lon1: number, lon1: number,
lat1: number, lat1: number,
lon2: number, lon2: number,
lat2: number, lat2: number,
): { azimuth: number; distance: number } => { ): { azimuth: number; distance: number } =>
const { azi2: azimuth, s12: distance } = this.geod.Inverse( this.geodesic.inverse(lon1, lat1, lon2, lat2);
lat1,
lon1,
lat2,
lon2,
);
return { azimuth, distance };
};
} }
@@ -5,9 +5,9 @@ import { Path } from '../../domain/types/path.type';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { catchError, lastValueFrom, map } from 'rxjs'; import { catchError, lastValueFrom, map } from 'rxjs';
import { AxiosError, AxiosResponse } from 'axios'; import { AxiosError, AxiosResponse } from 'axios';
import { IGeodesic } from '../../domain/interfaces/geodesic.interface'; import { IGeodesic } from '../../../geography/domain/interfaces/geodesic.interface';
import { NamedRoute } from '../../domain/entities/ecosystem/named-route'; import { NamedRoute } from '../../domain/entities/ecosystem/named-route';
import { Route } from '../../domain/entities/ecosystem/route'; import { MatcherRoute } from '../../domain/entities/ecosystem/matcher-route';
import { SpacetimePoint } from '../../domain/entities/ecosystem/spacetime-point'; import { SpacetimePoint } from '../../domain/entities/ecosystem/spacetime-point';
import { import {
MatcherException, MatcherException,
@@ -17,11 +17,11 @@ import {
@Injectable() @Injectable()
export class GraphhopperGeorouter implements IGeorouter { export class GraphhopperGeorouter implements IGeorouter {
private url: string; private url: string;
private urlArgs: Array<string>; private urlArgs: string[];
private withTime: boolean; private withTime: boolean;
private withPoints: boolean; private withPoints: boolean;
private withDistance: boolean; private withDistance: boolean;
private paths: Array<Path>; private paths: Path[];
private httpService: HttpService; private httpService: HttpService;
private geodesic: IGeodesic; private geodesic: IGeodesic;
@@ -32,9 +32,9 @@ export class GraphhopperGeorouter implements IGeorouter {
} }
route = async ( route = async (
paths: Array<Path>, paths: Path[],
settings: GeorouterSettings, settings: GeorouterSettings,
): Promise<Array<NamedRoute>> => { ): Promise<NamedRoute[]> => {
this.setDefaultUrlArgs(); this.setDefaultUrlArgs();
this.setWithTime(settings.withTime); this.setWithTime(settings.withTime);
this.setWithPoints(settings.withPoints); this.setWithPoints(settings.withPoints);
@@ -70,7 +70,7 @@ export class GraphhopperGeorouter implements IGeorouter {
} }
}; };
private getRoutes = async (): Promise<Array<NamedRoute>> => { private getRoutes = async (): Promise<NamedRoute[]> => {
const routes = Promise.all( const routes = Promise.all(
this.paths.map(async (path) => { this.paths.map(async (path) => {
const url: string = [ const url: string = [
@@ -106,8 +106,8 @@ export class GraphhopperGeorouter implements IGeorouter {
private createRoute = ( private createRoute = (
response: AxiosResponse<GraphhopperResponse>, response: AxiosResponse<GraphhopperResponse>,
): Route => { ): MatcherRoute => {
const route = new Route(this.geodesic); const route = new MatcherRoute(this.geodesic);
if (response.data.paths && response.data.paths[0]) { if (response.data.paths && response.data.paths[0]) {
const shortestPath = response.data.paths[0]; const shortestPath = response.data.paths[0];
route.distance = shortestPath.distance ?? 0; route.distance = shortestPath.distance ?? 0;
@@ -125,7 +125,7 @@ export class GraphhopperGeorouter implements IGeorouter {
shortestPath.snapped_waypoints && shortestPath.snapped_waypoints &&
shortestPath.snapped_waypoints.coordinates shortestPath.snapped_waypoints.coordinates
) { ) {
let instructions: Array<GraphhopperInstruction> = []; let instructions: GraphhopperInstruction[] = [];
if (shortestPath.instructions) if (shortestPath.instructions)
instructions = shortestPath.instructions; instructions = shortestPath.instructions;
route.setSpacetimePoints( route.setSpacetimePoints(
@@ -143,18 +143,18 @@ export class GraphhopperGeorouter implements IGeorouter {
}; };
private generateSpacetimePoints = ( private generateSpacetimePoints = (
points: Array<Array<number>>, points: Array<number[]>,
snappedWaypoints: Array<Array<number>>, snappedWaypoints: Array<number[]>,
durations: Array<Array<number>>, durations: Array<number[]>,
instructions: Array<GraphhopperInstruction>, instructions: GraphhopperInstruction[],
): Array<SpacetimePoint> => { ): SpacetimePoint[] => {
const indices = this.getIndices(points, snappedWaypoints); const indices = this.getIndices(points, snappedWaypoints);
const times = this.getTimes(durations, indices); const times = this.getTimes(durations, indices);
const distances = this.getDistances(instructions, indices); const distances = this.getDistances(instructions, indices);
return indices.map( return indices.map(
(index) => (index) =>
new SpacetimePoint( new SpacetimePoint(
points[index], { lon: points[index][1], lat: points[index][0] },
times.find((time) => time.index == index)?.duration, times.find((time) => time.index == index)?.duration,
distances.find((distance) => distance.index == index)?.distance, distances.find((distance) => distance.index == index)?.distance,
), ),
@@ -162,9 +162,9 @@ export class GraphhopperGeorouter implements IGeorouter {
}; };
private getIndices = ( private getIndices = (
points: Array<Array<number>>, points: Array<number[]>,
snappedWaypoints: Array<Array<number>>, snappedWaypoints: Array<number[]>,
): Array<number> => { ): number[] => {
const indices = snappedWaypoints.map((waypoint) => const indices = snappedWaypoints.map((waypoint) =>
points.findIndex( points.findIndex(
(point) => point[0] == waypoint[0] && point[1] == waypoint[1], (point) => point[0] == waypoint[0] && point[1] == waypoint[1],
@@ -178,7 +178,7 @@ export class GraphhopperGeorouter implements IGeorouter {
{ {
index: number; index: number;
originIndex: number; originIndex: number;
waypoint: Array<number>; waypoint: number[];
nearest: number; nearest: number;
distance: number; distance: number;
} }
@@ -212,8 +212,8 @@ export class GraphhopperGeorouter implements IGeorouter {
}; };
private getTimes = ( private getTimes = (
durations: Array<Array<number>>, durations: Array<number[]>,
indices: Array<number>, indices: number[],
): Array<{ index: number; duration: number }> => { ): Array<{ index: number; duration: number }> => {
const times: Array<{ index: number; duration: number }> = []; const times: Array<{ index: number; duration: number }> = [];
let duration = 0; let duration = 0;
@@ -262,8 +262,8 @@ export class GraphhopperGeorouter implements IGeorouter {
}; };
private getDistances = ( private getDistances = (
instructions: Array<GraphhopperInstruction>, instructions: GraphhopperInstruction[],
indices: Array<number>, indices: number[],
): Array<{ index: number; distance: number }> => { ): Array<{ index: number; distance: number }> => {
let distance = 0; let distance = 0;
const distances: Array<{ index: number; distance: number }> = [ const distances: Array<{ index: number; distance: number }> = [
@@ -296,26 +296,26 @@ type GraphhopperResponse = {
weight: number; weight: number;
time: number; time: number;
points_encoded: boolean; points_encoded: boolean;
bbox: Array<number>; bbox: number[];
points: GraphhopperCoordinates; points: GraphhopperCoordinates;
snapped_waypoints: GraphhopperCoordinates; snapped_waypoints: GraphhopperCoordinates;
details: { details: {
time: Array<Array<number>>; time: Array<number[]>;
}; };
instructions: Array<GraphhopperInstruction>; instructions: GraphhopperInstruction[];
}, },
]; ];
}; };
type GraphhopperCoordinates = { type GraphhopperCoordinates = {
coordinates: Array<Array<number>>; coordinates: Array<number[]>;
}; };
type GraphhopperInstruction = { type GraphhopperInstruction = {
distance: number; distance: number;
heading: number; heading: number;
sign: GraphhopperSign; sign: GraphhopperSign;
interval: Array<number>; interval: number[];
text: string; text: string;
}; };
@@ -0,0 +1,21 @@
import { Injectable } from '@nestjs/common';
import { DateTime, TimeZone } from 'timezonecomplete';
import { IConvertTime } from '../../domain/interfaces/time-converter.interface';
@Injectable()
export class TimeConverter implements IConvertTime {
toUtcDate = (date: Date, timezone: string): Date => {
try {
return new Date(
new DateTime(
`${date.getFullYear()}-${date.getMonth()}-${date.getDate()}T${date.getHours()}:${date.getMinutes()}`,
TimeZone.zone(timezone, false),
)
.convert(TimeZone.zone('UTC'))
.toIsoString(),
);
} catch (e) {
return undefined;
}
};
}
@@ -0,0 +1,11 @@
import { Injectable } from '@nestjs/common';
import { GeoTimezoneFinder } from '../../../geography/adapters/secondaries/geo-timezone-finder';
import { IFindTimezone } from '../../../geography/domain/interfaces/timezone-finder.interface';
@Injectable()
export class TimezoneFinder implements IFindTimezone {
constructor(private readonly geoTimezoneFinder: GeoTimezoneFinder) {}
timezones = (lon: number, lat: number): string[] =>
this.geoTimezoneFinder.timezones(lon, lat);
}
@@ -10,27 +10,38 @@ import {
Min, Min,
} from 'class-validator'; } from 'class-validator';
import { AutoMap } from '@automapper/classes'; import { AutoMap } from '@automapper/classes';
import { Point } from '../types/point.type'; import { Point } from '../../../geography/domain/types/point.type';
import { Schedule } from '../types/schedule.type'; import { Schedule } from '../types/schedule.type';
import { MarginDurations } from '../types/margin-durations.type'; import { MarginDurations } from '../types/margin-durations.type';
import { AlgorithmType } from '../types/algorithm.enum'; import { AlgorithmType } from '../types/algorithm.enum';
import { IRequestTime } from '../interfaces/time-request.interface'; import { IRequestTime } from '../interfaces/time-request.interface';
import { IRequestPerson } from '../interfaces/person-request.interface'; import { IRequestAd } from '../interfaces/ad-request.interface';
import { IRequestGeography } from '../interfaces/geography-request.interface'; import { IRequestGeography } from '../interfaces/geography-request.interface';
import { IRequestRequirement } from '../interfaces/requirement-request.interface'; import { IRequestRequirement } from '../interfaces/requirement-request.interface';
import { IRequestAlgorithmSettings } from '../interfaces/algorithm-settings-request.interface'; import { IRequestAlgorithmSettings } from '../interfaces/algorithm-settings-request.interface';
import { Mode } from '../types/mode.enum';
export class MatchRequest export class MatchRequest
implements implements
IRequestTime, IRequestTime,
IRequestPerson, IRequestAd,
IRequestGeography, IRequestGeography,
IRequestRequirement, IRequestRequirement,
IRequestAlgorithmSettings IRequestAlgorithmSettings
{ {
@IsOptional()
@IsString()
@AutoMap()
uuid: string;
@IsOptional()
@IsEnum(Mode)
@AutoMap()
mode: Mode;
@IsArray() @IsArray()
@AutoMap() @AutoMap()
waypoints: Array<Point>; waypoints: Point[];
@IsOptional() @IsOptional()
@IsString() @IsString()
@@ -138,10 +149,7 @@ export class MatchRequest
@IsOptional() @IsOptional()
@IsArray() @IsArray()
exclusions: Array<number>; exclusions: string[];
@IsOptional() timezone?: string;
@IsInt()
@AutoMap()
identifier: number;
} }
@@ -1,14 +1,14 @@
import { Role } from '../../types/role.enum'; import { Role } from '../../types/role.enum';
import { Step } from '../../types/step.enum'; import { Step } from '../../types/step.enum';
import { Person } from './person'; import { Ad } from './ad';
export class Actor { export class Actor {
person: Person; ad: Ad;
role: Role; role: Role;
step: Step; step: Step;
constructor(person: Person, role: Role, step: Step) { constructor(ad: Ad, role: Role, step: Step) {
this.person = person; this.ad = ad;
this.role = role; this.role = role;
this.step = step; this.step = step;
} }
@@ -1,6 +1,40 @@
import { AutoMap } from '@automapper/classes'; import { IRequestAd } from '../../interfaces/ad-request.interface';
export class Ad { export class Ad {
@AutoMap() private adRequest: IRequestAd;
private defaultUuid: string;
private defaultMarginDuration: number;
uuid: string; uuid: string;
marginDurations: number[];
constructor(
adRequest: IRequestAd,
defaultUuid: string,
defaultMarginDuration: number,
) {
this.adRequest = adRequest;
this.defaultUuid = defaultUuid;
this.defaultMarginDuration = defaultMarginDuration;
}
init = (): void => {
this.setUuid(this.adRequest.uuid ?? this.defaultUuid);
this.setMarginDurations([
this.defaultMarginDuration,
this.defaultMarginDuration,
this.defaultMarginDuration,
this.defaultMarginDuration,
this.defaultMarginDuration,
this.defaultMarginDuration,
this.defaultMarginDuration,
]);
};
setUuid = (uuid: string): void => {
this.uuid = uuid;
};
setMarginDurations = (marginDurations: number[]): void => {
this.marginDurations = marginDurations;
};
} }
@@ -1,15 +1,15 @@
import { IRequestAlgorithmSettings } from '../../interfaces/algorithm-settings-request.interface'; import { IRequestAlgorithmSettings } from '../../interfaces/algorithm-settings-request.interface';
import { DefaultAlgorithmSettings } from '../../types/default-algorithm-settings.type'; import { DefaultAlgorithmSettings } from '../../types/default-algorithm-settings.type';
import { AlgorithmType } from '../../types/algorithm.enum'; import { AlgorithmType } from '../../types/algorithm.enum';
import { TimingFrequency } from '../../types/timing';
import { ICreateGeorouter } from '../../interfaces/georouter-creator.interface'; import { ICreateGeorouter } from '../../interfaces/georouter-creator.interface';
import { IGeorouter } from '../../interfaces/georouter.interface'; import { IGeorouter } from '../../interfaces/georouter.interface';
import { Frequency } from '../../../../ad/domain/types/frequency.enum';
export class AlgorithmSettings { export class AlgorithmSettings {
private algorithmSettingsRequest: IRequestAlgorithmSettings; private algorithmSettingsRequest: IRequestAlgorithmSettings;
private strict: boolean; private strict: boolean;
algorithmType: AlgorithmType; algorithmType: AlgorithmType;
restrict: TimingFrequency; restrict: Frequency;
remoteness: number; remoteness: number;
useProportion: boolean; useProportion: boolean;
proportion: number; proportion: number;
@@ -22,38 +22,38 @@ export class AlgorithmSettings {
constructor( constructor(
algorithmSettingsRequest: IRequestAlgorithmSettings, algorithmSettingsRequest: IRequestAlgorithmSettings,
defaultAlgorithmSettings: DefaultAlgorithmSettings, defaultAlgorithmSettings: DefaultAlgorithmSettings,
frequency: TimingFrequency, frequency: Frequency,
georouterCreator: ICreateGeorouter, georouterCreator: ICreateGeorouter,
) { ) {
this.algorithmSettingsRequest = algorithmSettingsRequest; this.algorithmSettingsRequest = algorithmSettingsRequest;
this.algorithmType = this.algorithmType =
algorithmSettingsRequest.algorithm ?? defaultAlgorithmSettings.algorithm; algorithmSettingsRequest.algorithm ?? defaultAlgorithmSettings.ALGORITHM;
this.strict = this.strict =
algorithmSettingsRequest.strict ?? defaultAlgorithmSettings.strict; algorithmSettingsRequest.strict ?? defaultAlgorithmSettings.STRICT;
this.remoteness = algorithmSettingsRequest.remoteness this.remoteness = algorithmSettingsRequest.remoteness
? Math.abs(algorithmSettingsRequest.remoteness) ? Math.abs(algorithmSettingsRequest.remoteness)
: defaultAlgorithmSettings.remoteness; : defaultAlgorithmSettings.REMOTENESS;
this.useProportion = this.useProportion =
algorithmSettingsRequest.useProportion ?? algorithmSettingsRequest.useProportion ??
defaultAlgorithmSettings.useProportion; defaultAlgorithmSettings.USE_PROPORTION;
this.proportion = algorithmSettingsRequest.proportion this.proportion = algorithmSettingsRequest.proportion
? Math.abs(algorithmSettingsRequest.proportion) ? Math.abs(algorithmSettingsRequest.proportion)
: defaultAlgorithmSettings.proportion; : defaultAlgorithmSettings.PROPORTION;
this.useAzimuth = this.useAzimuth =
algorithmSettingsRequest.useAzimuth ?? algorithmSettingsRequest.useAzimuth ??
defaultAlgorithmSettings.useAzimuth; defaultAlgorithmSettings.USE_AZIMUTH;
this.azimuthMargin = algorithmSettingsRequest.azimuthMargin this.azimuthMargin = algorithmSettingsRequest.azimuthMargin
? Math.abs(algorithmSettingsRequest.azimuthMargin) ? Math.abs(algorithmSettingsRequest.azimuthMargin)
: defaultAlgorithmSettings.azimuthMargin; : defaultAlgorithmSettings.AZIMUTH_MARGIN;
this.maxDetourDistanceRatio = this.maxDetourDistanceRatio =
algorithmSettingsRequest.maxDetourDistanceRatio ?? algorithmSettingsRequest.maxDetourDistanceRatio ??
defaultAlgorithmSettings.maxDetourDistanceRatio; defaultAlgorithmSettings.MAX_DETOUR_DISTANCE_RATIO;
this.maxDetourDurationRatio = this.maxDetourDurationRatio =
algorithmSettingsRequest.maxDetourDurationRatio ?? algorithmSettingsRequest.maxDetourDurationRatio ??
defaultAlgorithmSettings.maxDetourDurationRatio; defaultAlgorithmSettings.MAX_DETOUR_DURATION_RATIO;
this.georouter = georouterCreator.create( this.georouter = georouterCreator.create(
defaultAlgorithmSettings.georouterType, defaultAlgorithmSettings.GEOROUTER_TYPE,
defaultAlgorithmSettings.georouterUrl, defaultAlgorithmSettings.GEOROUTER_URL,
); );
if (this.strict) { if (this.strict) {
this.restrict = frequency; this.restrict = frequency;
@@ -3,39 +3,42 @@ import {
MatcherExceptionCode, MatcherExceptionCode,
} from '../../../exceptions/matcher.exception'; } from '../../../exceptions/matcher.exception';
import { IRequestGeography } from '../../interfaces/geography-request.interface'; import { IRequestGeography } from '../../interfaces/geography-request.interface';
import { PointType } from '../../types/geography.enum'; import { PointType } from '../../../../geography/domain/types/point-type.enum';
import { Point } from '../../types/point.type'; import { Point } from '../../../../geography/domain/types/point.type';
import { find } from 'geo-tz'; import { MatcherRoute } from './matcher-route';
import { Route } from './route';
import { Role } from '../../types/role.enum'; import { Role } from '../../types/role.enum';
import { IGeorouter } from '../../interfaces/georouter.interface'; import { IGeorouter } from '../../interfaces/georouter.interface';
import { Waypoint } from './waypoint'; import { Waypoint } from './waypoint';
import { Actor } from './actor'; import { Actor } from './actor';
import { Person } from './person'; import { Ad } from './ad';
import { Step } from '../../types/step.enum'; import { Step } from '../../types/step.enum';
import { Path } from '../../types/path.type'; import { Path } from '../../types/path.type';
import { IFindTimezone } from '../../../../geography/domain/interfaces/timezone-finder.interface';
import { Timezoner } from './timezoner';
export class Geography { export class Geography {
private geographyRequest: IRequestGeography; private geographyRequest: IRequestGeography;
private person: Person; private ad: Ad;
private points: Array<Point>; private points: Point[];
originType: PointType; originType: PointType;
destinationType: PointType; destinationType: PointType;
timezones: Array<string>; timezones: string[];
driverRoute: Route; driverRoute: MatcherRoute;
passengerRoute: Route; passengerRoute: MatcherRoute;
timezoneFinder: IFindTimezone;
constructor( constructor(
geographyRequest: IRequestGeography, geographyRequest: IRequestGeography,
defaultTimezone: string, timezoner: Timezoner,
person: Person, ad: Ad,
) { ) {
this.geographyRequest = geographyRequest; this.geographyRequest = geographyRequest;
this.person = person; this.ad = ad;
this.points = []; this.points = [];
this.originType = undefined; this.originType = undefined;
this.destinationType = undefined; this.destinationType = undefined;
this.timezones = [defaultTimezone]; this.timezones = [timezoner.timezone];
this.timezoneFinder = timezoner.finder;
} }
init = (): void => { init = (): void => {
@@ -45,12 +48,12 @@ export class Geography {
}; };
createRoutes = async ( createRoutes = async (
roles: Array<Role>, roles: Role[],
georouter: IGeorouter, georouter: IGeorouter,
): Promise<void> => { ): Promise<void> => {
let driverWaypoints: Array<Waypoint> = []; let driverWaypoints: Waypoint[] = [];
let passengerWaypoints: Array<Waypoint> = []; let passengerWaypoints: Waypoint[] = [];
const paths: Array<Path> = []; const paths: Path[] = [];
if (roles.includes(Role.DRIVER) && roles.includes(Role.PASSENGER)) { if (roles.includes(Role.DRIVER) && roles.includes(Role.PASSENGER)) {
if (this.points.length == 2) { if (this.points.length == 2) {
// 2 points => same route for driver and passenger // 2 points => same route for driver and passenger
@@ -147,7 +150,7 @@ export class Geography {
}; };
private setTimezones = (): void => { private setTimezones = (): void => {
this.timezones = find( this.timezones = this.timezoneFinder.timezones(
this.geographyRequest.waypoints[0].lat, this.geographyRequest.waypoints[0].lat,
this.geographyRequest.waypoints[0].lon, this.geographyRequest.waypoints[0].lon,
); );
@@ -171,18 +174,15 @@ export class Geography {
private isValidLatitude = (latitude: number): boolean => private isValidLatitude = (latitude: number): boolean =>
latitude >= -90 && latitude <= 90; latitude >= -90 && latitude <= 90;
private createWaypoints = ( private createWaypoints = (points: Point[], role: Role): Waypoint[] => {
points: Array<Point>,
role: Role,
): Array<Waypoint> => {
return points.map((point, index) => { return points.map((point, index) => {
const waypoint = new Waypoint(point); const waypoint = new Waypoint(point);
if (index == 0) { if (index == 0) {
waypoint.addActor(new Actor(this.person, role, Step.START)); waypoint.addActor(new Actor(this.ad, role, Step.START));
} else if (index == points.length - 1) { } else if (index == points.length - 1) {
waypoint.addActor(new Actor(this.person, role, Step.FINISH)); waypoint.addActor(new Actor(this.ad, role, Step.FINISH));
} else { } else {
waypoint.addActor(new Actor(this.person, role, Step.INTERMEDIATE)); waypoint.addActor(new Actor(this.ad, role, Step.INTERMEDIATE));
} }
return waypoint; return waypoint;
}); });
@@ -0,0 +1,16 @@
import { Route } from '../../../../geography/domain/entities/route';
import { IGeodesic } from '../../../../geography/domain/interfaces/geodesic.interface';
import { Waypoint } from './waypoint';
export class MatcherRoute extends Route {
waypoints: Waypoint[];
constructor(geodesic: IGeodesic) {
super(geodesic);
}
setWaypoints = (waypoints: Waypoint[]): void => {
this.waypoints = waypoints;
this.setAzimuth(waypoints.map((waypoint) => waypoint.point));
};
}
@@ -1,6 +1,6 @@
import { Route } from './route'; import { MatcherRoute } from './matcher-route';
export type NamedRoute = { export type NamedRoute = {
key: string; key: string;
route: Route; route: MatcherRoute;
}; };
@@ -1,40 +0,0 @@
import { IRequestPerson } from '../../interfaces/person-request.interface';
export class Person {
private personRequest: IRequestPerson;
private defaultIdentifier: number;
private defaultMarginDuration: number;
identifier: number;
marginDurations: Array<number>;
constructor(
personRequest: IRequestPerson,
defaultIdentifier: number,
defaultMarginDuration: number,
) {
this.personRequest = personRequest;
this.defaultIdentifier = defaultIdentifier;
this.defaultMarginDuration = defaultMarginDuration;
}
init = (): void => {
this.setIdentifier(this.personRequest.identifier ?? this.defaultIdentifier);
this.setMarginDurations([
this.defaultMarginDuration,
this.defaultMarginDuration,
this.defaultMarginDuration,
this.defaultMarginDuration,
this.defaultMarginDuration,
this.defaultMarginDuration,
this.defaultMarginDuration,
]);
};
setIdentifier = (identifier: number): void => {
this.identifier = identifier;
};
setMarginDurations = (marginDurations: Array<number>): void => {
this.marginDurations = marginDurations;
};
}
@@ -1,10 +1,12 @@
import { Coordinate } from '../../../../geography/domain/entities/coordinate';
export class SpacetimePoint { export class SpacetimePoint {
point: Array<number>; coordinate: Coordinate;
duration: number; duration: number;
distance: number; distance: number;
constructor(point: Array<number>, duration: number, distance: number) { constructor(coordinate: Coordinate, duration: number, distance: number) {
this.point = point; this.coordinate = coordinate;
this.duration = duration; this.duration = duration;
this.distance = distance; this.distance = distance;
} }
@@ -4,27 +4,31 @@ import {
} from '../../../exceptions/matcher.exception'; } from '../../../exceptions/matcher.exception';
import { MarginDurations } from '../../types/margin-durations.type'; import { MarginDurations } from '../../types/margin-durations.type';
import { IRequestTime } from '../../interfaces/time-request.interface'; import { IRequestTime } from '../../interfaces/time-request.interface';
import { TimingDays, TimingFrequency, Days } from '../../types/timing'; import { DAYS } from '../../types/days.const';
import { Schedule } from '../../types/schedule.type'; import { TimeSchedule } from '../../types/time-schedule.type';
import { Frequency } from '../../../../ad/domain/types/frequency.enum';
import { Day } from '../../types/day.type';
import { IConvertTime } from '../../interfaces/time-converter.interface';
export class Time { export class Time {
private timeRequest: IRequestTime; private timeRequest: IRequestTime;
private defaultMarginDuration: number;
private defaultValidityDuration: number; private defaultValidityDuration: number;
frequency: TimingFrequency; private timeConverter: IConvertTime;
frequency: Frequency;
fromDate: Date; fromDate: Date;
toDate: Date; toDate: Date;
schedule: Schedule; schedule: TimeSchedule;
marginDurations: MarginDurations; marginDurations: MarginDurations;
constructor( constructor(
timeRequest: IRequestTime, timeRequest: IRequestTime,
defaultMarginDuration: number, defaultMarginDuration: number,
defaultValidityDuration: number, defaultValidityDuration: number,
timeConverter: IConvertTime,
) { ) {
this.timeRequest = timeRequest; this.timeRequest = timeRequest;
this.defaultMarginDuration = defaultMarginDuration;
this.defaultValidityDuration = defaultValidityDuration; this.defaultValidityDuration = defaultValidityDuration;
this.timeConverter = timeConverter;
this.schedule = {}; this.schedule = {};
this.marginDurations = { this.marginDurations = {
mon: defaultMarginDuration, mon: defaultMarginDuration,
@@ -106,7 +110,7 @@ export class Time {
} }
if ( if (
!Object.keys(this.timeRequest.schedule).some((elem) => !Object.keys(this.timeRequest.schedule).some((elem) =>
Days.includes(elem), DAYS.includes(elem),
) )
) { ) {
throw new MatcherException( throw new MatcherException(
@@ -127,15 +131,17 @@ export class Time {
private setPunctualRequest = (): void => { private setPunctualRequest = (): void => {
if (this.timeRequest.departure) { if (this.timeRequest.departure) {
this.frequency = TimingFrequency.FREQUENCY_PUNCTUAL; this.frequency = Frequency.PUNCTUAL;
this.schedule[TimingDays[this.fromDate.getDay()]] = this.schedule[Day[this.fromDate.getDay()]] = this.timeConverter.toUtcDate(
this.fromDate.getHours() + ':' + this.fromDate.getMinutes(); this.fromDate,
this.timeRequest.timezone,
);
} }
}; };
private setRecurrentRequest = (): void => { private setRecurrentRequest = (): void => {
if (this.timeRequest.fromDate) { if (this.timeRequest.fromDate) {
this.frequency = TimingFrequency.FREQUENCY_RECURRENT; this.frequency = Frequency.RECURRENT;
if (!this.toDate) { if (!this.toDate) {
this.toDate = this.addDays(this.fromDate, this.defaultValidityDuration); this.toDate = this.addDays(this.fromDate, this.defaultValidityDuration);
} }
@@ -145,7 +151,14 @@ export class Time {
private setSchedule = (): void => { private setSchedule = (): void => {
Object.keys(this.timeRequest.schedule).map((day) => { Object.keys(this.timeRequest.schedule).map((day) => {
this.schedule[day] = this.timeRequest.schedule[day]; this.schedule[day] = this.timeConverter.toUtcDate(
new Date(
`${this.fromDate.getFullYear()}-${this.fromDate.getMonth()}-${this.fromDate.getDate()} ${
this.timeRequest.schedule[day]
}`,
),
this.timeRequest.timezone,
);
}); });
}; };
@@ -165,7 +178,7 @@ export class Time {
if (this.timeRequest.marginDurations) { if (this.timeRequest.marginDurations) {
if ( if (
!Object.keys(this.timeRequest.marginDurations).some((elem) => !Object.keys(this.timeRequest.marginDurations).some((elem) =>
Days.includes(elem), DAYS.includes(elem),
) )
) { ) {
throw new MatcherException( throw new MatcherException(
@@ -0,0 +1,6 @@
import { IFindTimezone } from '../../../../geography/domain/interfaces/timezone-finder.interface';
export type Timezoner = {
timezone: string;
finder: IFindTimezone;
};
@@ -1,9 +1,9 @@
import { Point } from '../../types/point.type'; import { Point } from '../../../../geography/domain/types/point.type';
import { Actor } from './actor'; import { Actor } from './actor';
export class Waypoint { export class Waypoint {
point: Point; point: Point;
actors: Array<Actor>; actors: Actor[];
constructor(point: Point) { constructor(point: Point) {
this.point = point; this.point = point;
@@ -1,5 +1,5 @@
import { Person } from '../ecosystem/person'; import { Ad } from '../ecosystem/ad';
export class Candidate { export class Candidate {
person: Person; ad: Ad;
} }
@@ -5,7 +5,7 @@ import { Selector } from '../selector/selector.abstract';
export abstract class AlgorithmFactory { export abstract class AlgorithmFactory {
protected matchQuery: MatchQuery; protected matchQuery: MatchQuery;
private candidates: Array<Candidate>; private candidates: Candidate[];
constructor(matchQuery: MatchQuery) { constructor(matchQuery: MatchQuery) {
this.matchQuery = matchQuery; this.matchQuery = matchQuery;
@@ -13,5 +13,5 @@ export abstract class AlgorithmFactory {
} }
abstract createSelector(): Selector; abstract createSelector(): Selector;
abstract createProcessors(): Array<Processor>; abstract createProcessors(): Processor[];
} }
@@ -10,7 +10,7 @@ import { ClassicSelector } from '../selector/classic.selector';
export class ClassicAlgorithmFactory extends AlgorithmFactory { export class ClassicAlgorithmFactory extends AlgorithmFactory {
createSelector = (): Selector => new ClassicSelector(this.matchQuery); createSelector = (): Selector => new ClassicSelector(this.matchQuery);
createProcessors = (): Array<Processor> => [ createProcessors = (): Processor[] => [
new ClassicWaypointsCompleter(this.matchQuery), new ClassicWaypointsCompleter(this.matchQuery),
new RouteCompleter(this.matchQuery, true, true, true), new RouteCompleter(this.matchQuery, true, true, true),
new ClassicGeoFilter(this.matchQuery), new ClassicGeoFilter(this.matchQuery),
@@ -11,10 +11,10 @@ export class Matcher {
private readonly algorithmFactoryCreator: AlgorithmFactoryCreator, private readonly algorithmFactoryCreator: AlgorithmFactoryCreator,
) {} ) {}
match = async (matchQuery: MatchQuery): Promise<Array<Match>> => { match = async (matchQuery: MatchQuery): Promise<Match[]> => {
const algorithmFactory: AlgorithmFactory = const algorithmFactory: AlgorithmFactory =
this.algorithmFactoryCreator.create(matchQuery); this.algorithmFactoryCreator.create(matchQuery);
let candidates: Array<Candidate> = await algorithmFactory let candidates: Candidate[] = await algorithmFactory
.createSelector() .createSelector()
.select(); .select();
for (const processor of algorithmFactory.createProcessors()) { for (const processor of algorithmFactory.createProcessors()) {
@@ -2,7 +2,7 @@ import { Candidate } from '../../candidate';
import { Completer } from './completer.abstract'; import { Completer } from './completer.abstract';
export class ClassicWaypointsCompleter extends Completer { export class ClassicWaypointsCompleter extends Completer {
complete = (candidates: Array<Candidate>): Array<Candidate> => { complete = (candidates: Candidate[]): Candidate[] => {
return candidates; return candidates;
}; };
} }
@@ -2,8 +2,7 @@ import { Candidate } from '../../candidate';
import { Processor } from '../processor.abstract'; import { Processor } from '../processor.abstract';
export abstract class Completer extends Processor { export abstract class Completer extends Processor {
execute = (candidates: Array<Candidate>): Array<Candidate> => execute = (candidates: Candidate[]): Candidate[] => this.complete(candidates);
this.complete(candidates);
abstract complete(candidates: Array<Candidate>): Array<Candidate>; abstract complete(candidates: Candidate[]): Candidate[];
} }

Some files were not shown because too many files have changed in this diff Show More