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",
"ioredis": "^5.3.1",
"reflect-metadata": "^0.1.13",
"rxjs": "^7.2.0"
"rxjs": "^7.2.0",
"timezonecomplete": "^5.12.4"
},
"devDependencies": {
"@nestjs/cli": "^9.0.0",
@ -8576,6 +8577,14 @@
"integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==",
"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": {
"version": "0.0.33",
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz",
@ -8867,6 +8876,11 @@
"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": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/uid/-/uid-2.0.2.tgz",

View File

@ -59,7 +59,8 @@
"got": "^11.8.6",
"ioredis": "^5.3.1",
"reflect-metadata": "^0.1.13",
"rxjs": "^7.2.0"
"rxjs": "^7.2.0",
"timezonecomplete": "^5.12.4"
},
"devDependencies": {
"@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 {
provider = "prisma-client-js"
binaryTargets = ["linux-musl", "debian-openssl-3.0.x"]
previewFeatures = ["postgresqlExtensions"]
}
@ -13,47 +14,52 @@ datasource db {
}
model Ad {
uuid String @id @default(uuid()) @db.Uuid
driver Boolean
passenger Boolean
frequency Int
from_date DateTime @db.Date
to_date DateTime @db.Date
mon_time DateTime @db.Timestamptz()
tue_time DateTime @db.Timestamptz()
wed_time DateTime @db.Timestamptz()
thu_time DateTime @db.Timestamptz()
fri_time DateTime @db.Timestamptz()
sat_time DateTime @db.Timestamptz()
sun_time DateTime @db.Timestamptz()
mon_margin Int
tue_margin Int
wed_margin Int
thu_margin Int
fri_margin Int
sat_margin Int
sun_margin Int
driver_duration Int
driver_distance Int
passenger_duration Int
passenger_distance Int
origin_type Int @db.SmallInt
destination_type Int @db.SmallInt
waypoints Unsupported("geography(LINESTRING)")
direction Unsupported("geography(LINESTRING)")
fwd_azimuth Int
back_azimuth Int
seats_driver Int @db.SmallInt
seats_passenger Int @db.SmallInt
seats_used Int @db.SmallInt
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
uuid String @id @db.Uuid
userUuid String @db.Uuid
driver Boolean
passenger Boolean
frequency Frequency
fromDate DateTime @db.Date
toDate DateTime @db.Date
monTime DateTime? @db.Timestamptz()
tueTime DateTime? @db.Timestamptz()
wedTime DateTime? @db.Timestamptz()
thuTime DateTime? @db.Timestamptz()
friTime DateTime? @db.Timestamptz()
satTime DateTime? @db.Timestamptz()
sunTime DateTime? @db.Timestamptz()
monMargin Int
tueMargin Int
wedMargin Int
thuMargin Int
friMargin Int
satMargin Int
sunMargin Int
driverDuration Int?
driverDistance Int?
passengerDuration Int?
passengerDistance Int?
waypoints Unsupported("geography(LINESTRING)")?
direction Unsupported("geography(LINESTRING)")?
fwdAzimuth Int
backAzimuth Int
seatsDriver Int @db.SmallInt
seatsPassenger Int @db.SmallInt
seatsUsed Int @db.SmallInt
strict Boolean
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
@@index([driver])
@@index([passenger])
@@index([from_date])
@@index([to_date])
@@index([fwd_azimuth])
@@index([fromDate])
@@index([toDate])
@@index([fwdAzimuth])
@@index([direction], name: "direction_idx", type: Gist)
@@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 { HealthModule } from './modules/health/health.module';
import { MatcherModule } from './modules/matcher/matcher.module';
import { AdModule } from './modules/ad/ad.module';
@Module({
imports: [
@ -13,6 +14,7 @@ import { MatcherModule } from './modules/matcher/matcher.module';
ConfigurationModule,
HealthModule,
MatcherModule,
AdModule,
],
controllers: [],
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',
})
public async propagateConfigurationsHandler(message: string) {
const configurations: Array<Configuration> = JSON.parse(message);
const configurations: Configuration[] = JSON.parse(message);
configurations.forEach(async (configuration) => {
if (
configuration.domain ==

View File

@ -10,7 +10,7 @@ import { PrismaService } from './prisma-service';
*/
@Injectable()
export abstract class PrismaRepository<T> implements IRepository<T> {
protected _model: string;
protected model: string;
constructor(protected readonly _prisma: PrismaService) {}
@ -21,13 +21,13 @@ export abstract class PrismaRepository<T> implements IRepository<T> {
include?: any,
): Promise<ICollection<T>> {
const [data, total] = await this._prisma.$transaction([
this._prisma[this._model].findMany({
this._prisma[this.model].findMany({
where,
include,
skip: (page - 1) * perPage,
take: perPage,
}),
this._prisma[this._model].count({
this._prisma[this.model].count({
where,
}),
]);
@ -39,7 +39,7 @@ export abstract class PrismaRepository<T> implements IRepository<T> {
async findOneByUuid(uuid: string): Promise<T> {
try {
const entity = await this._prisma[this._model].findUnique({
const entity = await this._prisma[this.model].findUnique({
where: { uuid },
});
@ -59,7 +59,7 @@ export abstract class PrismaRepository<T> implements IRepository<T> {
async findOne(where: any, include?: any): Promise<T> {
try {
const entity = await this._prisma[this._model].findFirst({
const entity = await this._prisma[this.model].findFirst({
where: where,
include: include,
});
@ -81,7 +81,7 @@ export abstract class PrismaRepository<T> implements IRepository<T> {
// TODO : Refactor for good clean architecture ?
async create(entity: Partial<T> | any, include?: any): Promise<T> {
try {
const res = await this._prisma[this._model].create({
const res = await this._prisma[this.model].create({
data: entity,
include: include,
});
@ -102,7 +102,7 @@ export abstract class PrismaRepository<T> implements IRepository<T> {
async update(uuid: string, entity: Partial<T>): Promise<T> {
try {
const updatedEntity = await this._prisma[this._model].update({
const updatedEntity = await this._prisma[this.model].update({
where: { uuid },
data: entity,
});
@ -126,7 +126,7 @@ export abstract class PrismaRepository<T> implements IRepository<T> {
include?: any,
): Promise<T> {
try {
const updatedEntity = await this._prisma[this._model].update({
const updatedEntity = await this._prisma[this.model].update({
where: where,
data: entity,
include: include,
@ -148,7 +148,7 @@ export abstract class PrismaRepository<T> implements IRepository<T> {
async delete(uuid: string): Promise<T> {
try {
const entity = await this._prisma[this._model].delete({
const entity = await this._prisma[this.model].delete({
where: { uuid },
});
@ -168,7 +168,7 @@ export abstract class PrismaRepository<T> implements IRepository<T> {
async deleteMany(where: any): Promise<void> {
try {
const entity = await this._prisma[this._model].deleteMany({
const entity = await this._prisma[this.model].deleteMany({
where: where,
});
@ -187,13 +187,13 @@ export abstract class PrismaRepository<T> implements IRepository<T> {
}
async findAllByQuery(
include: Array<string>,
where: Array<string>,
include: string[],
where: string[],
): Promise<ICollection<T>> {
const query = `SELECT ${include.join(',')} FROM ${
this._model
this.model
} WHERE ${where.join(' AND ')}`;
const data: Array<T> = await this._prisma.$queryRawUnsafe(query);
const data: T[] = await this._prisma.$queryRawUnsafe(query);
return Promise.resolve({
data,
total: data.length,
@ -202,9 +202,9 @@ export abstract class PrismaRepository<T> implements IRepository<T> {
async createWithFields(fields: object): Promise<number> {
try {
const command = `INSERT INTO ${this._model} (${Object.keys(fields).join(
',',
)}) VALUES (${Object.values(fields).join(',')})`;
const command = `INSERT INTO ${this.model} ("${Object.keys(fields).join(
'","',
)}") VALUES (${Object.values(fields).join(',')})`;
return await this._prisma.$executeRawUnsafe(command);
} catch (e) {
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)`;
const values = Object.keys(entity).map((key) => `${key} = ${entity[key]}`);
try {
const command = `UPDATE ${this._model} SET ${values.join(
const command = `UPDATE ${this.model} SET ${values.join(
', ',
)} WHERE uuid = '${uuid}'`;
return await this._prisma.$executeRawUnsafe(command);

View File

@ -1,9 +1,9 @@
import { Module } from '@nestjs/common';
import { PrismaService } from './src/adapters/secondaries/prisma-service';
import { MatcherRepository } from './src/domain/matcher-repository';
import { PrismaService } from './adapters/secondaries/prisma-service';
import { AdRepository } from '../ad/adapters/secondaries/ad.repository';
@Module({
providers: [PrismaService, MatcherRepository],
exports: [PrismaService, MatcherRepository],
providers: [PrismaService, AdRepository],
exports: [PrismaService, AdRepository],
})
export class DatabaseModule {}

View File

@ -1,8 +1,8 @@
import { Injectable } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import { PrismaService } from '../../src/adapters/secondaries/prisma-service';
import { PrismaRepository } from '../../src/adapters/secondaries/prisma-repository.abstract';
import { DatabaseException } from '../../src/exceptions/database.exception';
import { PrismaService } from '../../adapters/secondaries/prisma-service';
import { PrismaRepository } from '../../adapters/secondaries/prisma-repository.abstract';
import { DatabaseException } from '../../exceptions/database.exception';
import { Prisma } from '@prisma/client';
class FakeEntity {
@ -41,7 +41,7 @@ Array.from({ length: 10 }).forEach(() => {
@Injectable()
class FakePrismaRepository extends PrismaRepository<FakeEntity> {
protected _model = 'fake';
protected model = 'fake';
}
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 { Point } from '../../types/point.type';
import { IGeodesic } from '../interfaces/geodesic.interface';
import { Point } from '../types/point.type';
import { SpacetimePoint } from './spacetime-point';
import { Waypoint } from './waypoint';
export class Route {
distance: number;
@ -9,9 +8,8 @@ export class Route {
fwdAzimuth: number;
backAzimuth: number;
distanceAzimuth: number;
waypoints: Array<Waypoint>;
points: Array<Point>;
spacetimePoints: Array<SpacetimePoint>;
points: Point[];
spacetimePoints: SpacetimePoint[];
private geodesic: IGeodesic;
constructor(geodesic: IGeodesic) {
@ -20,27 +18,21 @@ export class Route {
this.fwdAzimuth = undefined;
this.backAzimuth = undefined;
this.distanceAzimuth = undefined;
this.waypoints = [];
this.points = [];
this.spacetimePoints = [];
this.geodesic = geodesic;
}
setWaypoints = (waypoints: Array<Waypoint>): void => {
this.waypoints = waypoints;
this.setAzimuth(waypoints.map((waypoint) => waypoint.point));
};
setPoints = (points: Array<Point>): void => {
setPoints = (points: Point[]): void => {
this.points = points;
this.setAzimuth(points);
};
setSpacetimePoints = (spacetimePoints: Array<SpacetimePoint>): void => {
setSpacetimePoints = (spacetimePoints: SpacetimePoint[]): void => {
this.spacetimePoints = spacetimePoints;
};
private setAzimuth = (points: Array<Point>): void => {
protected setAzimuth = (points: Point[]): void => {
const inverse = this.geodesic.inverse(
points[0].lon,
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,
HealthIndicatorResult,
} from '@nestjs/terminus';
import { AdRepository } from '../../../matcher/adapters/secondaries/ad.repository';
import { AdRepository } from '../../../ad/adapters/secondaries/ad.repository';
@Injectable()
export class PrismaHealthIndicatorUseCase extends HealthIndicator {

View File

@ -7,7 +7,7 @@ import { TerminusModule } from '@nestjs/terminus';
import { RabbitMQModule } from '@golevelup/nestjs-rabbitmq';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { Messager } from './adapters/secondaries/messager';
import { AdRepository } from '../matcher/adapters/secondaries/ad.repository';
import { AdRepository } from '../ad/adapters/secondaries/ad.repository';
@Module({
imports: [

View File

@ -1,7 +1,7 @@
import { Test, TestingModule } from '@nestjs/testing';
import { PrismaHealthIndicatorUseCase } from '../../domain/usecases/prisma.health-indicator.usecase';
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';
const mockAdRepository = {

View File

@ -3,14 +3,16 @@ import { InjectMapper } from '@automapper/nestjs';
import { Controller, UsePipes } from '@nestjs/common';
import { QueryBus } from '@nestjs/cqrs';
import { GrpcMethod, RpcException } from '@nestjs/microservices';
import { RpcValidationPipe } from 'src/modules/utils/pipes/rpc.validation-pipe';
import { RpcValidationPipe } from '../../../utils/pipes/rpc.validation-pipe';
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 { MatchPresenter } from '../secondaries/match.presenter';
import { DefaultParamsProvider } from '../secondaries/default-params.provider';
import { GeorouterCreator } from '../secondaries/georouter-creator';
import { Match } from '../../domain/entities/ecosystem/match';
import { GeoTimezoneFinder } from '../../../geography/adapters/secondaries/geo-timezone-finder';
import { TimeConverter } from '../secondaries/time-converter';
@UsePipes(
new RpcValidationPipe({
@ -21,25 +23,29 @@ import { Match } from '../../domain/entities/ecosystem/match';
@Controller()
export class MatcherController {
constructor(
private readonly _queryBus: QueryBus,
private readonly _defaultParamsProvider: DefaultParamsProvider,
@InjectMapper() private readonly _mapper: Mapper,
private readonly _georouterCreator: GeorouterCreator,
private readonly queryBus: QueryBus,
private readonly defaultParamsProvider: DefaultParamsProvider,
@InjectMapper() private readonly mapper: Mapper,
private readonly georouterCreator: GeorouterCreator,
private readonly timezoneFinder: GeoTimezoneFinder,
private readonly timeConverter: TimeConverter,
) {}
@GrpcMethod('MatcherService', 'Match')
async match(data: MatchRequest): Promise<ICollection<Match>> {
try {
const matchCollection = await this._queryBus.execute(
const matchCollection = await this.queryBus.execute(
new MatchQuery(
data,
this._defaultParamsProvider.getParams(),
this._georouterCreator,
this.defaultParamsProvider.getParams(),
this.georouterCreator,
this.timezoneFinder,
this.timeConverter,
),
);
return Promise.resolve({
data: matchCollection.data.map((match: Match) =>
this._mapper.map(match, Match, MatchPresenter),
this.mapper.map(match, Match, MatchPresenter),
),
total: matchCollection.total,
});

View File

@ -7,31 +7,31 @@ service MatcherService {
}
message MatchRequest {
repeated Point waypoints = 1;
string departure = 2;
string fromDate = 3;
Schedule schedule = 4;
bool driver = 5;
bool passenger = 6;
string toDate = 7;
int32 marginDuration = 8;
MarginDurations marginDurations = 9;
int32 seatsPassenger = 10;
int32 seatsDriver = 11;
bool strict = 12;
Algorithm algorithm = 13;
int32 remoteness = 14;
bool useProportion = 15;
int32 proportion = 16;
bool useAzimuth = 17;
int32 azimuthMargin = 18;
float maxDetourDistanceRatio = 19;
float maxDetourDurationRatio = 20;
repeated int32 exclusions = 21;
int32 identifier = 22;
string uuid = 1;
repeated Coordinates waypoints = 2;
string departure = 3;
string fromDate = 4;
Schedule schedule = 5;
bool driver = 6;
bool passenger = 7;
string toDate = 8;
int32 marginDuration = 9;
MarginDurations marginDurations = 10;
int32 seatsPassenger = 11;
int32 seatsDriver = 12;
bool strict = 13;
Algorithm algorithm = 14;
int32 remoteness = 15;
bool useProportion = 16;
int32 proportion = 17;
bool useAzimuth = 18;
int32 azimuthMargin = 19;
float maxDetourDistanceRatio = 20;
float maxDetourDurationRatio = 21;
repeated int32 exclusions = 22;
}
message Point {
message Coordinates {
float lon = 1;
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 => {
return {
DEFAULT_IDENTIFIER: parseInt(
this.configService.get('DEFAULT_IDENTIFIER'),
),
DEFAULT_UUID: this.configService.get('DEFAULT_UUID'),
MARGIN_DURATION: parseInt(this.configService.get('MARGIN_DURATION')),
VALIDITY_DURATION: parseInt(this.configService.get('VALIDITY_DURATION')),
DEFAULT_TIMEZONE: this.configService.get('DEFAULT_TIMEZONE'),
DEFAULT_SEATS: parseInt(this.configService.get('DEFAULT_SEATS')),
DEFAULT_ALGORITHM_SETTINGS: {
algorithm: this.configService.get('ALGORITHM'),
strict: !!parseInt(this.configService.get('STRICT_ALGORITHM')),
remoteness: parseInt(this.configService.get('REMOTENESS')),
useProportion: !!parseInt(this.configService.get('USE_PROPORTION')),
proportion: parseInt(this.configService.get('PROPORTION')),
useAzimuth: !!parseInt(this.configService.get('USE_AZIMUTH')),
azimuthMargin: parseInt(this.configService.get('AZIMUTH_MARGIN')),
maxDetourDistanceRatio: parseFloat(
ALGORITHM: this.configService.get('ALGORITHM'),
STRICT: !!parseInt(this.configService.get('STRICT_ALGORITHM')),
REMOTENESS: parseInt(this.configService.get('REMOTENESS')),
USE_PROPORTION: !!parseInt(this.configService.get('USE_PROPORTION')),
PROPORTION: parseInt(this.configService.get('PROPORTION')),
USE_AZIMUTH: !!parseInt(this.configService.get('USE_AZIMUTH')),
AZIMUTH_MARGIN: parseInt(this.configService.get('AZIMUTH_MARGIN')),
MAX_DETOUR_DISTANCE_RATIO: parseFloat(
this.configService.get('MAX_DETOUR_DISTANCE_RATIO'),
),
maxDetourDurationRatio: parseFloat(
MAX_DETOUR_DURATION_RATIO: parseFloat(
this.configService.get('MAX_DETOUR_DURATION_RATIO'),
),
georouterType: this.configService.get('GEOROUTER_TYPE'),
georouterUrl: this.configService.get('GEOROUTER_URL'),
GEOROUTER_TYPE: this.configService.get('GEOROUTER_TYPE'),
GEOROUTER_URL: this.configService.get('GEOROUTER_URL'),
},
};
};

View File

@ -1,27 +1,16 @@
import { Injectable } from '@nestjs/common';
import { IGeodesic } from '../../domain/interfaces/geodesic.interface';
import { Geodesic, GeodesicClass } from 'geographiclib-geodesic';
import { Geodesic } from '../../../geography/adapters/secondaries/geodesic';
import { IGeodesic } from '../../../geography/domain/interfaces/geodesic.interface';
@Injectable()
export class MatcherGeodesic implements IGeodesic {
private geod: GeodesicClass;
constructor() {
this.geod = Geodesic.WGS84;
}
constructor(private readonly geodesic: Geodesic) {}
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 };
};
): { azimuth: number; distance: number } =>
this.geodesic.inverse(lon1, lat1, lon2, lat2);
}

View File

@ -5,9 +5,9 @@ import { Path } from '../../domain/types/path.type';
import { Injectable } from '@nestjs/common';
import { catchError, lastValueFrom, map } from 'rxjs';
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 { Route } from '../../domain/entities/ecosystem/route';
import { MatcherRoute } from '../../domain/entities/ecosystem/matcher-route';
import { SpacetimePoint } from '../../domain/entities/ecosystem/spacetime-point';
import {
MatcherException,
@ -17,11 +17,11 @@ import {
@Injectable()
export class GraphhopperGeorouter implements IGeorouter {
private url: string;
private urlArgs: Array<string>;
private urlArgs: string[];
private withTime: boolean;
private withPoints: boolean;
private withDistance: boolean;
private paths: Array<Path>;
private paths: Path[];
private httpService: HttpService;
private geodesic: IGeodesic;
@ -32,9 +32,9 @@ export class GraphhopperGeorouter implements IGeorouter {
}
route = async (
paths: Array<Path>,
paths: Path[],
settings: GeorouterSettings,
): Promise<Array<NamedRoute>> => {
): Promise<NamedRoute[]> => {
this.setDefaultUrlArgs();
this.setWithTime(settings.withTime);
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(
this.paths.map(async (path) => {
const url: string = [
@ -106,8 +106,8 @@ export class GraphhopperGeorouter implements IGeorouter {
private createRoute = (
response: AxiosResponse<GraphhopperResponse>,
): Route => {
const route = new Route(this.geodesic);
): MatcherRoute => {
const route = new MatcherRoute(this.geodesic);
if (response.data.paths && response.data.paths[0]) {
const shortestPath = response.data.paths[0];
route.distance = shortestPath.distance ?? 0;
@ -125,7 +125,7 @@ export class GraphhopperGeorouter implements IGeorouter {
shortestPath.snapped_waypoints &&
shortestPath.snapped_waypoints.coordinates
) {
let instructions: Array<GraphhopperInstruction> = [];
let instructions: GraphhopperInstruction[] = [];
if (shortestPath.instructions)
instructions = shortestPath.instructions;
route.setSpacetimePoints(
@ -143,18 +143,18 @@ export class GraphhopperGeorouter implements IGeorouter {
};
private generateSpacetimePoints = (
points: Array<Array<number>>,
snappedWaypoints: Array<Array<number>>,
durations: Array<Array<number>>,
instructions: Array<GraphhopperInstruction>,
): Array<SpacetimePoint> => {
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(
points[index],
{ lon: points[index][1], lat: points[index][0] },
times.find((time) => time.index == index)?.duration,
distances.find((distance) => distance.index == index)?.distance,
),
@ -162,9 +162,9 @@ export class GraphhopperGeorouter implements IGeorouter {
};
private getIndices = (
points: Array<Array<number>>,
snappedWaypoints: Array<Array<number>>,
): Array<number> => {
points: Array<number[]>,
snappedWaypoints: Array<number[]>,
): number[] => {
const indices = snappedWaypoints.map((waypoint) =>
points.findIndex(
(point) => point[0] == waypoint[0] && point[1] == waypoint[1],
@ -178,7 +178,7 @@ export class GraphhopperGeorouter implements IGeorouter {
{
index: number;
originIndex: number;
waypoint: Array<number>;
waypoint: number[];
nearest: number;
distance: number;
}
@ -212,8 +212,8 @@ export class GraphhopperGeorouter implements IGeorouter {
};
private getTimes = (
durations: Array<Array<number>>,
indices: Array<number>,
durations: Array<number[]>,
indices: number[],
): Array<{ index: number; duration: number }> => {
const times: Array<{ index: number; duration: number }> = [];
let duration = 0;
@ -262,8 +262,8 @@ export class GraphhopperGeorouter implements IGeorouter {
};
private getDistances = (
instructions: Array<GraphhopperInstruction>,
indices: Array<number>,
instructions: GraphhopperInstruction[],
indices: number[],
): Array<{ index: number; distance: number }> => {
let distance = 0;
const distances: Array<{ index: number; distance: number }> = [
@ -296,26 +296,26 @@ type GraphhopperResponse = {
weight: number;
time: number;
points_encoded: boolean;
bbox: Array<number>;
bbox: number[];
points: GraphhopperCoordinates;
snapped_waypoints: GraphhopperCoordinates;
details: {
time: Array<Array<number>>;
time: Array<number[]>;
};
instructions: Array<GraphhopperInstruction>;
instructions: GraphhopperInstruction[];
},
];
};
type GraphhopperCoordinates = {
coordinates: Array<Array<number>>;
coordinates: Array<number[]>;
};
type GraphhopperInstruction = {
distance: number;
heading: number;
sign: GraphhopperSign;
interval: Array<number>;
interval: number[];
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,
} from 'class-validator';
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 { MarginDurations } from '../types/margin-durations.type';
import { AlgorithmType } from '../types/algorithm.enum';
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 { IRequestRequirement } from '../interfaces/requirement-request.interface';
import { IRequestAlgorithmSettings } from '../interfaces/algorithm-settings-request.interface';
import { Mode } from '../types/mode.enum';
export class MatchRequest
implements
IRequestTime,
IRequestPerson,
IRequestAd,
IRequestGeography,
IRequestRequirement,
IRequestAlgorithmSettings
{
@IsOptional()
@IsString()
@AutoMap()
uuid: string;
@IsOptional()
@IsEnum(Mode)
@AutoMap()
mode: Mode;
@IsArray()
@AutoMap()
waypoints: Array<Point>;
waypoints: Point[];
@IsOptional()
@IsString()
@ -138,10 +149,7 @@ export class MatchRequest
@IsOptional()
@IsArray()
exclusions: Array<number>;
exclusions: string[];
@IsOptional()
@IsInt()
@AutoMap()
identifier: number;
timezone?: string;
}

View File

@ -1,14 +1,14 @@
import { Role } from '../../types/role.enum';
import { Step } from '../../types/step.enum';
import { Person } from './person';
import { Ad } from './ad';
export class Actor {
person: Person;
ad: Ad;
role: Role;
step: Step;
constructor(person: Person, role: Role, step: Step) {
this.person = person;
constructor(ad: Ad, role: Role, step: Step) {
this.ad = ad;
this.role = role;
this.step = step;
}

View File

@ -1,6 +1,40 @@
import { AutoMap } from '@automapper/classes';
import { IRequestAd } from '../../interfaces/ad-request.interface';
export class Ad {
@AutoMap()
private adRequest: IRequestAd;
private defaultUuid: string;
private defaultMarginDuration: number;
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 { DefaultAlgorithmSettings } from '../../types/default-algorithm-settings.type';
import { AlgorithmType } from '../../types/algorithm.enum';
import { TimingFrequency } from '../../types/timing';
import { ICreateGeorouter } from '../../interfaces/georouter-creator.interface';
import { IGeorouter } from '../../interfaces/georouter.interface';
import { Frequency } from '../../../../ad/domain/types/frequency.enum';
export class AlgorithmSettings {
private algorithmSettingsRequest: IRequestAlgorithmSettings;
private strict: boolean;
algorithmType: AlgorithmType;
restrict: TimingFrequency;
restrict: Frequency;
remoteness: number;
useProportion: boolean;
proportion: number;
@ -22,38 +22,38 @@ export class AlgorithmSettings {
constructor(
algorithmSettingsRequest: IRequestAlgorithmSettings,
defaultAlgorithmSettings: DefaultAlgorithmSettings,
frequency: TimingFrequency,
frequency: Frequency,
georouterCreator: ICreateGeorouter,
) {
this.algorithmSettingsRequest = algorithmSettingsRequest;
this.algorithmType =
algorithmSettingsRequest.algorithm ?? defaultAlgorithmSettings.algorithm;
algorithmSettingsRequest.algorithm ?? defaultAlgorithmSettings.ALGORITHM;
this.strict =
algorithmSettingsRequest.strict ?? defaultAlgorithmSettings.strict;
algorithmSettingsRequest.strict ?? defaultAlgorithmSettings.STRICT;
this.remoteness = algorithmSettingsRequest.remoteness
? Math.abs(algorithmSettingsRequest.remoteness)
: defaultAlgorithmSettings.remoteness;
: defaultAlgorithmSettings.REMOTENESS;
this.useProportion =
algorithmSettingsRequest.useProportion ??
defaultAlgorithmSettings.useProportion;
defaultAlgorithmSettings.USE_PROPORTION;
this.proportion = algorithmSettingsRequest.proportion
? Math.abs(algorithmSettingsRequest.proportion)
: defaultAlgorithmSettings.proportion;
: defaultAlgorithmSettings.PROPORTION;
this.useAzimuth =
algorithmSettingsRequest.useAzimuth ??
defaultAlgorithmSettings.useAzimuth;
defaultAlgorithmSettings.USE_AZIMUTH;
this.azimuthMargin = algorithmSettingsRequest.azimuthMargin
? Math.abs(algorithmSettingsRequest.azimuthMargin)
: defaultAlgorithmSettings.azimuthMargin;
: defaultAlgorithmSettings.AZIMUTH_MARGIN;
this.maxDetourDistanceRatio =
algorithmSettingsRequest.maxDetourDistanceRatio ??
defaultAlgorithmSettings.maxDetourDistanceRatio;
defaultAlgorithmSettings.MAX_DETOUR_DISTANCE_RATIO;
this.maxDetourDurationRatio =
algorithmSettingsRequest.maxDetourDurationRatio ??
defaultAlgorithmSettings.maxDetourDurationRatio;
defaultAlgorithmSettings.MAX_DETOUR_DURATION_RATIO;
this.georouter = georouterCreator.create(
defaultAlgorithmSettings.georouterType,
defaultAlgorithmSettings.georouterUrl,
defaultAlgorithmSettings.GEOROUTER_TYPE,
defaultAlgorithmSettings.GEOROUTER_URL,
);
if (this.strict) {
this.restrict = frequency;

View File

@ -3,39 +3,42 @@ import {
MatcherExceptionCode,
} from '../../../exceptions/matcher.exception';
import { IRequestGeography } from '../../interfaces/geography-request.interface';
import { PointType } from '../../types/geography.enum';
import { Point } from '../../types/point.type';
import { find } from 'geo-tz';
import { Route } from './route';
import { PointType } from '../../../../geography/domain/types/point-type.enum';
import { Point } from '../../../../geography/domain/types/point.type';
import { MatcherRoute } from './matcher-route';
import { Role } from '../../types/role.enum';
import { IGeorouter } from '../../interfaces/georouter.interface';
import { Waypoint } from './waypoint';
import { Actor } from './actor';
import { Person } from './person';
import { Ad } from './ad';
import { Step } from '../../types/step.enum';
import { Path } from '../../types/path.type';
import { IFindTimezone } from '../../../../geography/domain/interfaces/timezone-finder.interface';
import { Timezoner } from './timezoner';
export class Geography {
private geographyRequest: IRequestGeography;
private person: Person;
private points: Array<Point>;
private ad: Ad;
private points: Point[];
originType: PointType;
destinationType: PointType;
timezones: Array<string>;
driverRoute: Route;
passengerRoute: Route;
timezones: string[];
driverRoute: MatcherRoute;
passengerRoute: MatcherRoute;
timezoneFinder: IFindTimezone;
constructor(
geographyRequest: IRequestGeography,
defaultTimezone: string,
person: Person,
timezoner: Timezoner,
ad: Ad,
) {
this.geographyRequest = geographyRequest;
this.person = person;
this.ad = ad;
this.points = [];
this.originType = undefined;
this.destinationType = undefined;
this.timezones = [defaultTimezone];
this.timezones = [timezoner.timezone];
this.timezoneFinder = timezoner.finder;
}
init = (): void => {
@ -45,12 +48,12 @@ export class Geography {
};
createRoutes = async (
roles: Array<Role>,
roles: Role[],
georouter: IGeorouter,
): Promise<void> => {
let driverWaypoints: Array<Waypoint> = [];
let passengerWaypoints: Array<Waypoint> = [];
const paths: Array<Path> = [];
let driverWaypoints: Waypoint[] = [];
let passengerWaypoints: Waypoint[] = [];
const paths: Path[] = [];
if (roles.includes(Role.DRIVER) && roles.includes(Role.PASSENGER)) {
if (this.points.length == 2) {
// 2 points => same route for driver and passenger
@ -147,7 +150,7 @@ export class Geography {
};
private setTimezones = (): void => {
this.timezones = find(
this.timezones = this.timezoneFinder.timezones(
this.geographyRequest.waypoints[0].lat,
this.geographyRequest.waypoints[0].lon,
);
@ -171,18 +174,15 @@ export class Geography {
private isValidLatitude = (latitude: number): boolean =>
latitude >= -90 && latitude <= 90;
private createWaypoints = (
points: Array<Point>,
role: Role,
): Array<Waypoint> => {
private createWaypoints = (points: Point[], role: Role): Waypoint[] => {
return points.map((point, index) => {
const waypoint = new Waypoint(point);
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) {
waypoint.addActor(new Actor(this.person, role, Step.FINISH));
waypoint.addActor(new Actor(this.ad, role, Step.FINISH));
} else {
waypoint.addActor(new Actor(this.person, role, Step.INTERMEDIATE));
waypoint.addActor(new Actor(this.ad, role, Step.INTERMEDIATE));
}
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 = {
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 {
point: Array<number>;
coordinate: Coordinate;
duration: number;
distance: number;
constructor(point: Array<number>, duration: number, distance: number) {
this.point = point;
constructor(coordinate: Coordinate, duration: number, distance: number) {
this.coordinate = coordinate;
this.duration = duration;
this.distance = distance;
}

View File

@ -4,27 +4,31 @@ import {
} from '../../../exceptions/matcher.exception';
import { MarginDurations } from '../../types/margin-durations.type';
import { IRequestTime } from '../../interfaces/time-request.interface';
import { TimingDays, TimingFrequency, Days } from '../../types/timing';
import { Schedule } from '../../types/schedule.type';
import { DAYS } from '../../types/days.const';
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 {
private timeRequest: IRequestTime;
private defaultMarginDuration: number;
private defaultValidityDuration: number;
frequency: TimingFrequency;
private timeConverter: IConvertTime;
frequency: Frequency;
fromDate: Date;
toDate: Date;
schedule: Schedule;
schedule: TimeSchedule;
marginDurations: MarginDurations;
constructor(
timeRequest: IRequestTime,
defaultMarginDuration: number,
defaultValidityDuration: number,
timeConverter: IConvertTime,
) {
this.timeRequest = timeRequest;
this.defaultMarginDuration = defaultMarginDuration;
this.defaultValidityDuration = defaultValidityDuration;
this.timeConverter = timeConverter;
this.schedule = {};
this.marginDurations = {
mon: defaultMarginDuration,
@ -106,7 +110,7 @@ export class Time {
}
if (
!Object.keys(this.timeRequest.schedule).some((elem) =>
Days.includes(elem),
DAYS.includes(elem),
)
) {
throw new MatcherException(
@ -127,15 +131,17 @@ export class Time {
private setPunctualRequest = (): void => {
if (this.timeRequest.departure) {
this.frequency = TimingFrequency.FREQUENCY_PUNCTUAL;
this.schedule[TimingDays[this.fromDate.getDay()]] =
this.fromDate.getHours() + ':' + this.fromDate.getMinutes();
this.frequency = Frequency.PUNCTUAL;
this.schedule[Day[this.fromDate.getDay()]] = this.timeConverter.toUtcDate(
this.fromDate,
this.timeRequest.timezone,
);
}
};
private setRecurrentRequest = (): void => {
if (this.timeRequest.fromDate) {
this.frequency = TimingFrequency.FREQUENCY_RECURRENT;
this.frequency = Frequency.RECURRENT;
if (!this.toDate) {
this.toDate = this.addDays(this.fromDate, this.defaultValidityDuration);
}
@ -145,7 +151,14 @@ export class Time {
private setSchedule = (): void => {
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 (
!Object.keys(this.timeRequest.marginDurations).some((elem) =>
Days.includes(elem),
DAYS.includes(elem),
)
) {
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';
export class Waypoint {
point: Point;
actors: Array<Actor>;
actors: Actor[];
constructor(point: Point) {
this.point = point;

View File

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

View File

@ -5,7 +5,7 @@ import { Selector } from '../selector/selector.abstract';
export abstract class AlgorithmFactory {
protected matchQuery: MatchQuery;
private candidates: Array<Candidate>;
private candidates: Candidate[];
constructor(matchQuery: MatchQuery) {
this.matchQuery = matchQuery;
@ -13,5 +13,5 @@ export abstract class AlgorithmFactory {
}
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 {
createSelector = (): Selector => new ClassicSelector(this.matchQuery);
createProcessors = (): Array<Processor> => [
createProcessors = (): Processor[] => [
new ClassicWaypointsCompleter(this.matchQuery),
new RouteCompleter(this.matchQuery, true, true, true),
new ClassicGeoFilter(this.matchQuery),

View File

@ -11,10 +11,10 @@ export class Matcher {
private readonly algorithmFactoryCreator: AlgorithmFactoryCreator,
) {}
match = async (matchQuery: MatchQuery): Promise<Array<Match>> => {
match = async (matchQuery: MatchQuery): Promise<Match[]> => {
const algorithmFactory: AlgorithmFactory =
this.algorithmFactoryCreator.create(matchQuery);
let candidates: Array<Candidate> = await algorithmFactory
let candidates: Candidate[] = await algorithmFactory
.createSelector()
.select();
for (const processor of algorithmFactory.createProcessors()) {

View File

@ -2,7 +2,7 @@ import { Candidate } from '../../candidate';
import { Completer } from './completer.abstract';
export class ClassicWaypointsCompleter extends Completer {
complete = (candidates: Array<Candidate>): Array<Candidate> => {
complete = (candidates: Candidate[]): Candidate[] => {
return candidates;
};
}

View File

@ -2,8 +2,7 @@ import { Candidate } from '../../candidate';
import { Processor } from '../processor.abstract';
export abstract class Completer extends Processor {
execute = (candidates: Array<Candidate>): Array<Candidate> =>
this.complete(candidates);
execute = (candidates: Candidate[]): Candidate[] => 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