Merge branch 'DDH' into 'main'

DDH : ad creation

See merge request v3/service/matcher!10
This commit is contained in:
Sylvain Briat 2023-08-24 13:33:54 +00:00
commit bca3374255
233 changed files with 5624 additions and 11322 deletions

View File

@ -4,12 +4,25 @@ SERVICE_PORT=5005
SERVICE_CONFIGURATION_DOMAIN=MATCHER SERVICE_CONFIGURATION_DOMAIN=MATCHER
HEALTH_SERVICE_PORT=6005 HEALTH_SERVICE_PORT=6005
# PRISMA
DATABASE_URL="postgresql://mobicoop:mobicoop@v3-db:5432/mobicoop?schema=matcher"
# MESSAGE BROKER
MESSAGE_BROKER_URI=amqp://v3-broker:5672
MESSAGE_BROKER_EXCHANGE=mobicoop
# REDIS
REDIS_HOST=v3-redis
REDIS_PASSWORD=redis
REDIS_PORT=6379
# CACHE
CACHE_TTL=5000
# DEFAULT CONFIGURATION # DEFAULT CONFIGURATION
# default identifier used for match requests # default identifier used for match requests
DEFAULT_UUID=00000000-0000-0000-0000-000000000000 DEFAULT_UUID=00000000-0000-0000-0000-000000000000
# default timezone
DEFAULT_TIMEZONE=Europe/Paris
# default number of seats proposed as driver # default number of seats proposed as driver
DEFAULT_SEATS=3 DEFAULT_SEATS=3
# algorithm type # algorithm type
@ -41,18 +54,3 @@ MAX_DETOUR_DURATION_RATIO=0.3
GEOROUTER_TYPE=graphhopper GEOROUTER_TYPE=graphhopper
# georouter url # georouter url
GEOROUTER_URL=http://localhost:8989 GEOROUTER_URL=http://localhost:8989
# PRISMA
DATABASE_URL="postgresql://mobicoop:mobicoop@v3-db:5432/mobicoop?schema=matcher"
# RABBIT MQ
RMQ_URI=amqp://v3-broker:5672
RMQ_EXCHANGE=mobicoop
# REDIS
REDIS_HOST=v3-redis
REDIS_PASSWORD=redis
REDIS_PORT=6379
# CACHE
CACHE_TTL=5000

View File

@ -2,12 +2,27 @@
SERVICE_URL=0.0.0.0 SERVICE_URL=0.0.0.0
SERVICE_PORT=5005 SERVICE_PORT=5005
# PRISMA
DATABASE_URL="postgresql://mobicoop:mobicoop@v3-db:5432/mobicoop?schema=public"
# MESSAGE BROKER
MESSAGE_BROKER_URI=amqp://v3-broker:5672
MESSAGE_BROKER_EXCHANGE=mobicoop
# REDIS
REDIS_HOST=v3-redis
REDIS_PASSWORD=redis
REDIS_PORT=6379
# IMAGES
BROKER_IMAGE=rabbitmq:3-alpine
REDIS_IMAGE=redis:7.0-alpine
POSTGRES_IMAGE=postgis/postgis:15-3.3
# DEFAULT CONFIGURATION # DEFAULT CONFIGURATION
# default identifier used for match requests # default identifier used for match requests
DEFAULT_UUID=00000000-0000-0000-0000-000000000000 DEFAULT_UUID=00000000-0000-0000-0000-000000000000
# default timezone
DEFAULT_TIMEZONE=Europe/Paris
# default number of seats proposed as driver # default number of seats proposed as driver
DEFAULT_SEATS=3 DEFAULT_SEATS=3
# algorithm type # algorithm type
@ -41,19 +56,4 @@ GEOROUTER_TYPE=graphhopper
GEOROUTER_URL=http://localhost:8989 GEOROUTER_URL=http://localhost:8989
# PRISMA
DATABASE_URL="postgresql://mobicoop:mobicoop@v3-db:5432/mobicoop?schema=public"
# RABBIT MQ
RMQ_URI=amqp://v3-broker:5672
# REDIS
REDIS_IMAGE=redis:7.0-alpine
REDIS_HOST=v3-redis
REDIS_PASSWORD=redis
# MESSAGE BROKER
BROKER_IMAGE=rabbitmq:3-alpine
# POSTGRES
POSTGRES_IMAGE=postgis/postgis:15-3.3

5074
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{ {
"name": "@mobicoop/matcher", "name": "@mobicoop/matcher",
"version": "0.0.1", "version": "0.0.2",
"description": "Mobicoop V3 Matcher", "description": "Mobicoop V3 Matcher",
"author": "sbriat", "author": "sbriat",
"private": true, "private": true,
@ -17,39 +17,37 @@
"lint:check": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix-dry-run --ignore-path .gitignore", "lint:check": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix-dry-run --ignore-path .gitignore",
"pretty:check": "./node_modules/.bin/prettier --check .", "pretty:check": "./node_modules/.bin/prettier --check .",
"pretty": "./node_modules/.bin/prettier --write .", "pretty": "./node_modules/.bin/prettier --write .",
"test": "npm run migrate:test && dotenv -e .env.test jest", "test": "npm run test:unit && npm run test:integration",
"test:unit": "jest --testPathPattern 'tests/unit/' --verbose", "test:unit": "jest --testPathPattern 'tests/unit/' --verbose",
"test:unit:ci": "jest --testPathPattern 'tests/unit/' --coverage", "test:unit:ci": "jest --testPathPattern 'tests/unit/' --coverage",
"test:integration": "npm run migrate:test && dotenv -e .env.test -- jest --testPathPattern 'tests/integration/' --verbose", "test:integration": "npm run migrate:test && dotenv -e .env.test -- jest --testPathPattern 'tests/integration/' --verbose --runInBand",
"test:integration:ci": "npm run migrate:test:ci && dotenv -e ci/.env.ci -- jest --testPathPattern 'tests/integration/' --verbose", "test:integration:ci": "npm run migrate:test:ci && dotenv -e ci/.env.ci -- jest --testPathPattern 'tests/integration/' --runInBand",
"test:cov": "jest --testPathPattern 'tests/unit/' --coverage", "test:cov": "jest --testPathPattern 'tests/unit/' --coverage",
"test:cov:watch": "jest --testPathPattern 'tests/unit/' --coverage --watch",
"test:e2e": "jest --config ./test/jest-e2e.json", "test:e2e": "jest --config ./test/jest-e2e.json",
"generate": "docker exec v3-matcher-api sh -c 'npx prisma generate'",
"migrate": "docker exec v3-matcher-api sh -c 'npx prisma migrate dev'", "migrate": "docker exec v3-matcher-api sh -c 'npx prisma migrate dev'",
"migrate:test": "dotenv -e .env.test -- npx prisma migrate deploy", "migrate:test": "dotenv -e .env.test -- npx prisma migrate deploy",
"migrate:test:ci": "dotenv -e ci/.env.ci -- npx prisma migrate deploy", "migrate:test:ci": "dotenv -e ci/.env.ci -- npx prisma migrate deploy",
"migrate:deploy": "npx prisma migrate deploy" "migrate:deploy": "npx prisma migrate deploy"
}, },
"dependencies": { "dependencies": {
"@automapper/classes": "^8.7.7", "@grpc/grpc-js": "^1.8.14",
"@automapper/core": "^8.7.7",
"@automapper/nestjs": "^8.7.7",
"@golevelup/nestjs-rabbitmq": "^3.6.0",
"@grpc/grpc-js": "^1.8.13",
"@grpc/proto-loader": "^0.7.6", "@grpc/proto-loader": "^0.7.6",
"@liaoliaots/nestjs-redis": "^9.0.5", "@liaoliaots/nestjs-redis": "^9.0.5",
"@mobicoop/configuration-module": "^1.0.0", "@mobicoop/configuration-module": "^1.2.0",
"@mobicoop/ddd-library": "^1.1.0",
"@mobicoop/health-module": "^2.0.0",
"@mobicoop/message-broker-module": "^1.2.0",
"@nestjs/axios": "^2.0.0", "@nestjs/axios": "^2.0.0",
"@nestjs/cache-manager": "^1.0.0", "@nestjs/cache-manager": "^1.0.0",
"@nestjs/common": "^9.0.0", "@nestjs/common": "^9.0.0",
"@nestjs/config": "^2.3.1", "@nestjs/config": "^2.3.1",
"@nestjs/core": "^9.0.0", "@nestjs/core": "^9.0.0",
"@nestjs/cqrs": "^9.0.3", "@nestjs/cqrs": "^9.0.3",
"@nestjs/event-emitter": "^1.4.2",
"@nestjs/microservices": "^9.4.0", "@nestjs/microservices": "^9.4.0",
"@nestjs/platform-express": "^9.0.0", "@nestjs/platform-express": "^9.0.0",
"@nestjs/terminus": "^9.2.2", "@nestjs/terminus": "^9.2.2",
"@prisma/client": "^4.12.0", "@prisma/client": "^4.13.0",
"axios": "^1.3.5", "axios": "^1.3.5",
"cache-manager": "^5.2.0", "cache-manager": "^5.2.0",
"cache-manager-ioredis-yet": "^1.1.0", "cache-manager-ioredis-yet": "^1.1.0",
@ -58,7 +56,8 @@
"geo-tz": "^7.0.7", "geo-tz": "^7.0.7",
"geographiclib-geodesic": "^2.0.0", "geographiclib-geodesic": "^2.0.0",
"got": "^11.8.6", "got": "^11.8.6",
"ioredis": "^5.3.1", "ioredis": "^5.3.2",
"nestjs-request-context": "^2.1.0",
"reflect-metadata": "^0.1.13", "reflect-metadata": "^0.1.13",
"rxjs": "^7.2.0", "rxjs": "^7.2.0",
"timezonecomplete": "^5.12.4" "timezonecomplete": "^5.12.4"
@ -71,6 +70,7 @@
"@types/jest": "29.5.0", "@types/jest": "29.5.0",
"@types/node": "18.15.11", "@types/node": "18.15.11",
"@types/supertest": "^2.0.11", "@types/supertest": "^2.0.11",
"@types/uuid": "^9.0.2",
"@typescript-eslint/eslint-plugin": "^5.0.0", "@typescript-eslint/eslint-plugin": "^5.0.0",
"@typescript-eslint/parser": "^5.0.0", "@typescript-eslint/parser": "^5.0.0",
"dotenv-cli": "^7.2.1", "dotenv-cli": "^7.2.1",
@ -79,7 +79,7 @@
"eslint-plugin-prettier": "^4.0.0", "eslint-plugin-prettier": "^4.0.0",
"jest": "29.5.0", "jest": "29.5.0",
"prettier": "^2.3.2", "prettier": "^2.3.2",
"prisma": "^4.12.0", "prisma": "^4.13.0",
"source-map-support": "^0.5.20", "source-map-support": "^0.5.20",
"supertest": "^6.1.3", "supertest": "^6.1.3",
"ts-jest": "29.0.5", "ts-jest": "29.0.5",
@ -95,15 +95,13 @@
"ts" "ts"
], ],
"modulePathIgnorePatterns": [ "modulePathIgnorePatterns": [
".controller.ts",
".module.ts", ".module.ts",
".request.ts", ".dto.ts",
".presenter.ts", ".di-tokens.ts",
".profile.ts", ".response.ts",
".exception.ts", ".port.ts",
".enum.ts", "prisma.service.ts",
"main.ts", "main.ts"
"prisma-service.ts"
], ],
"rootDir": "src", "rootDir": "src",
"testRegex": ".*\\.spec\\.ts$", "testRegex": ".*\\.spec\\.ts$",
@ -114,17 +112,19 @@
"**/*.(t|j)s" "**/*.(t|j)s"
], ],
"coveragePathIgnorePatterns": [ "coveragePathIgnorePatterns": [
".controller.ts",
".module.ts", ".module.ts",
".request.ts", ".dto.ts",
".presenter.ts", ".di-tokens.ts",
".profile.ts", ".response.ts",
".exception.ts", ".port.ts",
".enum.ts", "prisma.service.ts",
"main.ts", "main.ts"
"prisma-service.ts"
], ],
"coverageDirectory": "../coverage", "coverageDirectory": "../coverage",
"moduleNameMapper": {
"^@modules(.*)": "<rootDir>/modules/$1",
"^@src(.*)": "<rootDir>$1"
},
"testEnvironment": "node" "testEnvironment": "node"
} }
} }

View File

@ -11,26 +11,14 @@ CREATE TYPE "Frequency" AS ENUM ('PUNCTUAL', 'RECURRENT');
-- CreateTable -- CreateTable
CREATE TABLE "ad" ( CREATE TABLE "ad" (
"uuid" UUID NOT NULL, "uuid" UUID NOT NULL,
"userUuid" UUID NOT NULL,
"driver" BOOLEAN NOT NULL, "driver" BOOLEAN NOT NULL,
"passenger" BOOLEAN NOT NULL, "passenger" BOOLEAN NOT NULL,
"frequency" "Frequency" NOT NULL, "frequency" "Frequency" NOT NULL,
"fromDate" DATE NOT NULL, "fromDate" DATE NOT NULL,
"toDate" DATE NOT NULL, "toDate" DATE NOT NULL,
"monTime" TIMESTAMPTZ, "seatsProposed" SMALLINT NOT NULL,
"tueTime" TIMESTAMPTZ, "seatsRequested" SMALLINT NOT NULL,
"wedTime" TIMESTAMPTZ, "strict" BOOLEAN NOT NULL,
"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, "driverDuration" INTEGER,
"driverDistance" INTEGER, "driverDistance" INTEGER,
"passengerDuration" INTEGER, "passengerDuration" INTEGER,
@ -39,16 +27,25 @@ CREATE TABLE "ad" (
"direction" geography(LINESTRING), "direction" geography(LINESTRING),
"fwdAzimuth" INTEGER NOT NULL, "fwdAzimuth" INTEGER NOT NULL,
"backAzimuth" 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, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ad_pkey" PRIMARY KEY ("uuid") CONSTRAINT "ad_pkey" PRIMARY KEY ("uuid")
); );
-- CreateTable
CREATE TABLE "schedule_item" (
"uuid" UUID NOT NULL,
"adUuid" UUID NOT NULL,
"day" INTEGER NOT NULL,
"time" TIME(4) NOT NULL,
"margin" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "schedule_item_pkey" PRIMARY KEY ("uuid")
);
-- CreateIndex -- CreateIndex
CREATE INDEX "ad_driver_idx" ON "ad"("driver"); CREATE INDEX "ad_driver_idx" ON "ad"("driver");
@ -66,3 +63,6 @@ CREATE INDEX "ad_fwdAzimuth_idx" ON "ad"("fwdAzimuth");
-- CreateIndex -- CreateIndex
CREATE INDEX "direction_idx" ON "ad" USING GIST ("direction"); CREATE INDEX "direction_idx" ON "ad" USING GIST ("direction");
-- AddForeignKey
ALTER TABLE "schedule_item" ADD CONSTRAINT "schedule_item_adUuid_fkey" FOREIGN KEY ("adUuid") REFERENCES "ad"("uuid") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -15,26 +15,15 @@ datasource db {
model Ad { model Ad {
uuid String @id @db.Uuid uuid String @id @db.Uuid
userUuid String @db.Uuid
driver Boolean driver Boolean
passenger Boolean passenger Boolean
frequency Frequency frequency Frequency
fromDate DateTime @db.Date fromDate DateTime @db.Date
toDate DateTime @db.Date toDate DateTime @db.Date
monTime DateTime? @db.Timestamptz() schedule ScheduleItem[]
tueTime DateTime? @db.Timestamptz() seatsProposed Int @db.SmallInt
wedTime DateTime? @db.Timestamptz() seatsRequested Int @db.SmallInt
thuTime DateTime? @db.Timestamptz() strict Boolean
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? driverDuration Int?
driverDistance Int? driverDistance Int?
passengerDuration Int? passengerDuration Int?
@ -43,10 +32,6 @@ model Ad {
direction Unsupported("geography(LINESTRING)")? direction Unsupported("geography(LINESTRING)")?
fwdAzimuth Int fwdAzimuth Int
backAzimuth Int backAzimuth Int
seatsDriver Int @db.SmallInt
seatsPassenger Int @db.SmallInt
seatsUsed Int @db.SmallInt
strict Boolean
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt updatedAt DateTime @default(now()) @updatedAt
@ -59,6 +44,19 @@ model Ad {
@@map("ad") @@map("ad")
} }
model ScheduleItem {
uuid String @id @default(uuid()) @db.Uuid
adUuid String @db.Uuid
day Int
time DateTime @db.Time(4)
margin Int
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
Ad Ad @relation(fields: [adUuid], references: [uuid], onDelete: Cascade)
@@map("schedule_item")
}
enum Frequency { enum Frequency {
PUNCTUAL PUNCTUAL
RECURRENT RECURRENT

View File

@ -1,32 +1,68 @@
import { classes } from '@automapper/classes';
import { AutomapperModule } from '@automapper/nestjs';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config'; import { ConfigModule, ConfigService } from '@nestjs/config';
import { HealthModule } from './modules/health/health.module';
import { MatcherModule } from './modules/matcher/matcher.module';
import { AdModule } from './modules/ad/ad.module'; import { AdModule } from './modules/ad/ad.module';
import { ConfigurationModule } from '@mobicoop/configuration-module'; import {
ConfigurationModule,
ConfigurationModuleOptions,
} from '@mobicoop/configuration-module';
import { EventEmitterModule } from '@nestjs/event-emitter';
import { RequestContextModule } from 'nestjs-request-context';
import { MessagerModule } from '@modules/messager/messager.module';
import { HealthModule, HealthRepositoryPort } from '@mobicoop/health-module';
import { AD_REPOSITORY } from '@modules/ad/ad.di-tokens';
import { MESSAGE_PUBLISHER } from '@modules/messager/messager.di-tokens';
import { HealthModuleOptions } from '@mobicoop/health-module/dist/core/domain/types/health.types';
import { MessagePublisherPort } from '@mobicoop/ddd-library';
import { GeographyModule } from '@modules/geography/geography.module';
@Module({ @Module({
imports: [ imports: [
ConfigModule.forRoot({ isGlobal: true }), ConfigModule.forRoot({ isGlobal: true }),
EventEmitterModule.forRoot(),
RequestContextModule,
ConfigurationModule.forRootAsync({ ConfigurationModule.forRootAsync({
setConfigurationBrokerRoutingKeys: [ imports: [ConfigModule],
'configuration.create', inject: [ConfigService],
'configuration.update', useFactory: async (
], configService: ConfigService,
deleteConfigurationRoutingKey: 'configuration.delete', ): Promise<ConfigurationModuleOptions> => ({
propagateConfigurationRoutingKey: 'configuration.propagate', domain: configService.get<string>('SERVICE_CONFIGURATION_DOMAIN'),
setConfigurationBrokerQueue: 'matcher-configuration-create-update', messageBroker: {
deleteConfigurationQueue: 'matcher-configuration-delete', uri: configService.get<string>('MESSAGE_BROKER_URI'),
propagateConfigurationQueue: 'matcher-configuration-propagate', exchange: configService.get<string>('MESSAGE_BROKER_EXCHANGE'),
},
redis: {
host: configService.get<string>('REDIS_HOST'),
password: configService.get<string>('REDIS_PASSWORD'),
port: configService.get<number>('REDIS_PORT'),
},
setConfigurationBrokerQueue: 'matcher-configuration-create-update',
deleteConfigurationQueue: 'matcher-configuration-delete',
propagateConfigurationQueue: 'matcher-configuration-propagate',
}),
}),
HealthModule.forRootAsync({
imports: [AdModule, MessagerModule],
inject: [AD_REPOSITORY, MESSAGE_PUBLISHER],
useFactory: async (
adRepository: HealthRepositoryPort,
messagePublisher: MessagePublisherPort,
): Promise<HealthModuleOptions> => ({
serviceName: 'matcher',
criticalLoggingKey: 'logging.matcher.health.crit',
checkRepositories: [
{
name: 'AdRepository',
repository: adRepository,
},
],
messagePublisher,
}),
}), }),
AutomapperModule.forRoot({ strategyInitializer: classes() }),
HealthModule,
MatcherModule,
AdModule, AdModule,
GeographyModule,
MessagerModule,
], ],
controllers: [], exports: [AdModule, GeographyModule, MessagerModule],
providers: [],
}) })
export class AppModule {} export class AppModule {}

View File

@ -2,7 +2,6 @@ syntax = "proto3";
package health; package health;
service Health { service Health {
rpc Check(HealthCheckRequest) returns (HealthCheckResponse); rpc Check(HealthCheckRequest) returns (HealthCheckResponse);
} }
@ -18,4 +17,5 @@ message HealthCheckResponse {
NOT_SERVING = 2; NOT_SERVING = 2;
} }
ServingStatus status = 1; ServingStatus status = 1;
string message = 2;
} }

View File

@ -11,11 +11,8 @@ async function bootstrap() {
app.connectMicroservice<MicroserviceOptions>({ app.connectMicroservice<MicroserviceOptions>({
transport: Transport.GRPC, transport: Transport.GRPC,
options: { options: {
package: ['matcher', 'health'], package: ['health'],
protoPath: [ protoPath: [join(__dirname, 'health.proto')],
join(__dirname, 'modules/matcher/adapters/primaries/matcher.proto'),
join(__dirname, 'modules/health/adapters/primaries/health.proto'),
],
url: process.env.SERVICE_URL + ':' + process.env.SERVICE_PORT, url: process.env.SERVICE_URL + ':' + process.env.SERVICE_PORT,
loader: { keepCase: true }, loader: { keepCase: true },
}, },

View File

@ -0,0 +1,7 @@
export const AD_REPOSITORY = Symbol('AD_REPOSITORY');
export const AD_DIRECTION_ENCODER = Symbol('AD_DIRECTION_ENCODER');
export const AD_MESSAGE_PUBLISHER = Symbol('AD_MESSAGE_PUBLISHER');
export const AD_GET_BASIC_ROUTE_CONTROLLER = Symbol(
'AD_GET_BASIC_ROUTE_CONTROLLER',
);
export const AD_ROUTE_PROVIDER = Symbol('AD_ROUTE_PROVIDER');

132
src/modules/ad/ad.mapper.ts Normal file
View File

@ -0,0 +1,132 @@
import { Inject, Injectable } from '@nestjs/common';
import { AdEntity } from './core/domain/ad.entity';
import {
AdWriteModel,
AdReadModel,
ScheduleItemModel,
AdUnsupportedWriteModel,
} from './infrastructure/ad.repository';
import { Frequency } from './core/domain/ad.types';
import { v4 } from 'uuid';
import { ScheduleItemProps } from './core/domain/value-objects/schedule-item.value-object';
import { DirectionEncoderPort } from '@modules/geography/core/application/ports/direction-encoder.port';
import { AD_DIRECTION_ENCODER } from './ad.di-tokens';
import { ExtendedMapper } from '@mobicoop/ddd-library';
/**
* Mapper constructs objects that are used in different layers:
* Record is an object that is stored in a database,
* Entity is an object that is used in application domain layer,
* and a ResponseDTO is an object returned to a user (usually as json).
*/
@Injectable()
export class AdMapper
implements
ExtendedMapper<
AdEntity,
AdReadModel,
AdWriteModel,
AdUnsupportedWriteModel,
undefined
>
{
constructor(
@Inject(AD_DIRECTION_ENCODER)
private readonly directionEncoder: DirectionEncoderPort,
) {}
toPersistence = (entity: AdEntity): AdWriteModel => {
const copy = entity.getProps();
const now = new Date();
const record: AdWriteModel = {
uuid: copy.id,
driver: copy.driver,
passenger: copy.passenger,
frequency: copy.frequency,
fromDate: new Date(copy.fromDate),
toDate: new Date(copy.toDate),
schedule: {
create: copy.schedule.map((scheduleItem: ScheduleItemProps) => ({
uuid: v4(),
day: scheduleItem.day,
time: new Date(
1970,
0,
1,
parseInt(scheduleItem.time.split(':')[0]),
parseInt(scheduleItem.time.split(':')[1]),
),
margin: scheduleItem.margin,
createdAt: now,
updatedAt: now,
})),
},
seatsProposed: copy.seatsProposed,
seatsRequested: copy.seatsRequested,
strict: copy.strict,
driverDuration: copy.driverDuration,
driverDistance: copy.driverDistance,
passengerDuration: copy.passengerDuration,
passengerDistance: copy.passengerDistance,
fwdAzimuth: copy.fwdAzimuth,
backAzimuth: copy.backAzimuth,
createdAt: copy.createdAt,
updatedAt: copy.updatedAt,
};
return record;
};
toDomain = (record: AdReadModel): AdEntity => {
const entity = new AdEntity({
id: record.uuid,
createdAt: new Date(record.createdAt),
updatedAt: new Date(record.updatedAt),
props: {
driver: record.driver,
passenger: record.passenger,
frequency: Frequency[record.frequency],
fromDate: record.fromDate.toISOString().split('T')[0],
toDate: record.toDate.toISOString().split('T')[0],
schedule: record.schedule.map((scheduleItem: ScheduleItemModel) => ({
day: scheduleItem.day,
time: `${scheduleItem.time
.getUTCHours()
.toString()
.padStart(2, '0')}:${scheduleItem.time
.getUTCMinutes()
.toString()
.padStart(2, '0')}`,
margin: scheduleItem.margin,
})),
seatsProposed: record.seatsProposed,
seatsRequested: record.seatsRequested,
strict: record.strict,
driverDuration: record.driverDuration,
driverDistance: record.driverDistance,
passengerDuration: record.passengerDuration,
passengerDistance: record.passengerDistance,
waypoints: this.directionEncoder
.decode(record.waypoints)
.map((coordinates, index) => ({
position: index,
...coordinates,
})),
fwdAzimuth: record.fwdAzimuth,
backAzimuth: record.backAzimuth,
points: [],
},
});
return entity;
};
// eslint-disable-next-line @typescript-eslint/no-unused-vars
toResponse = (entity: AdEntity): undefined => {
return undefined;
};
toUnsupportedPersistence = (entity: AdEntity): AdUnsupportedWriteModel => ({
waypoints: this.directionEncoder.encode(entity.getProps().waypoints),
direction: this.directionEncoder.encode(entity.getProps().points),
});
}

View File

@ -1,72 +1,71 @@
import { RabbitMQModule } from '@golevelup/nestjs-rabbitmq'; import { Module, Provider } from '@nestjs/common';
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 { CqrsModule } from '@nestjs/cqrs';
import { Messager } from './adapters/secondaries/messager'; import {
import { GeoTimezoneFinder } from '../geography/adapters/secondaries/geo-timezone-finder'; AD_MESSAGE_PUBLISHER,
import { DefaultParamsProvider } from './adapters/secondaries/default-params.provider'; AD_REPOSITORY,
import { GeorouterCreator } from '../geography/adapters/secondaries/georouter-creator'; AD_DIRECTION_ENCODER,
import { GeographyModule } from '../geography/geography.module'; AD_ROUTE_PROVIDER,
import { HttpModule } from '@nestjs/axios'; AD_GET_BASIC_ROUTE_CONTROLLER,
import { PostgresDirectionEncoder } from '../geography/adapters/secondaries/postgres-direction-encoder'; } from './ad.di-tokens';
import { MessageBrokerPublisher } from '@mobicoop/message-broker-module';
import { AdRepository } from './infrastructure/ad.repository';
import { PrismaService } from './infrastructure/prisma.service';
import { AdMapper } from './ad.mapper';
import { AdCreatedMessageHandler } from './interface/message-handlers/ad-created.message-handler';
import { PostgresDirectionEncoder } from '@modules/geography/infrastructure/postgres-direction-encoder';
import { GetBasicRouteController } from '@modules/geography/interface/controllers/get-basic-route.controller';
import { RouteProvider } from './infrastructure/route-provider';
import { GeographyModule } from '@modules/geography/geography.module';
import { CreateAdService } from './core/application/commands/create-ad/create-ad.service';
const messageHandlers = [AdCreatedMessageHandler];
const commandHandlers: Provider[] = [CreateAdService];
const mappers: Provider[] = [AdMapper];
const repositories: Provider[] = [
{
provide: AD_REPOSITORY,
useClass: AdRepository,
},
];
const messagePublishers: Provider[] = [
{
provide: AD_MESSAGE_PUBLISHER,
useExisting: MessageBrokerPublisher,
},
];
const orms: Provider[] = [PrismaService];
const adapters: Provider[] = [
{
provide: AD_DIRECTION_ENCODER,
useClass: PostgresDirectionEncoder,
},
{
provide: AD_ROUTE_PROVIDER,
useClass: RouteProvider,
},
{
provide: AD_GET_BASIC_ROUTE_CONTROLLER,
useClass: GetBasicRouteController,
},
];
@Module({ @Module({
imports: [ imports: [CqrsModule, GeographyModule],
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: [ providers: [
{ ...messageHandlers,
provide: 'ParamsProvider', ...commandHandlers,
useClass: DefaultParamsProvider, ...mappers,
}, ...repositories,
{ ...messagePublishers,
provide: 'GeorouterCreator', ...orms,
useClass: GeorouterCreator, ...adapters,
},
{
provide: 'TimezoneFinder',
useClass: GeoTimezoneFinder,
},
{
provide: 'DirectionEncoder',
useClass: PostgresDirectionEncoder,
},
AdProfile,
Messager,
AdRepository,
CreateAdUseCase,
], ],
exports: [], exports: [PrismaService, AdMapper, AD_REPOSITORY, AD_DIRECTION_ENCODER],
}) })
export class AdModule {} export class AdModule {}

View File

@ -1,81 +0,0 @@
import { RabbitSubscribe } from '@golevelup/nestjs-rabbitmq';
import { Controller } from '@nestjs/common';
import { CommandBus, QueryBus } 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';
import { DatabaseException } from 'src/modules/database/exceptions/database.exception';
import { ExceptionCode } from 'src/modules/utils/exception-code.enum';
@Controller()
export class AdMessagerController {
constructor(
private readonly messager: Messager,
private readonly commandBus: CommandBus,
private readonly queryBus: QueryBus,
) {}
@RabbitSubscribe({
name: 'adCreated',
})
async adCreatedHandler(message: string): Promise<void> {
let createAdRequest: CreateAdRequest;
// parse message to request instance
try {
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;
}
}
} catch (e) {
this.messager.publish(
'matcher.ad.crit',
JSON.stringify({
message: `Can't validate message : ${message}`,
error: e,
}),
);
}
try {
await this.commandBus.execute(new CreateAdCommand(createAdRequest));
} catch (e) {
if (e instanceof DatabaseException) {
if (e.message.includes('already exists')) {
this.messager.publish(
'matcher.ad.crit',
JSON.stringify({
code: ExceptionCode.ALREADY_EXISTS,
message: 'Already exists',
uuid: createAdRequest.uuid,
}),
);
}
if (e.message.includes("Can't reach database server")) {
this.messager.publish(
'matcher.ad.crit',
JSON.stringify({
code: ExceptionCode.UNAVAILABLE,
message: 'Database server unavailable',
uuid: createAdRequest.uuid,
}),
);
}
}
this.messager.publish(
'logging.matcher.ad.crit',
JSON.stringify({
message,
error: e,
}),
);
}
}
}

View File

@ -1,129 +0,0 @@
import { Injectable } from '@nestjs/common';
import { DatabaseRepository } from '../../../database/domain/database.repository';
import { Ad } from '../../domain/entities/ad';
import { DatabaseException } from '../../../database/exceptions/database.exception';
@Injectable()
export class AdRepository extends DatabaseRepository<Ad> {
protected model = 'ad';
createAd = async (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> => ({
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

@ -1,17 +0,0 @@
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

@ -1,12 +0,0 @@
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

@ -1,18 +0,0 @@
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

@ -1,11 +0,0 @@
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

@ -1,9 +0,0 @@
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,33 @@
import { Frequency } from '@modules/ad/core/domain/ad.types';
import { Command, CommandProps } from '@mobicoop/ddd-library';
import { ScheduleItem } from '../../types/schedule-item.type';
import { Waypoint } from '../../types/waypoint.type';
export class CreateAdCommand extends Command {
readonly id: string;
readonly driver: boolean;
readonly passenger: boolean;
readonly frequency: Frequency;
readonly fromDate: string;
readonly toDate: string;
readonly schedule: ScheduleItem[];
readonly seatsProposed: number;
readonly seatsRequested: number;
readonly strict: boolean;
readonly waypoints: Waypoint[];
constructor(props: CommandProps<CreateAdCommand>) {
super(props);
this.id = props.id;
this.driver = props.driver;
this.passenger = props.passenger;
this.frequency = props.frequency;
this.fromDate = props.fromDate;
this.toDate = props.toDate;
this.schedule = props.schedule;
this.seatsProposed = props.seatsProposed;
this.seatsRequested = props.seatsRequested;
this.strict = props.strict;
this.waypoints = props.waypoints;
}
}

View File

@ -0,0 +1,61 @@
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import { CreateAdCommand } from './create-ad.command';
import { Inject } from '@nestjs/common';
import { AD_REPOSITORY, AD_ROUTE_PROVIDER } from '@modules/ad/ad.di-tokens';
import { AdEntity } from '@modules/ad/core/domain/ad.entity';
import { AdRepositoryPort } from '../../ports/ad.repository.port';
import { AggregateID, ConflictException } from '@mobicoop/ddd-library';
import { AdAlreadyExistsException } from '@modules/ad/core/domain/ad.errors';
import { RouteProviderPort } from '../../ports/route-provider.port';
import { Role } from '@modules/ad/core/domain/ad.types';
import { Route } from '../../types/route.type';
@CommandHandler(CreateAdCommand)
export class CreateAdService implements ICommandHandler {
constructor(
@Inject(AD_REPOSITORY)
private readonly repository: AdRepositoryPort,
@Inject(AD_ROUTE_PROVIDER)
private readonly routeProvider: RouteProviderPort,
) {}
async execute(command: CreateAdCommand): Promise<AggregateID> {
const roles: Role[] = [];
if (command.driver) roles.push(Role.DRIVER);
if (command.passenger) roles.push(Role.PASSENGER);
const route: Route = await this.routeProvider.getBasic(
roles,
command.waypoints,
);
const ad = AdEntity.create({
id: command.id,
driver: command.driver,
passenger: command.passenger,
frequency: command.frequency,
fromDate: command.fromDate,
toDate: command.toDate,
schedule: command.schedule,
seatsProposed: command.seatsProposed,
seatsRequested: command.seatsRequested,
strict: command.strict,
waypoints: command.waypoints,
points: route.points,
driverDistance: route.driverDistance,
driverDuration: route.driverDuration,
passengerDistance: route.passengerDistance,
passengerDuration: route.passengerDuration,
fwdAzimuth: route.fwdAzimuth,
backAzimuth: route.backAzimuth,
});
try {
await this.repository.insertWithUnsupportedFields(ad, 'ad');
return ad.id;
} catch (error: any) {
if (error instanceof ConflictException) {
throw new AdAlreadyExistsException(error);
}
throw error;
}
}
}

View File

@ -0,0 +1,4 @@
import { ExtendedRepositoryPort } from '@mobicoop/ddd-library';
import { AdEntity } from '../../domain/ad.entity';
export type AdRepositoryPort = ExtendedRepositoryPort<AdEntity>;

View File

@ -0,0 +1,10 @@
import { Role } from '../../domain/ad.types';
import { Waypoint } from '../types/waypoint.type';
import { Route } from '../types/route.type';
export interface RouteProviderPort {
/**
* Get a basic route with points and overall duration / distance
*/
getBasic(roles: Role[], waypoints: Waypoint[]): Promise<Route>;
}

View File

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

View File

@ -0,0 +1,11 @@
import { Coordinates } from './coordinates.type';
export type Route = {
driverDistance?: number;
driverDuration?: number;
passengerDistance?: number;
passengerDuration?: number;
fwdAzimuth: number;
backAzimuth: number;
points: Coordinates[];
};

View File

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

View File

@ -0,0 +1,5 @@
import { Coordinates } from './coordinates.type';
export type Waypoint = {
position: number;
} & Coordinates;

View File

@ -0,0 +1,15 @@
import { AggregateRoot, AggregateID } from '@mobicoop/ddd-library';
import { AdProps, CreateAdProps } from './ad.types';
export class AdEntity extends AggregateRoot<AdProps> {
protected readonly _id: AggregateID;
static create = (create: CreateAdProps): AdEntity => {
const props: AdProps = { ...create };
return new AdEntity({ id: create.id, props });
};
validate(): void {
// entity business rules validation to protect it's invariant before saving entity to a database
}
}

View File

@ -0,0 +1,11 @@
import { ExceptionBase } from '@mobicoop/ddd-library';
export class AdAlreadyExistsException extends ExceptionBase {
static readonly message = 'Ad already exists';
public readonly code = 'AD.ALREADY_EXISTS';
constructor(cause?: Error, metadata?: unknown) {
super(AdAlreadyExistsException.message, cause, metadata);
}
}

View File

@ -0,0 +1,56 @@
import { PointProps } from './value-objects/point.value-object';
import { ScheduleItemProps } from './value-objects/schedule-item.value-object';
import { WaypointProps } from './value-objects/waypoint.value-object';
// All properties that an Ad has
export interface AdProps {
driver: boolean;
passenger: boolean;
frequency: Frequency;
fromDate: string;
toDate: string;
schedule: ScheduleItemProps[];
seatsProposed: number;
seatsRequested: number;
strict: boolean;
driverDuration?: number;
driverDistance?: number;
passengerDuration?: number;
passengerDistance?: number;
waypoints: WaypointProps[];
points: PointProps[];
fwdAzimuth: number;
backAzimuth: number;
}
// Properties that are needed for an Ad creation
export interface CreateAdProps {
id: string;
driver: boolean;
passenger: boolean;
frequency: Frequency;
fromDate: string;
toDate: string;
schedule: ScheduleItemProps[];
seatsProposed: number;
seatsRequested: number;
strict: boolean;
waypoints: WaypointProps[];
driverDuration?: number;
driverDistance?: number;
passengerDuration?: number;
passengerDistance?: number;
fwdAzimuth: number;
backAzimuth: number;
points: PointProps[];
}
export enum Frequency {
PUNCTUAL = 'PUNCTUAL',
RECURRENT = 'RECURRENT',
}
export enum Role {
DRIVER = 'DRIVER',
PASSENGER = 'PASSENGER',
}

View File

@ -0,0 +1,31 @@
import {
ArgumentOutOfRangeException,
ValueObject,
} from '@mobicoop/ddd-library';
/** Note:
* Value Objects with multiple properties can contain
* other Value Objects inside if needed.
* */
export interface PointProps {
lon: number;
lat: number;
}
export class Point extends ValueObject<PointProps> {
get lon(): number {
return this.props.lon;
}
get lat(): number {
return this.props.lat;
}
protected validate(props: PointProps): void {
if (props.lon > 180 || props.lon < -180)
throw new ArgumentOutOfRangeException('lon must be between -180 and 180');
if (props.lat > 90 || props.lat < -90)
throw new ArgumentOutOfRangeException('lat must be between -90 and 90');
}
}

View File

@ -0,0 +1,48 @@
import {
ArgumentInvalidException,
ArgumentOutOfRangeException,
ValueObject,
} from '@mobicoop/ddd-library';
/** Note:
* Value Objects with multiple properties can contain
* other Value Objects inside if needed.
* */
export interface ScheduleItemProps {
day: number;
time: string;
margin: number;
}
export class ScheduleItem extends ValueObject<ScheduleItemProps> {
get day(): number | undefined {
return this.props.day;
}
get time(): string {
return this.props.time;
}
get margin(): number | undefined {
return this.props.margin;
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
protected validate(props: ScheduleItemProps): void {
if (props.day < 0 || props.day > 6)
throw new ArgumentOutOfRangeException('day must be between 0 and 6');
if (props.time.split(':').length != 2)
throw new ArgumentInvalidException('time is invalid');
if (
parseInt(props.time.split(':')[0]) < 0 ||
parseInt(props.time.split(':')[0]) > 23
)
throw new ArgumentInvalidException('time is invalid');
if (
parseInt(props.time.split(':')[1]) < 0 ||
parseInt(props.time.split(':')[1]) > 59
)
throw new ArgumentInvalidException('time is invalid');
}
}

View File

@ -0,0 +1,41 @@
import {
ArgumentInvalidException,
ArgumentOutOfRangeException,
ValueObject,
} from '@mobicoop/ddd-library';
/** Note:
* Value Objects with multiple properties can contain
* other Value Objects inside if needed.
* */
export interface WaypointProps {
position: number;
lon: number;
lat: number;
}
export class Waypoint extends ValueObject<WaypointProps> {
get position(): number {
return this.props.position;
}
get lon(): number {
return this.props.lon;
}
get lat(): number {
return this.props.lat;
}
protected validate(props: WaypointProps): void {
if (props.position < 0)
throw new ArgumentInvalidException(
'position must be greater than or equal to 0',
);
if (props.lon > 180 || props.lon < -180)
throw new ArgumentOutOfRangeException('lon must be between -180 and 180');
if (props.lat > 90 || props.lat < -90)
throw new ArgumentOutOfRangeException('lat must be between -90 and 90');
}
}

View File

@ -1,140 +0,0 @@
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

@ -1,32 +0,0 @@
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

@ -1,109 +0,0 @@
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

@ -1,92 +0,0 @@
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

@ -1,19 +0,0 @@
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

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

View File

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

View File

@ -1,144 +0,0 @@
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,89 @@
import { Inject, Injectable, Logger } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { AdRepositoryPort } from '../core/application/ports/ad.repository.port';
import { LoggerBase, MessagePublisherPort } from '@mobicoop/ddd-library';
import { PrismaService } from './prisma.service';
import { AD_MESSAGE_PUBLISHER } from '../ad.di-tokens';
import { AdEntity } from '../core/domain/ad.entity';
import { AdMapper } from '../ad.mapper';
import { ExtendedPrismaRepositoryBase } from '@mobicoop/ddd-library/dist/db/prisma-repository.base';
export type AdBaseModel = {
uuid: string;
driver: boolean;
passenger: boolean;
frequency: string;
fromDate: Date;
toDate: Date;
seatsProposed: number;
seatsRequested: number;
strict: boolean;
driverDuration: number;
driverDistance: number;
passengerDuration: number;
passengerDistance: number;
fwdAzimuth: number;
backAzimuth: number;
createdAt: Date;
updatedAt: Date;
};
export type AdReadModel = AdBaseModel & {
waypoints: string;
direction: string;
schedule: ScheduleItemModel[];
};
export type AdWriteModel = AdBaseModel & {
schedule: {
create: ScheduleItemModel[];
};
};
export type AdUnsupportedWriteModel = {
waypoints: string;
direction: string;
};
export type ScheduleItemModel = {
uuid: string;
day: number;
time: Date;
margin: number;
createdAt: Date;
updatedAt: Date;
};
/**
* Repository is used for retrieving/saving domain entities
* */
@Injectable()
export class AdRepository
extends ExtendedPrismaRepositoryBase<
AdEntity,
AdReadModel,
AdWriteModel,
AdUnsupportedWriteModel
>
implements AdRepositoryPort
{
constructor(
prisma: PrismaService,
mapper: AdMapper,
eventEmitter: EventEmitter2,
@Inject(AD_MESSAGE_PUBLISHER)
protected readonly messagePublisher: MessagePublisherPort,
) {
super(
prisma.ad,
prisma,
mapper,
eventEmitter,
new LoggerBase({
logger: new Logger(AdRepository.name),
domain: 'matcher',
messagePublisher,
}),
);
}
}

View File

@ -0,0 +1,21 @@
import { Inject, Injectable } from '@nestjs/common';
import { RouteProviderPort } from '../core/application/ports/route-provider.port';
import { Route } from '../core/application/types/route.type';
import { Waypoint } from '../core/application/types/waypoint.type';
import { Role } from '../core/domain/ad.types';
import { GetBasicRouteControllerPort } from '@modules/geography/core/application/ports/get-basic-route-controller.port';
import { AD_GET_BASIC_ROUTE_CONTROLLER } from '../ad.di-tokens';
@Injectable()
export class RouteProvider implements RouteProviderPort {
constructor(
@Inject(AD_GET_BASIC_ROUTE_CONTROLLER)
private readonly getBasicRouteController: GetBasicRouteControllerPort,
) {}
getBasic = async (roles: Role[], waypoints: Waypoint[]): Promise<Route> =>
await this.getBasicRouteController.get({
roles,
waypoints,
});
}

View File

@ -0,0 +1,34 @@
import { Injectable } from '@nestjs/common';
import { RabbitSubscribe } from '@mobicoop/message-broker-module';
import { CommandBus } from '@nestjs/cqrs';
import { CreateAdCommand } from '@modules/ad/core/application/commands/create-ad/create-ad.command';
import { Ad } from './ad.types';
@Injectable()
export class AdCreatedMessageHandler {
constructor(private readonly commandBus: CommandBus) {}
@RabbitSubscribe({
name: 'adCreated',
})
public async adCreated(message: string) {
try {
const createdAd: Ad = JSON.parse(message);
await this.commandBus.execute(
new CreateAdCommand({
id: createdAd.id,
driver: createdAd.driver,
passenger: createdAd.passenger,
frequency: createdAd.frequency,
fromDate: createdAd.fromDate,
toDate: createdAd.toDate,
schedule: createdAd.schedule,
seatsProposed: createdAd.seatsProposed,
seatsRequested: createdAd.seatsRequested,
strict: createdAd.strict,
waypoints: createdAd.waypoints,
}),
);
} catch (e: any) {}
}
}

View File

@ -0,0 +1,34 @@
import { Frequency } from '@modules/ad/core/domain/ad.types';
export type Ad = {
id: string;
userId: string;
driver: boolean;
passenger: boolean;
frequency: Frequency;
fromDate: string;
toDate: string;
schedule: ScheduleItem[];
seatsProposed: number;
seatsRequested: number;
strict: boolean;
waypoints: Waypoint[];
};
export type ScheduleItem = {
day: number;
time: string;
margin: number;
};
export type Waypoint = {
position: number;
name?: string;
houseNumber?: string;
street?: string;
locality?: string;
postalCode?: string;
country: string;
lon: number;
lat: number;
};

View File

@ -1,18 +0,0 @@
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

@ -1,218 +1,61 @@
import { TestingModule, Test } from '@nestjs/testing'; import {
import { DatabaseModule } from '../../../database/database.module'; AD_DIRECTION_ENCODER,
import { PrismaService } from '../../../database/adapters/secondaries/prisma.service'; AD_MESSAGE_PUBLISHER,
import { AdRepository } from '../../adapters/secondaries/ad.repository'; AD_REPOSITORY,
import { Ad } from '../../domain/entities/ad'; } from '@modules/ad/ad.di-tokens';
import { Frequency } from '../../domain/types/frequency.enum'; import { AdMapper } from '@modules/ad/ad.mapper';
import { AdEntity } from '@modules/ad/core/domain/ad.entity';
import { CreateAdProps, Frequency } from '@modules/ad/core/domain/ad.types';
import { AdRepository } from '@modules/ad/infrastructure/ad.repository';
import { PrismaService } from '@modules/ad/infrastructure/prisma.service';
import { PostgresDirectionEncoder } from '@modules/geography/infrastructure/postgres-direction-encoder';
import { ConfigModule } from '@nestjs/config';
import { EventEmitterModule } from '@nestjs/event-emitter';
import { Test } from '@nestjs/testing';
describe('AdRepository', () => { describe('Ad Repository', () => {
let prismaService: PrismaService; let prismaService: PrismaService;
let adRepository: AdRepository; let adRepository: AdRepository;
const baseUuid = { const mockMessagePublisher = {
uuid: 'be459a29-7a41-4c0b-b371-abe90bfb6f00', publish: jest.fn().mockImplementation(),
};
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 mockLogger = {
const adToCreate = { log: jest.fn(),
...baseUuid, warn: jest.fn(),
...baseUserUuid, error: jest.fn(),
...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 () => { beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({ const module = await Test.createTestingModule({
imports: [DatabaseModule], imports: [
providers: [AdRepository, PrismaService], EventEmitterModule.forRoot(),
}).compile(); ConfigModule.forRoot({ isGlobal: true }),
],
providers: [
PrismaService,
AdMapper,
{
provide: AD_REPOSITORY,
useClass: AdRepository,
},
{
provide: AD_MESSAGE_PUBLISHER,
useValue: mockMessagePublisher,
},
{
provide: AD_DIRECTION_ENCODER,
useClass: PostgresDirectionEncoder,
},
],
})
// disable logging
.setLogger(mockLogger)
.compile();
prismaService = module.get<PrismaService>(PrismaService); prismaService = module.get<PrismaService>(PrismaService);
adRepository = module.get<AdRepository>(AdRepository); adRepository = module.get<AdRepository>(AD_REPOSITORY);
}); });
afterAll(async () => { afterAll(async () => {
@ -223,180 +66,157 @@ describe('AdRepository', () => {
await prismaService.ad.deleteMany(); 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', () => { describe('create', () => {
it('should create an ad', async () => { it('should create a punctual ad', async () => {
const beforeCount = await prismaService.ad.count(); const beforeCount = await prismaService.ad.count();
const adToCreate: Ad = new Ad(); const createAdProps: CreateAdProps = {
adToCreate.uuid = 'be459a29-7a41-4c0b-b371-abe90bfb6f00'; id: 'b4b56444-f8d3-4110-917c-e37bba77f383',
adToCreate.userUuid = '4e52b54d-a729-4dbd-9283-f84a11bb2200'; driver: true,
adToCreate.driver = true; passenger: false,
adToCreate.passenger = false; frequency: Frequency.PUNCTUAL,
adToCreate.fwdAzimuth = 0; fromDate: '2023-02-01',
adToCreate.backAzimuth = 180; toDate: '2023-02-01',
adToCreate.waypoints = "'LINESTRING(6 47,6.1 47.1,6.2 47.2)'"; schedule: [
adToCreate.direction = {
"'LINESTRING(6 47,6.05 47.05,6.1 47.1,6.15 47.15,6.2 47.2)'"; day: 3,
adToCreate.seatsDriver = 3; time: '12:05',
adToCreate.seatsPassenger = 1; margin: 900,
adToCreate.seatsUsed = 0; },
adToCreate.strict = false; ],
adToCreate.frequency = Frequency.PUNCTUAL; seatsProposed: 3,
adToCreate.fromDate = new Date(2023, 0, 1); seatsRequested: 1,
adToCreate.toDate = new Date(2023, 0, 1); strict: false,
adToCreate.sunTime = new Date(2023, 0, 1, 6, 0, 0); waypoints: [
adToCreate.monMargin = 900; {
adToCreate.tueMargin = 900; position: 0,
adToCreate.wedMargin = 900; lon: 43.7102,
adToCreate.thuMargin = 900; lat: 7.262,
adToCreate.friMargin = 900; },
adToCreate.satMargin = 900; {
adToCreate.sunMargin = 900; position: 1,
const ad = await adRepository.createAd(adToCreate); lon: 43.2965,
lat: 5.3698,
},
],
points: [
{
lon: 7.262,
lat: 43.7102,
},
{
lon: 6.797838,
lat: 43.547031,
},
{
lon: 6.18535,
lat: 43.407517,
},
{
lon: 5.3698,
lat: 43.2965,
},
],
driverDuration: 7668,
driverDistance: 199000,
passengerDuration: 7668,
passengerDistance: 199000,
fwdAzimuth: 273,
backAzimuth: 93,
};
const adToCreate: AdEntity = AdEntity.create(createAdProps);
await adRepository.insertWithUnsupportedFields(adToCreate, 'ad');
const afterCount = await prismaService.ad.count();
expect(afterCount - beforeCount).toBe(1);
});
it('should create a recurrent ad', async () => {
const beforeCount = await prismaService.ad.count();
const createAdProps: CreateAdProps = {
id: 'b4b56444-f8d3-4110-917c-e37bba77f383',
driver: true,
passenger: false,
frequency: Frequency.RECURRENT,
fromDate: '2023-02-01',
toDate: '2024-01-31',
schedule: [
{
day: 1,
time: '08:00',
margin: 900,
},
{
day: 2,
time: '08:00',
margin: 900,
},
{
day: 3,
time: '09:00',
margin: 900,
},
{
day: 4,
time: '08:00',
margin: 900,
},
{
day: 5,
time: '08:00',
margin: 900,
},
],
seatsProposed: 3,
seatsRequested: 1,
strict: false,
waypoints: [
{
position: 0,
lon: 43.7102,
lat: 7.262,
},
{
position: 1,
lon: 43.2965,
lat: 5.3698,
},
],
points: [
{
lon: 7.262,
lat: 43.7102,
},
{
lon: 6.797838,
lat: 43.547031,
},
{
lon: 6.18535,
lat: 43.407517,
},
{
lon: 5.3698,
lat: 43.2965,
},
],
driverDuration: 7668,
driverDistance: 199000,
passengerDuration: 7668,
passengerDistance: 199000,
fwdAzimuth: 273,
backAzimuth: 93,
};
const adToCreate: AdEntity = AdEntity.create(createAdProps);
await adRepository.insertWithUnsupportedFields(adToCreate, 'ad');
const afterCount = await prismaService.ad.count(); const afterCount = await prismaService.ad.count();
expect(afterCount - beforeCount).toBe(1); expect(afterCount - beforeCount).toBe(1);
expect(ad.uuid).toBe('be459a29-7a41-4c0b-b371-abe90bfb6f00');
}); });
}); });
}); });

View File

@ -0,0 +1,172 @@
import { AD_DIRECTION_ENCODER } from '@modules/ad/ad.di-tokens';
import { AdMapper } from '@modules/ad/ad.mapper';
import { AdEntity } from '@modules/ad/core/domain/ad.entity';
import { Frequency } from '@modules/ad/core/domain/ad.types';
import {
AdReadModel,
AdUnsupportedWriteModel,
AdWriteModel,
} from '@modules/ad/infrastructure/ad.repository';
import { DirectionEncoderPort } from '@modules/geography/core/application/ports/direction-encoder.port';
import { Test } from '@nestjs/testing';
const now = new Date('2023-06-21 06:00:00');
const adEntity: AdEntity = new AdEntity({
id: 'c160cf8c-f057-4962-841f-3ad68346df44',
props: {
driver: true,
passenger: true,
frequency: Frequency.PUNCTUAL,
fromDate: '2023-06-21',
toDate: '2023-06-21',
schedule: [
{
day: 3,
time: '07:15',
margin: 900,
},
],
waypoints: [
{
position: 0,
lat: 48.689445,
lon: 6.1765102,
},
{
position: 1,
lat: 48.8566,
lon: 2.3522,
},
],
strict: false,
seatsProposed: 3,
seatsRequested: 1,
driverDistance: 350101,
driverDuration: 14422,
passengerDistance: 350101,
passengerDuration: 14422,
fwdAzimuth: 273,
backAzimuth: 93,
points: [
{
lon: 6.1765102,
lat: 48.689445,
},
{
lon: 4.984578,
lat: 48.725687,
},
{
lon: 2.3522,
lat: 48.8566,
},
],
},
createdAt: now,
updatedAt: now,
});
const adReadModel: AdReadModel = {
uuid: 'c160cf8c-f057-4962-841f-3ad68346df44',
driver: true,
passenger: true,
frequency: Frequency.PUNCTUAL,
fromDate: new Date('2023-06-21'),
toDate: new Date('2023-06-21'),
schedule: [
{
uuid: '3978f3d6-560f-4a8f-83ba-9bf5aa9a2d27',
day: 3,
time: new Date('2023-06-21T07:05:00Z'),
margin: 900,
createdAt: now,
updatedAt: now,
},
],
waypoints: "'LINESTRING(6.1765102 48.689445,2.3522 48.8566)'",
direction:
"'LINESTRING(6.1765102 48.689445,5.12345 48.76543,2.3522 48.8566)'",
driverDistance: 350000,
driverDuration: 14400,
passengerDistance: 350000,
passengerDuration: 14400,
fwdAzimuth: 273,
backAzimuth: 93,
strict: false,
seatsProposed: 3,
seatsRequested: 1,
createdAt: now,
updatedAt: now,
};
const mockDirectionEncoder: DirectionEncoderPort = {
encode: jest
.fn()
.mockImplementationOnce(
() => "'LINESTRING(6.1765102 48.689445,2.3522 48.8566)'",
)
.mockImplementationOnce(
() =>
"'LINESTRING(6.1765102 48.689445,4.984578 48.725687,2.3522 48.8566)'",
),
decode: jest.fn().mockImplementation(() => [
{
lon: 6.1765102,
lat: 48.689445,
},
{
lon: 2.3522,
lat: 48.8566,
},
]),
};
describe('Ad Mapper', () => {
let adMapper: AdMapper;
beforeAll(async () => {
const module = await Test.createTestingModule({
providers: [
AdMapper,
{
provide: AD_DIRECTION_ENCODER,
useValue: mockDirectionEncoder,
},
],
}).compile();
adMapper = module.get<AdMapper>(AdMapper);
});
it('should be defined', () => {
expect(adMapper).toBeDefined();
});
it('should map domain entity to persistence data', async () => {
const mapped: AdWriteModel = adMapper.toPersistence(adEntity);
expect(mapped.schedule.create.length).toBe(1);
expect(mapped.driverDuration).toBe(14422);
expect(mapped.fwdAzimuth).toBe(273);
});
it('should map domain entity to unsupported db persistence data', async () => {
const mapped: AdUnsupportedWriteModel =
adMapper.toUnsupportedPersistence(adEntity);
expect(mapped.waypoints).toBe(
"'LINESTRING(6.1765102 48.689445,2.3522 48.8566)'",
);
expect(mapped.direction).toBe(
"'LINESTRING(6.1765102 48.689445,4.984578 48.725687,2.3522 48.8566)'",
);
});
it('should map persisted data to domain entity', async () => {
const mapped: AdEntity = adMapper.toDomain(adReadModel);
expect(mapped.getProps().schedule.length).toBe(1);
expect(mapped.getProps().schedule[0].time).toBe('07:05');
expect(mapped.getProps().waypoints.length).toBe(2);
});
it('should map domain entity to response', async () => {
expect(adMapper.toResponse(adEntity)).toBeUndefined();
});
});

View File

@ -1,47 +0,0 @@
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

@ -1,35 +0,0 @@
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,54 @@
import { AdEntity } from '@modules/ad/core/domain/ad.entity';
import { CreateAdProps, Frequency } from '@modules/ad/core/domain/ad.types';
import { WaypointProps } from '@modules/ad/core/domain/value-objects/waypoint.value-object';
const originWaypointProps: WaypointProps = {
position: 0,
lat: 48.689445,
lon: 6.17651,
};
const destinationWaypointProps: WaypointProps = {
position: 1,
lat: 48.8566,
lon: 2.3522,
};
const createAdProps: CreateAdProps = {
id: 'e8fe64b1-4c33-49e1-9f69-4db48b21df36',
driver: true,
passenger: true,
fromDate: '2023-06-21',
toDate: '2023-06-21',
schedule: [
{
day: 3,
time: '08:30',
margin: 900,
},
],
frequency: Frequency.PUNCTUAL,
seatsProposed: 3,
seatsRequested: 1,
strict: false,
waypoints: [originWaypointProps, destinationWaypointProps],
driverDistance: 23000,
driverDuration: 900,
passengerDistance: 23000,
passengerDuration: 900,
fwdAzimuth: 283,
backAzimuth: 93,
points: [],
};
describe('Ad entity create', () => {
it('should create a new entity', async () => {
const ad: AdEntity = AdEntity.create(createAdProps);
expect(ad.id.length).toBe(36);
expect(ad.getProps().schedule.length).toBe(1);
expect(ad.getProps().schedule[0].day).toBe(3);
expect(ad.getProps().schedule[0].time).toBe('08:30');
expect(ad.getProps().driver).toBeTruthy();
expect(ad.getProps().passenger).toBeTruthy();
expect(ad.getProps().driverDistance).toBe(23000);
});
});

View File

@ -0,0 +1,141 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AD_REPOSITORY, AD_ROUTE_PROVIDER } from '@modules/ad/ad.di-tokens';
import { AggregateID } from '@mobicoop/ddd-library';
import { AdEntity } from '@modules/ad/core/domain/ad.entity';
import { ConflictException } from '@mobicoop/ddd-library';
import { CreateAdProps, Frequency } from '@modules/ad/core/domain/ad.types';
import { CreateAdService } from '@modules/ad/core/application/commands/create-ad/create-ad.service';
import { CreateAdCommand } from '@modules/ad/core/application/commands/create-ad/create-ad.command';
import { AdAlreadyExistsException } from '@modules/ad/core/domain/ad.errors';
import { WaypointProps } from '@modules/ad/core/domain/value-objects/waypoint.value-object';
import { RouteProviderPort } from '@modules/ad/core/application/ports/route-provider.port';
const originWaypoint: WaypointProps = {
position: 0,
lat: 48.689445,
lon: 6.17651,
};
const destinationWaypoint: WaypointProps = {
position: 1,
lat: 48.8566,
lon: 2.3522,
};
const createAdProps: CreateAdProps = {
id: '4eb6a6af-ecfd-41c3-9118-473a507014d4',
fromDate: '2023-12-21',
toDate: '2023-12-21',
schedule: [
{
day: 4,
time: '08:15',
margin: 900,
},
],
driver: true,
passenger: true,
seatsProposed: 3,
seatsRequested: 1,
strict: false,
frequency: Frequency.PUNCTUAL,
waypoints: [originWaypoint, destinationWaypoint],
driverDistance: 23000,
driverDuration: 900,
passengerDistance: 23000,
passengerDuration: 900,
fwdAzimuth: 283,
backAzimuth: 93,
points: [],
};
const mockAdRepository = {
insertWithUnsupportedFields: jest
.fn()
.mockImplementationOnce(() => ({}))
.mockImplementationOnce(() => {
throw new Error();
})
.mockImplementationOnce(() => {
throw new ConflictException('already exists');
}),
};
const mockRouteProvider: RouteProviderPort = {
getBasic: jest.fn().mockImplementation(() => ({
driverDistance: 350101,
driverDuration: 14422,
passengerDistance: 350101,
passengerDuration: 14422,
fwdAzimuth: 273,
backAzimuth: 93,
distanceAzimuth: 336544,
points: [
{
lon: 6.1765102,
lat: 48.689445,
},
{
lon: 4.984578,
lat: 48.725687,
},
{
lon: 2.3522,
lat: 48.8566,
},
],
})),
};
describe('create-ad.service', () => {
let createAdService: CreateAdService;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
{
provide: AD_REPOSITORY,
useValue: mockAdRepository,
},
{
provide: AD_ROUTE_PROVIDER,
useValue: mockRouteProvider,
},
CreateAdService,
],
}).compile();
createAdService = module.get<CreateAdService>(CreateAdService);
});
it('should be defined', () => {
expect(createAdService).toBeDefined();
});
describe('execution', () => {
const createAdCommand = new CreateAdCommand(createAdProps);
it('should create a new ad', async () => {
AdEntity.create = jest.fn().mockReturnValue({
id: '047a6ecf-23d4-4d3e-877c-3225d560a8da',
});
const result: AggregateID = await createAdService.execute(
createAdCommand,
);
expect(result).toBe('047a6ecf-23d4-4d3e-877c-3225d560a8da');
});
it('should throw an error if something bad happens', async () => {
AdEntity.create = jest.fn().mockReturnValue({
id: '047a6ecf-23d4-4d3e-877c-3225d560a8da',
});
await expect(
createAdService.execute(createAdCommand),
).rejects.toBeInstanceOf(Error);
});
it('should throw an exception if Ad already exists', async () => {
AdEntity.create = jest.fn().mockReturnValue({
id: '047a6ecf-23d4-4d3e-877c-3225d560a8da',
});
await expect(
createAdService.execute(createAdCommand),
).rejects.toBeInstanceOf(AdAlreadyExistsException);
});
});
});

View File

@ -0,0 +1,49 @@
import { ArgumentOutOfRangeException } from '@mobicoop/ddd-library';
import { Point } from '@modules/ad/core/domain/value-objects/point.value-object';
describe('Point value object', () => {
it('should create a point value object', () => {
const pointVO = new Point({
lat: 48.689445,
lon: 6.17651,
});
expect(pointVO.lat).toBe(48.689445);
expect(pointVO.lon).toBe(6.17651);
});
it('should throw an exception if longitude is invalid', () => {
try {
new Point({
lat: 48.689445,
lon: 186.17651,
});
} catch (e: any) {
expect(e).toBeInstanceOf(ArgumentOutOfRangeException);
}
try {
new Point({
lat: 48.689445,
lon: -186.17651,
});
} catch (e: any) {
expect(e).toBeInstanceOf(ArgumentOutOfRangeException);
}
});
it('should throw an exception if latitude is invalid', () => {
try {
new Point({
lat: 148.689445,
lon: 6.17651,
});
} catch (e: any) {
expect(e).toBeInstanceOf(ArgumentOutOfRangeException);
}
try {
new Point({
lat: -148.689445,
lon: 6.17651,
});
} catch (e: any) {
expect(e).toBeInstanceOf(ArgumentOutOfRangeException);
}
});
});

View File

@ -0,0 +1,62 @@
import {
ArgumentInvalidException,
ArgumentOutOfRangeException,
} from '@mobicoop/ddd-library';
import { ScheduleItem } from '@modules/ad/core/domain/value-objects/schedule-item.value-object';
describe('Schedule item value object', () => {
it('should create a schedule item value object', () => {
const scheduleItemVO = new ScheduleItem({
day: 0,
time: '07:00',
margin: 900,
});
expect(scheduleItemVO.day).toBe(0);
expect(scheduleItemVO.time).toBe('07:00');
expect(scheduleItemVO.margin).toBe(900);
});
it('should throw an exception if day is invalid', () => {
try {
new ScheduleItem({
day: 7,
time: '07:00',
margin: 900,
});
} catch (e: any) {
expect(e).toBeInstanceOf(ArgumentOutOfRangeException);
}
});
it('should throw an exception if time is invalid', () => {
try {
new ScheduleItem({
day: 0,
time: '07,00',
margin: 900,
});
} catch (e: any) {
expect(e).toBeInstanceOf(ArgumentInvalidException);
}
});
it('should throw an exception if the hour of the time is invalid', () => {
try {
new ScheduleItem({
day: 0,
time: '25:00',
margin: 900,
});
} catch (e: any) {
expect(e).toBeInstanceOf(ArgumentInvalidException);
}
});
it('should throw an exception if the minutes of the time are invalid', () => {
try {
new ScheduleItem({
day: 0,
time: '07:63',
margin: 900,
});
} catch (e: any) {
expect(e).toBeInstanceOf(ArgumentInvalidException);
}
});
});

View File

@ -0,0 +1,69 @@
import {
ArgumentInvalidException,
ArgumentOutOfRangeException,
} from '@mobicoop/ddd-library';
import { Waypoint } from '@modules/ad/core/domain/value-objects/waypoint.value-object';
describe('Waypoint value object', () => {
it('should create a waypoint value object', () => {
const waypointVO = new Waypoint({
position: 0,
lat: 48.689445,
lon: 6.17651,
});
expect(waypointVO.position).toBe(0);
expect(waypointVO.lat).toBe(48.689445);
expect(waypointVO.lon).toBe(6.17651);
});
it('should throw an exception if position is invalid', () => {
try {
new Waypoint({
position: -1,
lat: 48.689445,
lon: 6.17651,
});
} catch (e: any) {
expect(e).toBeInstanceOf(ArgumentInvalidException);
}
});
it('should throw an exception if longitude is invalid', () => {
try {
new Waypoint({
position: 0,
lat: 48.689445,
lon: 186.17651,
});
} catch (e: any) {
expect(e).toBeInstanceOf(ArgumentOutOfRangeException);
}
try {
new Waypoint({
position: 0,
lat: 48.689445,
lon: -186.17651,
});
} catch (e: any) {
expect(e).toBeInstanceOf(ArgumentOutOfRangeException);
}
});
it('should throw an exception if latitude is invalid', () => {
try {
new Waypoint({
position: 0,
lat: 148.689445,
lon: 6.17651,
});
} catch (e: any) {
expect(e).toBeInstanceOf(ArgumentOutOfRangeException);
}
try {
new Waypoint({
position: 0,
lat: -148.689445,
lon: 6.17651,
});
} catch (e: any) {
expect(e).toBeInstanceOf(ArgumentOutOfRangeException);
}
});
});

View File

@ -1,176 +0,0 @@
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

@ -1,138 +0,0 @@
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

@ -1,53 +0,0 @@
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

@ -0,0 +1,62 @@
import {
AD_DIRECTION_ENCODER,
AD_ROUTE_PROVIDER,
} from '@modules/ad/ad.di-tokens';
import { AdMapper } from '@modules/ad/ad.mapper';
import { RouteProviderPort } from '@modules/ad/core/application/ports/route-provider.port';
import { AdRepository } from '@modules/ad/infrastructure/ad.repository';
import { PrismaService } from '@modules/ad/infrastructure/prisma.service';
import { DirectionEncoderPort } from '@modules/geography/core/application/ports/direction-encoder.port';
import { EventEmitter2, EventEmitterModule } from '@nestjs/event-emitter';
import { Test, TestingModule } from '@nestjs/testing';
const mockMessagePublisher = {
publish: jest.fn().mockImplementation(),
};
const mockDirectionEncoder: DirectionEncoderPort = {
encode: jest.fn(),
decode: jest.fn(),
};
const mockRouteProvider: RouteProviderPort = {
getBasic: jest.fn(),
};
describe('Ad repository', () => {
let prismaService: PrismaService;
let adMapper: AdMapper;
let eventEmitter: EventEmitter2;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
imports: [EventEmitterModule.forRoot()],
providers: [
PrismaService,
AdMapper,
{
provide: AD_DIRECTION_ENCODER,
useValue: mockDirectionEncoder,
},
{
provide: AD_ROUTE_PROVIDER,
useValue: mockRouteProvider,
},
],
}).compile();
prismaService = module.get<PrismaService>(PrismaService);
adMapper = module.get<AdMapper>(AdMapper);
eventEmitter = module.get<EventEmitter2>(EventEmitter2);
});
it('should be defined', () => {
expect(
new AdRepository(
prismaService,
adMapper,
eventEmitter,
mockMessagePublisher,
),
).toBeDefined();
});
});

View File

@ -0,0 +1,69 @@
import { AD_GET_BASIC_ROUTE_CONTROLLER } from '@modules/ad/ad.di-tokens';
import { Route } from '@modules/ad/core/application/types/route.type';
import { Role } from '@modules/ad/core/domain/ad.types';
import { RouteProvider } from '@modules/ad/infrastructure/route-provider';
import { GetBasicRouteControllerPort } from '@modules/geography/core/application/ports/get-basic-route-controller.port';
import { Test, TestingModule } from '@nestjs/testing';
const mockGetBasicRouteController: GetBasicRouteControllerPort = {
get: jest.fn().mockImplementation(() => ({
driverDistance: 23000,
driverDuration: 900,
passengerDistance: 23000,
passengerDuration: 900,
fwdAzimuth: 283,
backAzimuth: 93,
distanceAzimuth: 19840,
points: [
{
lon: 6.1765103,
lat: 48.689446,
},
{
lon: 2.3523,
lat: 48.8567,
},
],
})),
};
describe('Route provider', () => {
let routeProvider: RouteProvider;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
RouteProvider,
{
provide: AD_GET_BASIC_ROUTE_CONTROLLER,
useValue: mockGetBasicRouteController,
},
],
}).compile();
routeProvider = module.get<RouteProvider>(RouteProvider);
});
it('should be defined', () => {
expect(routeProvider).toBeDefined();
});
it('should provide a route', async () => {
const route: Route = await routeProvider.getBasic(
[Role.DRIVER],
[
{
position: 0,
lat: 48.689445,
lon: 6.1765102,
},
{
position: 1,
lat: 48.8566,
lon: 2.3522,
},
],
);
expect(route.driverDistance).toBe(23000);
});
});

View File

@ -0,0 +1,44 @@
import { AdCreatedMessageHandler } from '@modules/ad/interface/message-handlers/ad-created.message-handler';
import { CommandBus } from '@nestjs/cqrs';
import { Test, TestingModule } from '@nestjs/testing';
const adCreatedMessage =
'{"id":"4eb6a6af-ecfd-41c3-9118-473a507014d4","driver":"true","passenger":"true","frequency":"PUNCTUAL","fromDate":"2023-08-18","toDate":"2023-08-18","schedule":[{"day":"5","time":"10:00","margin":"900"}],"seatsProposed":"3","seatsRequested":"1","strict":"false","waypoints":[{"position":"0","houseNumber":"5","street":"rue de la monnaie","locality":"Nancy","postalCode":"54000","country":"France","lon":"48.689445","lat":"6.17651"},{"position":"1","locality":"Paris","postalCode":"75000","country":"France","lon":"48.8566","lat":"2.3522"}]}';
const mockCommandBus = {
execute: jest.fn(),
};
describe('Ad Created Message Handler', () => {
let adCreatedMessageHandler: AdCreatedMessageHandler;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
{
provide: CommandBus,
useValue: mockCommandBus,
},
AdCreatedMessageHandler,
],
}).compile();
adCreatedMessageHandler = module.get<AdCreatedMessageHandler>(
AdCreatedMessageHandler,
);
});
afterEach(async () => {
jest.clearAllMocks();
});
it('should be defined', () => {
expect(adCreatedMessageHandler).toBeDefined();
});
it('should create an ad', async () => {
jest.spyOn(mockCommandBus, 'execute');
await adCreatedMessageHandler.adCreated(adCreatedMessage);
expect(mockCommandBus.execute).toHaveBeenCalledTimes(1);
});
});

View File

@ -1,259 +0,0 @@
import { Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { DatabaseException } from '../../exceptions/database.exception';
import { ICollection } from '../../interfaces/collection.interface';
import { IRepository } from '../../interfaces/repository.interface';
import { PrismaService } from './prisma.service';
/**
* Child classes MUST redefined model property with appropriate model name
*/
@Injectable()
export abstract class PrismaRepository<T> implements IRepository<T> {
protected model: string;
constructor(protected readonly prisma: PrismaService) {}
findAll = async (
page = 1,
perPage = 10,
where?: any,
include?: any,
): Promise<ICollection<T>> => {
const [data, total] = await this.prisma.$transaction([
this.prisma[this.model].findMany({
where,
include,
skip: (page - 1) * perPage,
take: perPage,
}),
this.prisma[this.model].count({
where,
}),
]);
return Promise.resolve({
data,
total,
});
};
findOneByUuid = async (uuid: string): Promise<T> => {
try {
const entity = await this.prisma[this.model].findUnique({
where: { uuid },
});
return entity;
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseException(
Prisma.PrismaClientKnownRequestError.name,
e.code,
e.message,
);
} else {
throw new DatabaseException();
}
}
};
findOne = async (where: any, include?: any): Promise<T> => {
try {
const entity = await this.prisma[this.model].findFirst({
where: where,
include: include,
});
return entity;
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseException(
Prisma.PrismaClientKnownRequestError.name,
e.code,
);
} else {
throw new DatabaseException();
}
}
};
// TODO : using any is not good, but needed for nested entities
// 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({
data: entity,
include: include,
});
return res;
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseException(
Prisma.PrismaClientKnownRequestError.name,
e.code,
e.message,
);
} else {
throw new DatabaseException();
}
}
}
update = async (uuid: string, entity: Partial<T>): Promise<T> => {
try {
const updatedEntity = await this.prisma[this.model].update({
where: { uuid },
data: entity,
});
return updatedEntity;
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseException(
Prisma.PrismaClientKnownRequestError.name,
e.code,
e.message,
);
} else {
throw new DatabaseException();
}
}
};
updateWhere = async (
where: any,
entity: Partial<T> | any,
include?: any,
): Promise<T> => {
try {
const updatedEntity = await this.prisma[this.model].update({
where: where,
data: entity,
include: include,
});
return updatedEntity;
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseException(
Prisma.PrismaClientKnownRequestError.name,
e.code,
e.message,
);
} else {
throw new DatabaseException();
}
}
};
delete = async (uuid: string): Promise<T> => {
try {
const entity = await this.prisma[this.model].delete({
where: { uuid },
});
return entity;
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseException(
Prisma.PrismaClientKnownRequestError.name,
e.code,
e.message,
);
} else {
throw new DatabaseException();
}
}
};
deleteMany = async (where: any): Promise<void> => {
try {
const entity = await this.prisma[this.model].deleteMany({
where: where,
});
return entity;
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseException(
Prisma.PrismaClientKnownRequestError.name,
e.code,
e.message,
);
} else {
throw new DatabaseException();
}
}
};
findAllByQuery = async (
include: string[],
where: string[],
): Promise<ICollection<T>> => {
const query = `SELECT ${include.join(',')} FROM ${
this.model
} WHERE ${where.join(' AND ')}`;
const data: T[] = await this.prisma.$queryRawUnsafe(query);
return Promise.resolve({
data,
total: data.length,
});
};
createWithFields = async (fields: object): Promise<number> => {
try {
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) {
throw new DatabaseException(
Prisma.PrismaClientKnownRequestError.name,
e.code,
e.message,
);
} else {
throw new DatabaseException();
}
}
};
updateWithFields = async (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(
', ',
)} WHERE uuid = '${uuid}'`;
return await this.prisma.$executeRawUnsafe(command);
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseException(
Prisma.PrismaClientKnownRequestError.name,
e.code,
e.message,
);
} else {
throw new DatabaseException();
}
}
};
healthCheck = async (): Promise<boolean> => {
try {
await this.prisma.$queryRaw`SELECT 1`;
return true;
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseException(
Prisma.PrismaClientKnownRequestError.name,
e.code,
e.message,
);
} else {
throw new DatabaseException();
}
}
};
}

View File

@ -1,8 +0,0 @@
import { Module } from '@nestjs/common';
import { PrismaService } from './adapters/secondaries/prisma.service';
@Module({
providers: [PrismaService],
exports: [PrismaService],
})
export class DatabaseModule {}

View File

@ -1,3 +0,0 @@
import { PrismaRepository } from '../adapters/secondaries/prisma.repository.abstract';
export class DatabaseRepository<T> extends PrismaRepository<T> {}

View File

@ -1,24 +0,0 @@
export class DatabaseException implements Error {
name: string;
message: string;
constructor(
private _type: string = 'unknown',
private _code: string = '',
message?: string,
) {
this.name = 'DatabaseException';
this.message = message ?? 'An error occured with the database.';
if (this.message.includes('Unique constraint failed')) {
this.message = 'Already exists.';
}
}
get type(): string {
return this._type;
}
get code(): string {
return this._code;
}
}

View File

@ -1,4 +0,0 @@
export interface ICollection<T> {
data: T[];
total: number;
}

View File

@ -1,18 +0,0 @@
import { ICollection } from './collection.interface';
export interface IRepository<T> {
findAll(
page: number,
perPage: number,
params?: any,
include?: any,
): Promise<ICollection<T>>;
findOne(where: any, include?: any): Promise<T>;
findOneByUuid(uuid: string, include?: any): Promise<T>;
create(entity: Partial<T> | any, include?: any): Promise<T>;
update(uuid: string, entity: Partial<T>, include?: any): Promise<T>;
updateWhere(where: any, entity: Partial<T> | any, include?: any): Promise<T>;
delete(uuid: string): Promise<T>;
deleteMany(where: any): Promise<void>;
healthCheck(): Promise<boolean>;
}

View File

@ -1,571 +0,0 @@
import { Injectable } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
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 {
uuid?: string;
name: string;
}
let entityId = 2;
const entityUuid = 'uuid-';
const entityName = 'name-';
const createRandomEntity = (): FakeEntity => {
const entity: FakeEntity = {
uuid: `${entityUuid}${entityId}`,
name: `${entityName}${entityId}`,
};
entityId++;
return entity;
};
const fakeEntityToCreate: FakeEntity = {
name: 'test',
};
const fakeEntityCreated: FakeEntity = {
...fakeEntityToCreate,
uuid: 'some-uuid',
};
const fakeEntities: FakeEntity[] = [];
Array.from({ length: 10 }).forEach(() => {
fakeEntities.push(createRandomEntity());
});
@Injectable()
class FakePrismaRepository extends PrismaRepository<FakeEntity> {
protected model = 'fake';
}
class FakePrismaService extends PrismaService {
fake: any;
}
const mockPrismaService = {
$transaction: jest.fn().mockImplementation(async (data: any) => {
const entities = await data[0];
if (entities.length == 1) {
return Promise.resolve([[fakeEntityCreated], 1]);
}
return Promise.resolve([fakeEntities, fakeEntities.length]);
}),
// eslint-disable-next-line @typescript-eslint/no-unused-vars
$queryRawUnsafe: jest.fn().mockImplementation((query?: string) => {
return Promise.resolve(fakeEntities);
}),
$executeRawUnsafe: jest
.fn()
.mockResolvedValueOnce(fakeEntityCreated)
// eslint-disable-next-line @typescript-eslint/no-unused-vars
.mockImplementationOnce((fields: object) => {
throw new Prisma.PrismaClientKnownRequestError('unknown request', {
code: 'code',
clientVersion: 'version',
});
})
// eslint-disable-next-line @typescript-eslint/no-unused-vars
.mockImplementationOnce((fields: object) => {
throw new Error('an unknown error');
})
.mockResolvedValueOnce(fakeEntityCreated)
// eslint-disable-next-line @typescript-eslint/no-unused-vars
.mockImplementationOnce((fields: object) => {
throw new Prisma.PrismaClientKnownRequestError('unknown request', {
code: 'code',
clientVersion: 'version',
});
})
// eslint-disable-next-line @typescript-eslint/no-unused-vars
.mockImplementationOnce((fields: object) => {
throw new Error('an unknown error');
}),
$queryRaw: jest
.fn()
.mockImplementationOnce(() => {
throw new Prisma.PrismaClientKnownRequestError('unknown request', {
code: 'code',
clientVersion: 'version',
});
})
.mockImplementationOnce(() => {
return true;
})
.mockImplementation(() => {
throw new Prisma.PrismaClientKnownRequestError('Database unavailable', {
code: 'code',
clientVersion: 'version',
});
}),
fake: {
create: jest
.fn()
.mockResolvedValueOnce(fakeEntityCreated)
// eslint-disable-next-line @typescript-eslint/no-unused-vars
.mockImplementationOnce((params?: any) => {
throw new Prisma.PrismaClientKnownRequestError('unknown request', {
code: 'code',
clientVersion: 'version',
});
})
// eslint-disable-next-line @typescript-eslint/no-unused-vars
.mockImplementationOnce((params?: any) => {
throw new Error('an unknown error');
}),
findMany: jest.fn().mockImplementation((params?: any) => {
if (params?.where?.limit == 1) {
return Promise.resolve([fakeEntityCreated]);
}
return Promise.resolve(fakeEntities);
}),
count: jest.fn().mockResolvedValue(fakeEntities.length),
findUnique: jest.fn().mockImplementation(async (params?: any) => {
let entity;
if (params?.where?.uuid) {
entity = fakeEntities.find(
(entity) => entity.uuid === params?.where?.uuid,
);
}
if (!entity && params?.where?.uuid == 'unknown') {
throw new Prisma.PrismaClientKnownRequestError('unknown request', {
code: 'code',
clientVersion: 'version',
});
} else if (!entity) {
throw new Error('no entity');
}
return entity;
}),
findFirst: jest
.fn()
.mockImplementationOnce((params?: any) => {
if (params?.where?.name) {
return Promise.resolve(
fakeEntities.find((entity) => entity.name === params?.where?.name),
);
}
})
// eslint-disable-next-line @typescript-eslint/no-unused-vars
.mockImplementationOnce((params?: any) => {
throw new Prisma.PrismaClientKnownRequestError('unknown request', {
code: 'code',
clientVersion: 'version',
});
})
// eslint-disable-next-line @typescript-eslint/no-unused-vars
.mockImplementationOnce((params?: any) => {
throw new Error('an unknown error');
}),
update: jest
.fn()
// eslint-disable-next-line @typescript-eslint/no-unused-vars
.mockImplementationOnce((params?: any) => {
throw new Prisma.PrismaClientKnownRequestError('unknown request', {
code: 'code',
clientVersion: 'version',
});
})
// eslint-disable-next-line @typescript-eslint/no-unused-vars
.mockImplementationOnce((params?: any) => {
throw new Prisma.PrismaClientKnownRequestError('unknown request', {
code: 'code',
clientVersion: 'version',
});
})
.mockImplementationOnce((params: any) => {
const entity = fakeEntities.find(
(entity) => entity.name === params.where.name,
);
Object.entries(params.data).map(([key, value]) => {
entity[key] = value;
});
return Promise.resolve(entity);
})
.mockImplementation((params: any) => {
const entity = fakeEntities.find(
(entity) => entity.uuid === params.where.uuid,
);
Object.entries(params.data).map(([key, value]) => {
entity[key] = value;
});
return Promise.resolve(entity);
}),
delete: jest
.fn()
// eslint-disable-next-line @typescript-eslint/no-unused-vars
.mockImplementationOnce((params?: any) => {
throw new Prisma.PrismaClientKnownRequestError('unknown request', {
code: 'code',
clientVersion: 'version',
});
})
.mockImplementation((params: any) => {
let found = false;
fakeEntities.forEach((entity, index) => {
if (entity.uuid === params?.where?.uuid) {
found = true;
fakeEntities.splice(index, 1);
}
});
if (!found) {
throw new Error();
}
}),
deleteMany: jest
.fn()
// eslint-disable-next-line @typescript-eslint/no-unused-vars
.mockImplementationOnce((params?: any) => {
throw new Prisma.PrismaClientKnownRequestError('unknown request', {
code: 'code',
clientVersion: 'version',
});
})
.mockImplementation((params: any) => {
let found = false;
fakeEntities.forEach((entity, index) => {
if (entity.uuid === params?.where?.uuid) {
found = true;
fakeEntities.splice(index, 1);
}
});
if (!found) {
throw new Error();
}
}),
},
};
describe('PrismaRepository', () => {
let fakeRepository: FakePrismaRepository;
let prisma: FakePrismaService;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
FakePrismaRepository,
{
provide: PrismaService,
useValue: mockPrismaService,
},
],
}).compile();
fakeRepository = module.get<FakePrismaRepository>(FakePrismaRepository);
prisma = module.get<PrismaService>(PrismaService) as FakePrismaService;
});
it('should be defined', () => {
expect(fakeRepository).toBeDefined();
expect(prisma).toBeDefined();
});
describe('findAll', () => {
it('should return an array of entities', async () => {
jest.spyOn(prisma.fake, 'findMany');
jest.spyOn(prisma.fake, 'count');
jest.spyOn(prisma, '$transaction');
const entities = await fakeRepository.findAll();
expect(entities).toStrictEqual({
data: fakeEntities,
total: fakeEntities.length,
});
});
it('should return an array containing only one entity', async () => {
const entities = await fakeRepository.findAll(1, 10, { limit: 1 });
expect(prisma.fake.findMany).toHaveBeenCalledWith({
skip: 0,
take: 10,
where: { limit: 1 },
});
expect(entities).toEqual({
data: [fakeEntityCreated],
total: 1,
});
});
});
describe('create', () => {
it('should create an entity', async () => {
jest.spyOn(prisma.fake, 'create');
const newEntity = await fakeRepository.create(fakeEntityToCreate);
expect(newEntity).toBe(fakeEntityCreated);
expect(prisma.fake.create).toHaveBeenCalledTimes(1);
});
it('should throw a DatabaseException for client error', async () => {
await expect(
fakeRepository.create(fakeEntityToCreate),
).rejects.toBeInstanceOf(DatabaseException);
});
it('should throw a DatabaseException if uuid is not found', async () => {
await expect(
fakeRepository.create(fakeEntityToCreate),
).rejects.toBeInstanceOf(DatabaseException);
});
});
describe('findOneByUuid', () => {
it('should find an entity by uuid', async () => {
const entity = await fakeRepository.findOneByUuid(fakeEntities[0].uuid);
expect(entity).toBe(fakeEntities[0]);
});
it('should throw a DatabaseException for client error', async () => {
await expect(
fakeRepository.findOneByUuid('unknown'),
).rejects.toBeInstanceOf(DatabaseException);
});
it('should throw a DatabaseException if uuid is not found', async () => {
await expect(
fakeRepository.findOneByUuid('wrong-uuid'),
).rejects.toBeInstanceOf(DatabaseException);
});
});
describe('findOne', () => {
it('should find one entity', async () => {
const entity = await fakeRepository.findOne({
name: fakeEntities[0].name,
});
expect(entity.name).toBe(fakeEntities[0].name);
});
it('should throw a DatabaseException for client error', async () => {
await expect(
fakeRepository.findOne({
name: fakeEntities[0].name,
}),
).rejects.toBeInstanceOf(DatabaseException);
});
it('should throw a DatabaseException for unknown error', async () => {
await expect(
fakeRepository.findOne({
name: fakeEntities[0].name,
}),
).rejects.toBeInstanceOf(DatabaseException);
});
});
describe('update', () => {
it('should throw a DatabaseException for client error', async () => {
await expect(
fakeRepository.update('fake-uuid', { name: 'error' }),
).rejects.toBeInstanceOf(DatabaseException);
await expect(
fakeRepository.updateWhere({ name: 'error' }, { name: 'new error' }),
).rejects.toBeInstanceOf(DatabaseException);
});
it('should update an entity with name', async () => {
const newName = 'new-random-name';
await fakeRepository.updateWhere(
{ name: fakeEntities[0].name },
{
name: newName,
},
);
expect(fakeEntities[0].name).toBe(newName);
});
it('should update an entity with uuid', async () => {
const newName = 'random-name';
await fakeRepository.update(fakeEntities[0].uuid, {
name: newName,
});
expect(fakeEntities[0].name).toBe(newName);
});
it("should throw an exception if an entity doesn't exist", async () => {
await expect(
fakeRepository.update('fake-uuid', { name: 'error' }),
).rejects.toBeInstanceOf(DatabaseException);
await expect(
fakeRepository.updateWhere({ name: 'error' }, { name: 'new error' }),
).rejects.toBeInstanceOf(DatabaseException);
});
});
describe('delete', () => {
it('should throw a DatabaseException for client error', async () => {
await expect(fakeRepository.delete('fake-uuid')).rejects.toBeInstanceOf(
DatabaseException,
);
});
it('should delete an entity', async () => {
const savedUuid = fakeEntities[0].uuid;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const res = await fakeRepository.delete(savedUuid);
const deletedEntity = fakeEntities.find(
(entity) => entity.uuid === savedUuid,
);
expect(deletedEntity).toBeUndefined();
});
it("should throw an exception if an entity doesn't exist", async () => {
await expect(fakeRepository.delete('fake-uuid')).rejects.toBeInstanceOf(
DatabaseException,
);
});
});
describe('deleteMany', () => {
it('should throw a DatabaseException for client error', async () => {
await expect(
fakeRepository.deleteMany({ uuid: 'fake-uuid' }),
).rejects.toBeInstanceOf(DatabaseException);
});
it('should delete entities based on their uuid', async () => {
const savedUuid = fakeEntities[0].uuid;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const res = await fakeRepository.deleteMany({ uuid: savedUuid });
const deletedEntity = fakeEntities.find(
(entity) => entity.uuid === savedUuid,
);
expect(deletedEntity).toBeUndefined();
});
it("should throw an exception if an entity doesn't exist", async () => {
await expect(
fakeRepository.deleteMany({ uuid: 'fake-uuid' }),
).rejects.toBeInstanceOf(DatabaseException);
});
});
describe('findAllByquery', () => {
it('should return an array of entities', async () => {
const entities = await fakeRepository.findAllByQuery(
['uuid', 'name'],
['name is not null'],
);
expect(entities).toStrictEqual({
data: fakeEntities,
total: fakeEntities.length,
});
});
});
describe('createWithFields', () => {
it('should create an entity', async () => {
jest.spyOn(prisma, '$queryRawUnsafe');
const newEntity = await fakeRepository.createWithFields({
uuid: '804319b3-a09b-4491-9f82-7976bfce0aff',
name: 'my-name',
});
expect(newEntity).toBe(fakeEntityCreated);
expect(prisma.$queryRawUnsafe).toHaveBeenCalledTimes(1);
});
it('should throw a DatabaseException for client error', async () => {
await expect(
fakeRepository.createWithFields({
uuid: '804319b3-a09b-4491-9f82-7976bfce0aff',
name: 'my-name',
}),
).rejects.toBeInstanceOf(DatabaseException);
});
it('should throw a DatabaseException if uuid is not found', async () => {
await expect(
fakeRepository.createWithFields({
name: 'my-name',
}),
).rejects.toBeInstanceOf(DatabaseException);
});
});
describe('updateWithFields', () => {
it('should update an entity', async () => {
jest.spyOn(prisma, '$queryRawUnsafe');
const updatedEntity = await fakeRepository.updateWithFields(
'804319b3-a09b-4491-9f82-7976bfce0aff',
{
name: 'my-name',
},
);
expect(updatedEntity).toBe(fakeEntityCreated);
expect(prisma.$queryRawUnsafe).toHaveBeenCalledTimes(1);
});
it('should throw a DatabaseException for client error', async () => {
await expect(
fakeRepository.updateWithFields(
'804319b3-a09b-4491-9f82-7976bfce0aff',
{
name: 'my-name',
},
),
).rejects.toBeInstanceOf(DatabaseException);
});
it('should throw a DatabaseException if uuid is not found', async () => {
await expect(
fakeRepository.updateWithFields(
'804319b3-a09b-4491-9f82-7976bfce0aff',
{
name: 'my-name',
},
),
).rejects.toBeInstanceOf(DatabaseException);
});
});
describe('healthCheck', () => {
it('should throw a DatabaseException for client error', async () => {
await expect(fakeRepository.healthCheck()).rejects.toBeInstanceOf(
DatabaseException,
);
});
it('should return a healthy result', async () => {
const res = await fakeRepository.healthCheck();
expect(res).toBeTruthy();
});
it('should throw an exception if database is not available', async () => {
await expect(fakeRepository.healthCheck()).rejects.toBeInstanceOf(
DatabaseException,
);
});
});
});

View File

@ -1,8 +0,0 @@
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

@ -1,28 +0,0 @@
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

@ -1,11 +0,0 @@
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

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

View File

@ -0,0 +1,6 @@
import { Coordinates } from '../../domain/route.types';
export interface DirectionEncoderPort {
encode(coordinates: Coordinates[]): string;
decode(direction: string): Coordinates[];
}

View File

@ -1,4 +1,4 @@
export interface IGeodesic { export interface GeodesicPort {
inverse( inverse(
lon1: number, lon1: number,
lat1: number, lat1: number,

View File

@ -0,0 +1,6 @@
import { Path, Route } from '../../domain/route.types';
import { GeorouterSettings } from '../types/georouter-settings.type';
export interface GeorouterPort {
routes(paths: Path[], settings: GeorouterSettings): Promise<Route[]>;
}

View File

@ -0,0 +1,6 @@
import { GetRouteRequestDto } from '@modules/geography/interface/controllers/dtos/get-route.request.dto';
import { RouteResponseDto } from '@modules/geography/interface/dtos/route.response.dto';
export interface GetBasicRouteControllerPort {
get(data: GetRouteRequestDto): Promise<RouteResponseDto>;
}

View File

@ -0,0 +1,19 @@
import { IQueryHandler, QueryHandler } from '@nestjs/cqrs';
import { GetRouteQuery } from './get-route.query';
import { RouteEntity } from '@modules/geography/core/domain/route.entity';
import { Inject } from '@nestjs/common';
import { GEOROUTER } from '@modules/geography/geography.di-tokens';
import { GeorouterPort } from '../../ports/georouter.port';
@QueryHandler(GetRouteQuery)
export class GetRouteQueryHandler implements IQueryHandler {
constructor(@Inject(GEOROUTER) private readonly georouter: GeorouterPort) {}
execute = async (query: GetRouteQuery): Promise<RouteEntity> =>
await RouteEntity.create({
roles: query.roles,
waypoints: query.waypoints,
georouter: this.georouter,
georouterSettings: query.georouterSettings,
});
}

View File

@ -0,0 +1,20 @@
import { QueryBase } from '@mobicoop/ddd-library';
import { Role, Waypoint } from '@modules/geography/core/domain/route.types';
import { GeorouterSettings } from '../../types/georouter-settings.type';
export class GetRouteQuery extends QueryBase {
readonly roles: Role[];
readonly waypoints: Waypoint[];
readonly georouterSettings: GeorouterSettings;
constructor(
roles: Role[],
waypoints: Waypoint[],
georouterSettings: GeorouterSettings,
) {
super();
this.roles = roles;
this.waypoints = waypoints;
this.georouterSettings = georouterSettings;
}
}

View File

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

View File

@ -0,0 +1,5 @@
export type GeorouterSettings = {
points: boolean;
detailedDuration: boolean;
detailedDistance: boolean;
};

View File

@ -0,0 +1,162 @@
import { AggregateRoot, AggregateID } from '@mobicoop/ddd-library';
import {
CreateRouteProps,
Path,
Role,
RouteProps,
PathType,
Route,
} from './route.types';
import { WaypointProps } from './value-objects/waypoint.value-object';
import { v4 } from 'uuid';
import { RouteNotFoundException } from './route.errors';
export class RouteEntity extends AggregateRoot<RouteProps> {
protected readonly _id: AggregateID;
static create = async (create: CreateRouteProps): Promise<RouteEntity> => {
let routes: Route[];
try {
routes = await create.georouter.routes(
this.getPaths(create.roles, create.waypoints),
create.georouterSettings,
);
if (!routes || routes.length == 0) throw new RouteNotFoundException();
} catch (e: any) {
throw e;
}
let driverRoute: Route;
let passengerRoute: Route;
if (routes.some((route: Route) => route.type == PathType.GENERIC)) {
driverRoute = passengerRoute = routes.find(
(route: Route) => route.type == PathType.GENERIC,
);
} else {
driverRoute = routes.some((route: Route) => route.type == PathType.DRIVER)
? routes.find((route: Route) => route.type == PathType.DRIVER)
: undefined;
passengerRoute = routes.some(
(route: Route) => route.type == PathType.PASSENGER,
)
? routes.find((route: Route) => route.type == PathType.PASSENGER)
: undefined;
}
const routeProps: RouteProps = {
driverDistance: driverRoute?.distance,
driverDuration: driverRoute?.duration,
passengerDistance: passengerRoute?.distance,
passengerDuration: passengerRoute?.duration,
fwdAzimuth: driverRoute
? driverRoute.fwdAzimuth
: passengerRoute.fwdAzimuth,
backAzimuth: driverRoute
? driverRoute.backAzimuth
: passengerRoute.backAzimuth,
distanceAzimuth: driverRoute
? driverRoute.distanceAzimuth
: passengerRoute.distanceAzimuth,
waypoints: create.waypoints,
points: driverRoute ? driverRoute.points : passengerRoute.points,
};
return new RouteEntity({
id: v4(),
props: routeProps,
});
};
validate(): void {
// entity business rules validation to protect it's invariant before saving entity to a database
}
private static getPaths = (
roles: Role[],
waypoints: WaypointProps[],
): Path[] => {
const paths: Path[] = [];
if (roles.includes(Role.DRIVER) && roles.includes(Role.PASSENGER)) {
if (waypoints.length == 2) {
// 2 points => same route for driver and passenger
paths.push(this.createGenericPath(waypoints));
} else {
paths.push(
this.createDriverPath(waypoints),
this.createPassengerPath(waypoints),
);
}
} else if (roles.includes(Role.DRIVER)) {
paths.push(this.createDriverPath(waypoints));
} else if (roles.includes(Role.PASSENGER)) {
paths.push(this.createPassengerPath(waypoints));
}
return paths;
};
private static createGenericPath = (waypoints: WaypointProps[]): Path =>
this.createPath(waypoints, PathType.GENERIC);
private static createDriverPath = (waypoints: WaypointProps[]): Path =>
this.createPath(waypoints, PathType.DRIVER);
private static createPassengerPath = (waypoints: WaypointProps[]): Path =>
this.createPath(
[waypoints[0], waypoints[waypoints.length - 1]],
PathType.PASSENGER,
);
private static createPath = (
points: WaypointProps[],
type: PathType,
): Path => ({
type,
points,
});
}
// import { IGeodesic } from '../interfaces/geodesic.interface';
// import { Point } from '../types/point.type';
// import { SpacetimePoint } from './spacetime-point';
// export class Route {
// distance: number;
// duration: number;
// fwdAzimuth: number;
// backAzimuth: number;
// distanceAzimuth: number;
// points: Point[];
// spacetimePoints: SpacetimePoint[];
// private geodesic: IGeodesic;
// constructor(geodesic: IGeodesic) {
// this.distance = undefined;
// this.duration = undefined;
// this.fwdAzimuth = undefined;
// this.backAzimuth = undefined;
// this.distanceAzimuth = undefined;
// this.points = [];
// this.spacetimePoints = [];
// this.geodesic = geodesic;
// }
// setPoints = (points: Point[]): void => {
// this.points = points;
// this.setAzimuth(points);
// };
// setSpacetimePoints = (spacetimePoints: SpacetimePoint[]): void => {
// this.spacetimePoints = spacetimePoints;
// };
// protected setAzimuth = (points: Point[]): void => {
// const inverse = this.geodesic.inverse(
// points[0].lon,
// points[0].lat,
// points[points.length - 1].lon,
// points[points.length - 1].lat,
// );
// this.fwdAzimuth =
// inverse.azimuth >= 0 ? inverse.azimuth : 360 - Math.abs(inverse.azimuth);
// this.backAzimuth =
// this.fwdAzimuth > 180 ? this.fwdAzimuth - 180 : this.fwdAzimuth + 180;
// this.distanceAzimuth = inverse.distance;
// };
// }

View File

@ -0,0 +1,21 @@
import { ExceptionBase } from '@mobicoop/ddd-library';
export class RouteNotFoundException extends ExceptionBase {
static readonly message = 'Route not found';
public readonly code = 'ROUTE.NOT_FOUND';
constructor(cause?: Error, metadata?: unknown) {
super(RouteNotFoundException.message, cause, metadata);
}
}
export class GeorouterUnavailableException extends ExceptionBase {
static readonly message = 'Georouter unavailable';
public readonly code = 'GEOROUTER.UNAVAILABLE';
constructor(cause?: Error, metadata?: unknown) {
super(GeorouterUnavailableException.message, cause, metadata);
}
}

View File

@ -0,0 +1,67 @@
import { GeorouterPort } from '../application/ports/georouter.port';
import { GeorouterSettings } from '../application/types/georouter-settings.type';
import { CoordinatesProps } from './value-objects/coordinates.value-object';
import { SpacetimePointProps } from './value-objects/spacetime-point.value-object';
import { WaypointProps } from './value-objects/waypoint.value-object';
// All properties that a Route has
export interface RouteProps {
driverDistance?: number;
driverDuration?: number;
passengerDistance?: number;
passengerDuration?: number;
fwdAzimuth: number;
backAzimuth: number;
distanceAzimuth: number;
waypoints: WaypointProps[];
points: SpacetimePointProps[] | CoordinatesProps[];
}
// Properties that are needed for a Route creation
export interface CreateRouteProps {
roles: Role[];
waypoints: WaypointProps[];
georouter: GeorouterPort;
georouterSettings: GeorouterSettings;
}
export type Route = {
type: PathType;
distance: number;
duration: number;
fwdAzimuth: number;
backAzimuth: number;
distanceAzimuth: number;
points: Coordinates[];
spacetimeWaypoints: SpacetimePoint[];
};
export type Path = {
type: PathType;
points: Coordinates[];
};
export type Coordinates = {
lon: number;
lat: number;
};
export type Waypoint = Coordinates & {
position: number;
};
export type SpacetimePoint = Coordinates & {
duration: number;
distance: number;
};
export enum Role {
DRIVER = 'DRIVER',
PASSENGER = 'PASSENGER',
}
export enum PathType {
GENERIC = 'generic',
DRIVER = 'driver',
PASSENGER = 'passenger',
}

View File

@ -0,0 +1,31 @@
import {
ArgumentOutOfRangeException,
ValueObject,
} from '@mobicoop/ddd-library';
/** Note:
* Value Objects with multiple properties can contain
* other Value Objects inside if needed.
* */
export interface CoordinatesProps {
lon: number;
lat: number;
}
export class Coordinates extends ValueObject<CoordinatesProps> {
get lon(): number {
return this.props.lon;
}
get lat(): number {
return this.props.lat;
}
protected validate(props: CoordinatesProps): void {
if (props.lon > 180 || props.lon < -180)
throw new ArgumentOutOfRangeException('lon must be between -180 and 180');
if (props.lat > 90 || props.lat < -90)
throw new ArgumentOutOfRangeException('lat must be between -90 and 90');
}
}

View File

@ -0,0 +1,50 @@
import {
ArgumentInvalidException,
ArgumentOutOfRangeException,
ValueObject,
} from '@mobicoop/ddd-library';
/** Note:
* Value Objects with multiple properties can contain
* other Value Objects inside if needed.
* */
export interface SpacetimePointProps {
lon: number;
lat: number;
duration: number;
distance: number;
}
export class SpacetimePoint extends ValueObject<SpacetimePointProps> {
get lon(): number {
return this.props.lon;
}
get lat(): number {
return this.props.lat;
}
get duration(): number {
return this.props.duration;
}
get distance(): number {
return this.props.distance;
}
protected validate(props: SpacetimePointProps): void {
if (props.duration < 0)
throw new ArgumentInvalidException(
'duration must be greater than or equal to 0',
);
if (props.distance < 0)
throw new ArgumentInvalidException(
'distance must be greater than or equal to 0',
);
if (props.lon > 180 || props.lon < -180)
throw new ArgumentOutOfRangeException('lon must be between -180 and 180');
if (props.lat > 90 || props.lat < -90)
throw new ArgumentOutOfRangeException('lat must be between -90 and 90');
}
}

View File

@ -0,0 +1,41 @@
import {
ArgumentInvalidException,
ArgumentOutOfRangeException,
ValueObject,
} from '@mobicoop/ddd-library';
/** Note:
* Value Objects with multiple properties can contain
* other Value Objects inside if needed.
* */
export interface WaypointProps {
position: number;
lon: number;
lat: number;
}
export class Waypoint extends ValueObject<WaypointProps> {
get position(): number {
return this.props.position;
}
get lon(): number {
return this.props.lon;
}
get lat(): number {
return this.props.lat;
}
protected validate(props: WaypointProps): void {
if (props.position < 0)
throw new ArgumentInvalidException(
'position must be greater than or equal to 0',
);
if (props.lon > 180 || props.lon < -180)
throw new ArgumentOutOfRangeException('lon must be between -180 and 180');
if (props.lat > 90 || props.lat < -90)
throw new ArgumentOutOfRangeException('lat must be between -90 and 90');
}
}

View File

@ -1,19 +0,0 @@
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,48 +0,0 @@
import { IGeodesic } from '../interfaces/geodesic.interface';
import { Point } from '../types/point.type';
import { SpacetimePoint } from './spacetime-point';
export class Route {
distance: number;
duration: number;
fwdAzimuth: number;
backAzimuth: number;
distanceAzimuth: number;
points: Point[];
spacetimePoints: SpacetimePoint[];
private geodesic: IGeodesic;
constructor(geodesic: IGeodesic) {
this.distance = undefined;
this.duration = undefined;
this.fwdAzimuth = undefined;
this.backAzimuth = undefined;
this.distanceAzimuth = undefined;
this.points = [];
this.spacetimePoints = [];
this.geodesic = geodesic;
}
setPoints = (points: Point[]): void => {
this.points = points;
this.setAzimuth(points);
};
setSpacetimePoints = (spacetimePoints: SpacetimePoint[]): void => {
this.spacetimePoints = spacetimePoints;
};
protected setAzimuth = (points: Point[]): void => {
const inverse = this.geodesic.inverse(
points[0].lon,
points[0].lat,
points[points.length - 1].lon,
points[points.length - 1].lat,
);
this.fwdAzimuth =
inverse.azimuth >= 0 ? inverse.azimuth : 360 - Math.abs(inverse.azimuth);
this.backAzimuth =
this.fwdAzimuth > 180 ? this.fwdAzimuth - 180 : this.fwdAzimuth + 180;
this.distanceAzimuth = inverse.distance;
};
}

View File

@ -1,13 +0,0 @@
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

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

View File

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

View File

@ -1,7 +0,0 @@
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

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

View File

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

View File

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

View File

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

View File

@ -1,7 +0,0 @@
export enum PointType {
HOUSE_NUMBER = 'HOUSE_NUMBER',
STREET_ADDRESS = 'STREET_ADDRESS',
LOCALITY = 'LOCALITY',
VENUE = 'VENUE',
OTHER = 'OTHER',
}

View File

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

View File

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

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