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
commit 4be5301e5c
152 changed files with 4118 additions and 766 deletions

7
.env.test Normal file
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"

16
package-lock.json generated
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",

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",

View File

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

View File

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

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
}

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: [],

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,9 @@
import { CreateAdRequest } from '../domain/dtos/create-ad.request';
export class CreateAdCommand {
readonly createAdRequest: CreateAdRequest;
constructor(request: CreateAdRequest) {
this.createAdRequest = request;
}
}

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,4 @@
export enum Frequency {
PUNCTUAL = 'PUNCTUAL',
RECURRENT = 'RECURRENT',
}

View File

@ -0,0 +1,4 @@
export enum Role {
DRIVER = 'DRIVER',
PASSENGER = 'PASSENGER',
}

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,5 @@
import { Coordinate } from '../entities/coordinate';
export interface IEncodeDirection {
encode(coordinates: Coordinate[]): string;
}

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,6 @@
import { Route } from '../entities/route';
export type NamedRoute = {
key: string;
route: Route;
};

View File

@ -0,0 +1,6 @@
import { Point } from '../../../geography/domain/types/point.type';
export type Path = {
key: string;
points: Point[];
};

View File

@ -0,0 +1,6 @@
import { PointType } from './point-type.enum';
import { Coordinate } from '../entities/coordinate';
export type Point = Coordinate & {
type?: PointType;
};

View File

@ -0,0 +1,6 @@
import { IFindTimezone } from '../interfaces/timezone-finder.interface';
export type Timezoner = {
timezone: string;
finder: IFindTimezone;
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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: [

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,6 @@
import { IFindTimezone } from '../../../../geography/domain/interfaces/timezone-finder.interface';
export type Timezoner = {
timezone: string;
finder: IFindTimezone;
};

View File

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

View File

@ -1,5 +1,5 @@
import { Person } from '../ecosystem/person'; import { Ad } from '../ecosystem/ad';
export class Candidate { export class Candidate {
person: Person; ad: Ad;
} }

View File

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

View File

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

View File

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

View File

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

View File

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