Compare commits

..

28 Commits

Author SHA1 Message Date
Romain Thouvenin cc9b45c6a1 Handle ad.updated integration events 2024-05-16 17:16:51 +02:00
Romain Thouvenin 3be2d73c60 Implement the UpdateAdCommand 2024-05-16 17:16:51 +02:00
Romain Thouvenin 34ad357f47 Refactor AdEntity creation in a factory in the domain layer 2024-05-16 17:16:51 +02:00
Romain Thouvenin 2d9409d147 Upgrade ddd-library 2024-05-16 17:16:51 +02:00
Romain Thouvenin 35e8de4cfa Improve the driver search query to make use of the toDate index 2024-05-15 08:23:44 +02:00
Romain Thouvenin e2beba299b Take care of candidate processing peculiarities of having pax and driver depart on different day 2024-05-15 08:23:44 +02:00
Romain Thouvenin 3c65582d8e Improve support for trips that span more than one date
- Remove the where clause on schedule times, as it is not possible to compute a sensible driver date within the SQL query.
- Simplify the date clause comparisons
- Replace the compared date range with one adjusted with the driver duration, to catch ads that depart on a different day
2024-05-15 08:23:44 +02:00
Romain Thouvenin a9f5c36d49 Add integration tests for PassengerOrientedSelector to reproduce Redmine#7812 2024-05-15 08:23:44 +02:00
Romain Thouvenin f6b27978e9 Refactor ad props fixtures of integration tests into a separate re-usable file 2024-05-15 08:23:44 +02:00
Romain Thouvenin 0ef0a1dd39 Refactor testing module for integration tests into re-usable function 2024-05-07 17:03:26 +02:00
Romain Thouvenin 7fa1ac7fe6 Fix tests path in build config 2024-05-07 17:03:26 +02:00
Romain Thouvenin 18486012c6 Prepare release 1.8 2024-05-07 10:19:46 +02:00
Romain Thouvenin d48d01f051 Implement distributed event handler to propagate ad deletion 2024-04-25 16:06:59 +02:00
Romain Thouvenin 1701fbbeb1 Implement a DeleteAdCommand 2024-04-25 16:06:59 +02:00
Romain Thouvenin a7c281d740 fix retrieval of DB records not including schedule or waypoints 2024-04-25 15:14:35 +02:00
Romain Thouvenin 01ebac7e74 DRY the pax/driver schedule adjustment logic 2024-04-12 11:33:17 +02:00
Romain Thouvenin 104559d03d Add a test for punctual search matching a recurrent driver schedule 2024-04-05 11:44:10 +02:00
Romain Thouvenin 173e5ebba5 Rename schedule fixtures for better readability of tests 2024-04-05 11:43:30 +02:00
Romain Thouvenin 0dc01da2b0 Combine journey creation and filtering to avoid missing results 2024-04-04 17:24:23 +02:00
Sylvain Briat 945ce80840 improve documentation 2024-04-03 13:39:09 +02:00
Sylvain Briat 16ebe8d543 handle excluded ad in query and selector 2024-04-03 13:02:31 +02:00
Sylvain Briat 0c29e522ed add excludedAdId to proto and dto 2024-04-03 13:01:12 +02:00
Romain Thouvenin c51c368d83 Remove the controller-level cache from MatchGrpcController 2024-04-03 07:52:37 +00:00
Sylvain Briat 0446d267ef improve creation of schedules 2024-04-02 11:14:05 +00:00
Sylvain Briat 100fb3487d add possibility to have more than one driver target in carpool path item (eg. when a user adds 2 same waypoints with a different target) 2024-04-02 11:14:05 +00:00
Romain Thouvenin e501bef249 Move test job to a common template 2024-04-02 12:23:03 +02:00
Sylvain Briat 71ac97410a return frequency in match response 2024-04-02 10:06:34 +00:00
Romain Thouvenin 5696ac57bd Generic test job that also runs on release merge requests 2024-04-02 12:01:38 +02:00
54 changed files with 1713 additions and 873 deletions

View File

@ -8,25 +8,6 @@ include:
- template: Security/SAST.gitlab-ci.yml - template: Security/SAST.gitlab-ci.yml
- template: Security/Secret-Detection.gitlab-ci.yml - template: Security/Secret-Detection.gitlab-ci.yml
- project: mobicoop/v3/gitlab-templates - project: mobicoop/v3/gitlab-templates
file: /ci/release.build-job.yml file:
- /ci/release.build-job.yml
############## - /ci/service.test-job.yml
# TEST STAGE #
##############
test:
stage: test
image: docker/compose:latest
variables:
DOCKER_TLS_CERTDIR: ''
services:
- docker:dind
script:
- docker-compose -f docker-compose.ci.tools.yml -p matcher-tools --env-file ci/.env.ci up -d
- sh ci/wait-up.sh
- docker-compose -f docker-compose.ci.service.yml -p matcher-service --env-file ci/.env.ci up -d
- docker exec -t v3-matcher-api sh -c "npm run test:integration:ci"
coverage: /All files[^|]*\|[^|]*\s+([\d\.]+)/
rules:
- if: '$CI_MERGE_REQUEST_TARGET_BRANCH_NAME == $CI_DEFAULT_BRANCH || $CI_COMMIT_MESSAGE =~ /--check/ || $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
when: always

View File

@ -167,6 +167,7 @@ The app exposes the following [gRPC](https://grpc.io/) services :
- **seatsProposed** (integer, optional): number of seats proposed as driver (_default : 3_) - **seatsProposed** (integer, optional): number of seats proposed as driver (_default : 3_)
- **seatsRequested** (integer, optional): number of seats requested as passenger (_default : 1_) - **seatsRequested** (integer, optional): number of seats requested as passenger (_default : 1_)
- **waypoints**: an array of addresses that represent the waypoints of the journey (only first and last waypoints are used for passenger ads). Note that positions are **required** and **must** be consecutives - **waypoints**: an array of addresses that represent the waypoints of the journey (only first and last waypoints are used for passenger ads). Note that positions are **required** and **must** be consecutives
- **excludedAdId** (optional): the id of an ad to be excluded from the results (useful to avoid self-matchings)
- **algorithmType** (optional): the type of algorithm to use (as of 2023-09-28, only the default `PASSENGER_ORIENTED` is accepted) - **algorithmType** (optional): the type of algorithm to use (as of 2023-09-28, only the default `PASSENGER_ORIENTED` is accepted)
- **remoteness** (integer, optional): an integer to indicate the maximum flying distance (in metres) between the driver route and the passenger pick-up / drop-off points (_default : 15000_) - **remoteness** (integer, optional): an integer to indicate the maximum flying distance (in metres) between the driver route and the passenger pick-up / drop-off points (_default : 15000_)
- **useProportion** (boolean, optional): a boolean to indicate if the matching algorithm will compare the distance of the passenger route against the distance of the driver route (_default : 1_). Works in combination with **proportion** parameter - **useProportion** (boolean, optional): a boolean to indicate if the matching algorithm will compare the distance of the passenger route against the distance of the driver route (_default : 1_). Works in combination with **proportion** parameter
@ -218,10 +219,6 @@ If the matching is successful, you will get a result, containing :
Matching is a time-consuming process, so the results of a matching request are stored in cache before being paginated and returned to the requester. Matching is a time-consuming process, so the results of a matching request are stored in cache before being paginated and returned to the requester.
An id is attributed to the overall results of a request : on further requests (for example to query for different pages of results), the requester can provide this id and get in return the cached data, avoiding another longer process of computing the results from scratch. Obviously, new computing must be done periodically to get fresh new results ! An id is attributed to the overall results of a request : on further requests (for example to query for different pages of results), the requester can provide this id and get in return the cached data, avoiding another longer process of computing the results from scratch. Obviously, new computing must be done periodically to get fresh new results !
There's also a basic cache to store the results of the _same_ request sent multiple times successively.
Cache TTLs are customizable in the `.env` file.
## Tests / ESLint / Prettier ## Tests / ESLint / Prettier
Tests are run outside the container for ease of use (switching between different environments inside containers using prisma is complicated and error prone). Tests are run outside the container for ease of use (switching between different environments inside containers using prisma is complicated and error prone).

12
package-lock.json generated
View File

@ -1,18 +1,18 @@
{ {
"name": "@mobicoop/matcher", "name": "@mobicoop/matcher",
"version": "1.5.5", "version": "1.8.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@mobicoop/matcher", "name": "@mobicoop/matcher",
"version": "1.5.5", "version": "1.8.0",
"license": "AGPL", "license": "AGPL",
"dependencies": { "dependencies": {
"@grpc/grpc-js": "^1.9.14", "@grpc/grpc-js": "^1.9.14",
"@grpc/proto-loader": "^0.7.10", "@grpc/proto-loader": "^0.7.10",
"@mobicoop/configuration-module": "^8.0.0", "@mobicoop/configuration-module": "^8.0.0",
"@mobicoop/ddd-library": "^2.4.3", "@mobicoop/ddd-library": "^2.5.0",
"@mobicoop/health-module": "^2.3.2", "@mobicoop/health-module": "^2.3.2",
"@mobicoop/message-broker-module": "^2.1.2", "@mobicoop/message-broker-module": "^2.1.2",
"@nestjs/axios": "^3.0.1", "@nestjs/axios": "^3.0.1",
@ -1881,9 +1881,9 @@
} }
}, },
"node_modules/@mobicoop/ddd-library": { "node_modules/@mobicoop/ddd-library": {
"version": "2.4.3", "version": "2.5.0",
"resolved": "https://registry.npmjs.org/@mobicoop/ddd-library/-/ddd-library-2.4.3.tgz", "resolved": "https://registry.npmjs.org/@mobicoop/ddd-library/-/ddd-library-2.5.0.tgz",
"integrity": "sha512-HxNtAfov8ne7XsFTSIDI811r3L1VDV9YUikgX7HPjrB8u2gQh6FQFnIz3Fjb/zWOGxrDEIy8HEM0AYmXkf8ULA==", "integrity": "sha512-dTx7KTILs53HCqNx0BDVTzIZxfPW3pi0fZ4UMw/vDNm3oTqGA+jg7YBfNxn8yadM+j2dDIN5Kum43CmKGH8yYA==",
"dependencies": { "dependencies": {
"@nestjs/event-emitter": "^2.0.3", "@nestjs/event-emitter": "^2.0.3",
"@nestjs/microservices": "^10.3.0", "@nestjs/microservices": "^10.3.0",

View File

@ -1,6 +1,6 @@
{ {
"name": "@mobicoop/matcher", "name": "@mobicoop/matcher",
"version": "1.6.0", "version": "1.8.0",
"description": "Mobicoop V3 Matcher", "description": "Mobicoop V3 Matcher",
"author": "sbriat", "author": "sbriat",
"private": true, "private": true,
@ -22,7 +22,7 @@
"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 --runInBand", "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/' --runInBand", "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:watch": "jest --testPathPattern 'tests/unit/' --watch",
"test:e2e": "jest --config ./test/jest-e2e.json", "test:e2e": "jest --config ./test/jest-e2e.json",
"migrate": "docker exec v3-matcher-api sh -c 'npx prisma migrate deploy'", "migrate": "docker exec v3-matcher-api sh -c 'npx prisma migrate deploy'",
"migrate:dev": "docker exec v3-matcher-api sh -c 'npx prisma migrate dev'", "migrate:dev": "docker exec v3-matcher-api sh -c 'npx prisma migrate dev'",
@ -35,7 +35,7 @@
"@grpc/proto-loader": "^0.7.10", "@grpc/proto-loader": "^0.7.10",
"@songkeys/nestjs-redis": "^10.0.0", "@songkeys/nestjs-redis": "^10.0.0",
"@mobicoop/configuration-module": "^8.0.0", "@mobicoop/configuration-module": "^8.0.0",
"@mobicoop/ddd-library": "^2.4.3", "@mobicoop/ddd-library": "^2.5.0",
"@mobicoop/health-module": "^2.3.2", "@mobicoop/health-module": "^2.3.2",
"@mobicoop/message-broker-module": "^2.1.2", "@mobicoop/message-broker-module": "^2.1.2",
"@nestjs/axios": "^3.0.1", "@nestjs/axios": "^3.0.1",

View File

@ -10,11 +10,19 @@ export const GRPC_GEOROUTER_SERVICE_NAME = 'GeorouterService';
export const MATCHER_AD_CREATED_ROUTING_KEY = 'matcher-ad.created'; export const MATCHER_AD_CREATED_ROUTING_KEY = 'matcher-ad.created';
export const MATCHER_AD_CREATION_FAILED_ROUTING_KEY = export const MATCHER_AD_CREATION_FAILED_ROUTING_KEY =
'matcher-ad.creation-failed'; 'matcher-ad.creation-failed';
export const MATCHER_AD_UPDATED_ROUTING_KEY = 'matcher-ad.updated';
export const MATCHER_AD_UPDATE_FAILED_ROUTING_KEY = 'matcher-ad.update-failed';
// messaging input // messaging input
export const AD_CREATED_MESSAGE_HANDLER = 'adCreated'; export const AD_CREATED_MESSAGE_HANDLER = 'adCreated';
export const AD_CREATED_ROUTING_KEY = 'ad.created'; export const AD_CREATED_ROUTING_KEY = 'ad.created';
export const AD_CREATED_QUEUE = 'matcher.ad.created'; export const AD_CREATED_QUEUE = 'matcher.ad.created';
export const AD_UPDATED_MESSAGE_HANDLER = 'adUpdated';
export const AD_UPDATED_ROUTING_KEY = 'ad.updated';
export const AD_UPDATED_QUEUE = 'matcher.ad.updated';
export const AD_DELETED_MESSAGE_HANDLER = 'adDeleted';
export const AD_DELETED_ROUTING_KEY = 'ad.deleted';
export const AD_DELETED_QUEUE = 'matcher.ad.deleted';
// health // health
export const GRPC_HEALTH_PACKAGE_NAME = 'health'; export const GRPC_HEALTH_PACKAGE_NAME = 'health';

View File

@ -1,19 +1,20 @@
import { ExtendedMapper } from '@mobicoop/ddd-library';
import { DirectionEncoderPort } from '@modules/geography/core/application/ports/direction-encoder.port';
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { AdEntity } from './core/domain/ad.entity';
import {
AdWriteModel,
AdReadModel,
ScheduleItemModel,
AdWriteExtraModel,
} from './infrastructure/ad.repository';
import { v4 } from 'uuid'; import { v4 } from 'uuid';
import { AD_DIRECTION_ENCODER } from './ad.di-tokens';
import { AdEntity } from './core/domain/ad.entity';
import { import {
ScheduleItem, ScheduleItem,
ScheduleItemProps, ScheduleItemProps,
} from './core/domain/value-objects/schedule-item.value-object'; } from './core/domain/value-objects/schedule-item.value-object';
import { DirectionEncoderPort } from '@modules/geography/core/application/ports/direction-encoder.port'; import {
import { AD_DIRECTION_ENCODER } from './ad.di-tokens'; AdReadModel,
import { ExtendedMapper } from '@mobicoop/ddd-library'; AdWriteExtraModel,
AdWriteModel,
ScheduleItemModel,
ScheduleWriteModel,
} from './infrastructure/ad.repository';
/** /**
* Mapper constructs objects that are used in different layers: * Mapper constructs objects that are used in different layers:
@ -38,9 +39,8 @@ export class AdMapper
private readonly directionEncoder: DirectionEncoderPort, private readonly directionEncoder: DirectionEncoderPort,
) {} ) {}
toPersistence = (entity: AdEntity): AdWriteModel => { toPersistence = (entity: AdEntity, update?: boolean): AdWriteModel => {
const copy = entity.getProps(); const copy = entity.getProps();
const now = new Date();
const record: AdWriteModel = { const record: AdWriteModel = {
uuid: copy.id, uuid: copy.id,
driver: copy.driver, driver: copy.driver,
@ -48,8 +48,27 @@ export class AdMapper
frequency: copy.frequency, frequency: copy.frequency,
fromDate: new Date(copy.fromDate), fromDate: new Date(copy.fromDate),
toDate: new Date(copy.toDate), toDate: new Date(copy.toDate),
schedule: { schedule: this.toScheduleItemWriteModel(copy.schedule, update),
create: copy.schedule.map((scheduleItem: ScheduleItemProps) => ({ 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,
};
return record;
};
toScheduleItemWriteModel = (
schedule: ScheduleItemProps[],
update?: boolean,
): ScheduleWriteModel => {
const now = new Date();
const record: ScheduleWriteModel = {
create: schedule.map((scheduleItem: ScheduleItemProps) => ({
uuid: v4(), uuid: v4(),
day: scheduleItem.day, day: scheduleItem.day,
time: new Date( time: new Date(
@ -63,19 +82,12 @@ export class AdMapper
createdAt: now, createdAt: now,
updatedAt: 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,
}; };
if (update) {
record.deleteMany = {
createdAt: { lt: now },
};
}
return record; return record;
}; };
@ -97,7 +109,7 @@ export class AdMapper
frequency: record.frequency, frequency: record.frequency,
fromDate: record.fromDate.toISOString().split('T')[0], fromDate: record.fromDate.toISOString().split('T')[0],
toDate: record.toDate.toISOString().split('T')[0], toDate: record.toDate.toISOString().split('T')[0],
schedule: record.schedule.map( schedule: record.schedule?.map(
(scheduleItem: ScheduleItemModel) => (scheduleItem: ScheduleItemModel) =>
new ScheduleItem({ new ScheduleItem({
day: scheduleItem.day, day: scheduleItem.day,
@ -111,12 +123,14 @@ export class AdMapper
margin: scheduleItem.margin, margin: scheduleItem.margin,
}), }),
), ),
waypoints: this.directionEncoder waypoints: record.waypoints
? this.directionEncoder
.decode(record.waypoints) .decode(record.waypoints)
.map((coordinates, index) => ({ .map((coordinates, index) => ({
position: index, position: index,
...coordinates, ...coordinates,
})), }))
: [],
fwdAzimuth: record.fwdAzimuth, fwdAzimuth: record.fwdAzimuth,
backAzimuth: record.backAzimuth, backAzimuth: record.backAzimuth,
points: [], points: [],

View File

@ -1,49 +1,53 @@
import { Module, Provider } from '@nestjs/common'; import { ConfigurationRepository } from '@mobicoop/configuration-module';
import { CqrsModule } from '@nestjs/cqrs';
import {
AD_MESSAGE_PUBLISHER,
AD_REPOSITORY,
AD_DIRECTION_ENCODER,
AD_ROUTE_PROVIDER,
TIMEZONE_FINDER,
TIME_CONVERTER,
INPUT_DATETIME_TRANSFORMER,
OUTPUT_DATETIME_TRANSFORMER,
MATCHING_REPOSITORY,
AD_CONFIGURATION_REPOSITORY,
GEOGRAPHY_PACKAGE,
} from './ad.di-tokens';
import { MessageBrokerPublisher } from '@mobicoop/message-broker-module'; 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 { GeographyModule } from '@modules/geography/geography.module'; import { GeographyModule } from '@modules/geography/geography.module';
import { CreateAdService } from './core/application/commands/create-ad/create-ad.service'; import { PostgresDirectionEncoder } from '@modules/geography/infrastructure/postgres-direction-encoder';
import { MatchGrpcController } from './interface/grpc-controllers/match.grpc-controller';
import { MatchQueryHandler } from './core/application/queries/match/match.query-handler';
import { TimezoneFinder } from './infrastructure/timezone-finder';
import { TimeConverter } from './infrastructure/time-converter';
import { InputDateTimeTransformer } from './infrastructure/input-datetime-transformer';
import { MatchMapper } from './match.mapper';
import { OutputDateTimeTransformer } from './infrastructure/output-datetime-transformer';
import { MatchingRepository } from './infrastructure/matching.repository';
import { MatchingMapper } from './matching.mapper';
import { CacheModule } from '@nestjs/cache-manager'; import { CacheModule } from '@nestjs/cache-manager';
import { Module, Provider } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config'; import { ConfigModule, ConfigService } from '@nestjs/config';
import { redisStore } from 'cache-manager-ioredis-yet'; import { CqrsModule } from '@nestjs/cqrs';
import { ClientsModule, Transport } from '@nestjs/microservices';
import { import {
RedisClientOptions, RedisClientOptions,
RedisModule, RedisModule,
RedisModuleOptions, RedisModuleOptions,
} from '@songkeys/nestjs-redis'; } from '@songkeys/nestjs-redis';
import { ConfigurationRepository } from '@mobicoop/configuration-module';
import { PublishMessageWhenMatcherAdIsCreatedDomainEventHandler } from './core/application/event-handlers/publish-message-when-matcher-ad-is-created.domain-event-handler';
import { Georouter } from './infrastructure/georouter';
import { ClientsModule, Transport } from '@nestjs/microservices';
import { GRPC_GEOGRAPHY_PACKAGE_NAME } from '@src/app.constants'; import { GRPC_GEOGRAPHY_PACKAGE_NAME } from '@src/app.constants';
import { redisStore } from 'cache-manager-ioredis-yet';
import { join } from 'path'; import { join } from 'path';
import {
AD_CONFIGURATION_REPOSITORY,
AD_DIRECTION_ENCODER,
AD_MESSAGE_PUBLISHER,
AD_REPOSITORY,
AD_ROUTE_PROVIDER,
GEOGRAPHY_PACKAGE,
INPUT_DATETIME_TRANSFORMER,
MATCHING_REPOSITORY,
OUTPUT_DATETIME_TRANSFORMER,
TIMEZONE_FINDER,
TIME_CONVERTER,
} from './ad.di-tokens';
import { AdMapper } from './ad.mapper';
import { CreateAdService } from './core/application/commands/create-ad/create-ad.service';
import { DeleteAdService } from './core/application/commands/delete-ad/delete-ad.service';
import { UpdateAdService } from './core/application/commands/update-ad/update-ad.service';
import { PublishMessageWhenMatcherAdIsCreatedDomainEventHandler } from './core/application/event-handlers/publish-message-when-matcher-ad-is-created.domain-event-handler';
import { MatchQueryHandler } from './core/application/queries/match/match.query-handler';
import { AdRepository } from './infrastructure/ad.repository';
import { Georouter } from './infrastructure/georouter';
import { InputDateTimeTransformer } from './infrastructure/input-datetime-transformer';
import { MatchingRepository } from './infrastructure/matching.repository';
import { OutputDateTimeTransformer } from './infrastructure/output-datetime-transformer';
import { PrismaService } from './infrastructure/prisma.service';
import { TimeConverter } from './infrastructure/time-converter';
import { TimezoneFinder } from './infrastructure/timezone-finder';
import { MatchGrpcController } from './interface/grpc-controllers/match.grpc-controller';
import { AdCreatedMessageHandler } from './interface/message-handlers/ad-created.message-handler';
import { AdDeletedMessageHandler } from './interface/message-handlers/ad-deleted.message-handler';
import { AdUpdatedMessageHandler } from './interface/message-handlers/ad-updated.message-handler';
import { MatchMapper } from './match.mapper';
import { MatchingMapper } from './matching.mapper';
const imports = [ const imports = [
CqrsModule, CqrsModule,
@ -96,13 +100,21 @@ const imports = [
const grpcControllers = [MatchGrpcController]; const grpcControllers = [MatchGrpcController];
const messageHandlers = [AdCreatedMessageHandler]; const messageHandlers = [
AdCreatedMessageHandler,
AdUpdatedMessageHandler,
AdDeletedMessageHandler,
];
const eventHandlers: Provider[] = [ const eventHandlers: Provider[] = [
PublishMessageWhenMatcherAdIsCreatedDomainEventHandler, PublishMessageWhenMatcherAdIsCreatedDomainEventHandler,
]; ];
const commandHandlers: Provider[] = [CreateAdService]; const commandHandlers: Provider[] = [
CreateAdService,
UpdateAdService,
DeleteAdService,
];
const queryHandlers: Provider[] = [MatchQueryHandler]; const queryHandlers: Provider[] = [MatchQueryHandler];

View File

@ -1,9 +1,9 @@
import { Frequency } from '@modules/ad/core/domain/ad.types';
import { Command, CommandProps } from '@mobicoop/ddd-library'; import { Command, CommandProps } from '@mobicoop/ddd-library';
import { ScheduleItem } from '../../types/schedule-item.type'; import { Frequency, UserAd } from '@modules/ad/core/domain/ad.types';
import { Address } from '../../types/address.type'; import { Address } from '../../types/address.type';
import { ScheduleItem } from '../../types/schedule-item.type';
export class CreateAdCommand extends Command { export class CreateAdCommand extends Command implements UserAd {
readonly id: string; readonly id: string;
readonly driver: boolean; readonly driver: boolean;
readonly passenger: boolean; readonly passenger: boolean;

View File

@ -1,32 +1,22 @@
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import { CreateAdCommand } from './create-ad.command';
import { Inject } from '@nestjs/common';
import {
AD_MESSAGE_PUBLISHER,
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 { import {
AggregateID, AggregateID,
ConflictException, ConflictException,
MessagePublisherPort, MessagePublisherPort,
} from '@mobicoop/ddd-library'; } from '@mobicoop/ddd-library';
import { AdAlreadyExistsException } from '@modules/ad/core/domain/ad.errors';
import { Role } from '@modules/ad/core/domain/ad.types';
import { import {
Path, AD_MESSAGE_PUBLISHER,
PathCreator, AD_REPOSITORY,
PathType, AD_ROUTE_PROVIDER,
TypedRoute, } from '@modules/ad/ad.di-tokens';
} from '@modules/ad/core/domain/path-creator.service'; import { AdAlreadyExistsException } from '@modules/ad/core/domain/ad.errors';
import { Waypoint } from '../../types/waypoint.type'; import { AdFactory } from '@modules/ad/core/domain/ad.factory';
import { Point as PointValueObject } from '@modules/ad/core/domain/value-objects/point.value-object'; import { Inject } from '@nestjs/common';
import { Point } from '@modules/geography/core/domain/route.types'; import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import { MatcherAdCreationFailedIntegrationEvent } from '../../events/matcher-ad-creation-failed.integration-event';
import { MATCHER_AD_CREATION_FAILED_ROUTING_KEY } from '@src/app.constants'; import { MATCHER_AD_CREATION_FAILED_ROUTING_KEY } from '@src/app.constants';
import { GeorouterPort } from '../../ports/georouter.port'; import { GeorouterService } from '../../../domain/georouter.service';
import { MatcherAdCreationFailedIntegrationEvent } from '../../events/matcher-ad-failure.integration-event';
import { AdRepositoryPort } from '../../ports/ad.repository.port';
import { CreateAdCommand } from './create-ad.command';
@CommandHandler(CreateAdCommand) @CommandHandler(CreateAdCommand)
export class CreateAdService implements ICommandHandler { export class CreateAdService implements ICommandHandler {
@ -36,106 +26,16 @@ export class CreateAdService implements ICommandHandler {
@Inject(AD_REPOSITORY) @Inject(AD_REPOSITORY)
private readonly repository: AdRepositoryPort, private readonly repository: AdRepositoryPort,
@Inject(AD_ROUTE_PROVIDER) @Inject(AD_ROUTE_PROVIDER)
private readonly routeProvider: GeorouterPort, private readonly routeProvider: GeorouterService,
) {} ) {}
async execute(command: CreateAdCommand): Promise<AggregateID> { async execute(command: CreateAdCommand): Promise<AggregateID> {
const roles: Role[] = []; try {
if (command.driver) roles.push(Role.DRIVER); const adFactory = new AdFactory(this.routeProvider);
if (command.passenger) roles.push(Role.PASSENGER); const ad = await adFactory.create(command);
const pathCreator: PathCreator = new PathCreator(
roles,
command.waypoints.map(
(waypoint: Waypoint) =>
new PointValueObject({
lon: waypoint.lon,
lat: waypoint.lat,
}),
),
);
let typedRoutes: TypedRoute[];
let driverDistance: number | undefined;
let driverDuration: number | undefined;
let passengerDistance: number | undefined;
let passengerDuration: number | undefined;
let points: PointValueObject[] | undefined;
let fwdAzimuth: number | undefined;
let backAzimuth: number | undefined;
try {
try {
typedRoutes = await Promise.all(
pathCreator.getBasePaths().map(async (path: Path) => ({
type: path.type,
route: await this.routeProvider.getRoute({
waypoints: path.waypoints,
}),
})),
);
} catch (e: any) {
throw new Error('Unable to find a route for given waypoints');
}
try {
typedRoutes.forEach((typedRoute: TypedRoute) => {
if ([PathType.DRIVER, PathType.GENERIC].includes(typedRoute.type)) {
driverDistance = typedRoute.route.distance;
driverDuration = typedRoute.route.duration;
points = typedRoute.route.points.map(
(point: Point) =>
new PointValueObject({
lon: point.lon,
lat: point.lat,
}),
);
fwdAzimuth = typedRoute.route.fwdAzimuth;
backAzimuth = typedRoute.route.backAzimuth;
}
if (
[PathType.PASSENGER, PathType.GENERIC].includes(typedRoute.type)
) {
passengerDistance = typedRoute.route.distance;
passengerDuration = typedRoute.route.duration;
if (!points)
points = typedRoute.route.points.map(
(point: Point) =>
new PointValueObject({
lon: point.lon,
lat: point.lat,
}),
);
if (!fwdAzimuth) fwdAzimuth = typedRoute.route.fwdAzimuth;
if (!backAzimuth) backAzimuth = typedRoute.route.backAzimuth;
}
});
} catch (error: any) {
throw new Error('Invalid route');
}
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: points as PointValueObject[],
driverDistance,
driverDuration,
passengerDistance,
passengerDuration,
fwdAzimuth: fwdAzimuth as number,
backAzimuth: backAzimuth as number,
});
try { try {
//TODO it should not be this service's concern that Prisma does not support postgis types
await this.repository.insertExtra(ad, 'ad'); await this.repository.insertExtra(ad, 'ad');
return ad.id; return ad.id;
} catch (error: any) { } catch (error: any) {

View File

@ -0,0 +1,7 @@
import { Command, CommandProps } from '@mobicoop/ddd-library';
export class DeleteAdCommand extends Command {
constructor(props: CommandProps<DeleteAdCommand>) {
super(props);
}
}

View File

@ -0,0 +1,18 @@
import { AD_REPOSITORY } from '@modules/ad/ad.di-tokens';
import { Inject } from '@nestjs/common';
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import { AdRepositoryPort } from '../../ports/ad.repository.port';
import { DeleteAdCommand } from './delete-ad.command';
@CommandHandler(DeleteAdCommand)
export class DeleteAdService implements ICommandHandler {
constructor(
@Inject(AD_REPOSITORY) private readonly adRepository: AdRepositoryPort,
) {}
async execute(command: DeleteAdCommand): Promise<boolean> {
const ad = await this.adRepository.findOneById(command.id);
ad.delete();
return this.adRepository.delete(ad);
}
}

View File

@ -0,0 +1,3 @@
import { CreateAdCommand } from '../create-ad/create-ad.command';
export class UpdateAdCommand extends CreateAdCommand {}

View File

@ -0,0 +1,48 @@
import { MessagePublisherPort } from '@mobicoop/ddd-library';
import {
AD_MESSAGE_PUBLISHER,
AD_REPOSITORY,
AD_ROUTE_PROVIDER,
} from '@modules/ad/ad.di-tokens';
import { AdFactory } from '@modules/ad/core/domain/ad.factory';
import { Inject } from '@nestjs/common';
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import { MATCHER_AD_UPDATE_FAILED_ROUTING_KEY } from '@src/app.constants';
import { GeorouterService } from '../../../domain/georouter.service';
import { MatcherAdUpdateFailedIntegrationEvent } from '../../events/matcher-ad-failure.integration-event';
import { AdRepositoryPort } from '../../ports/ad.repository.port';
import { UpdateAdCommand } from './update-ad.command';
@CommandHandler(UpdateAdCommand)
export class UpdateAdService implements ICommandHandler {
constructor(
@Inject(AD_MESSAGE_PUBLISHER)
private readonly messagePublisher: MessagePublisherPort,
@Inject(AD_REPOSITORY)
private readonly repository: AdRepositoryPort,
@Inject(AD_ROUTE_PROVIDER)
private readonly routeProvider: GeorouterService,
) {}
async execute(command: UpdateAdCommand): Promise<void> {
try {
const adFactory = new AdFactory(this.routeProvider);
const ad = await adFactory.create(command);
return this.repository.update(ad.id, ad);
} catch (error: any) {
const integrationEvent = new MatcherAdUpdateFailedIntegrationEvent({
id: command.id,
metadata: {
correlationId: command.id,
timestamp: Date.now(),
},
cause: error.message,
});
this.messagePublisher.publish(
MATCHER_AD_UPDATE_FAILED_ROUTING_KEY,
JSON.stringify(integrationEvent),
);
throw error;
}
}
}

View File

@ -1,12 +0,0 @@
import { IntegrationEvent, IntegrationEventProps } from '@mobicoop/ddd-library';
export class MatcherAdCreationFailedIntegrationEvent extends IntegrationEvent {
readonly cause?: string;
constructor(
props: IntegrationEventProps<MatcherAdCreationFailedIntegrationEvent>,
) {
super(props);
this.cause = props.cause;
}
}

View File

@ -0,0 +1,15 @@
import { IntegrationEvent, IntegrationEventProps } from '@mobicoop/ddd-library';
export class MatcherAdFailureIntegrationEvent extends IntegrationEvent {
readonly cause?: string;
constructor(
props: IntegrationEventProps<MatcherAdCreationFailedIntegrationEvent>,
) {
super(props);
this.cause = props.cause;
}
}
export class MatcherAdCreationFailedIntegrationEvent extends MatcherAdFailureIntegrationEvent {}
export class MatcherAdUpdateFailedIntegrationEvent extends MatcherAdFailureIntegrationEvent {}

View File

@ -1,9 +1,9 @@
import { CandidateEntity } from '@modules/ad/core/domain/candidate.entity'; import { CandidateEntity } from '@modules/ad/core/domain/candidate.entity';
import { Completer } from './completer.abstract';
import { MatchQuery } from '../match.query';
import { Step } from '../../../types/step.type';
import { CarpoolPathItem } from '@modules/ad/core/domain/value-objects/carpool-path-item.value-object'; import { CarpoolPathItem } from '@modules/ad/core/domain/value-objects/carpool-path-item.value-object';
import { RouteResponse } from '../../../ports/georouter.port'; import { RouteResponse } from '../../../../domain/georouter.service';
import { Step } from '../../../types/step.type';
import { MatchQuery } from '../match.query';
import { Completer } from './completer.abstract';
export class RouteCompleter extends Completer { export class RouteCompleter extends Completer {
protected readonly type: RouteCompleterType; protected readonly type: RouteCompleterType;

View File

@ -8,8 +8,8 @@ import {
} from '@modules/ad/core/domain/path-creator.service'; } from '@modules/ad/core/domain/path-creator.service';
import { Point } from '@modules/ad/core/domain/value-objects/point.value-object'; import { Point } from '@modules/ad/core/domain/value-objects/point.value-object';
import { MatchRequestDto } from '@modules/ad/interface/grpc-controllers/dtos/match.request.dto'; import { MatchRequestDto } from '@modules/ad/interface/grpc-controllers/dtos/match.request.dto';
import { GeorouterService } from '../../../domain/georouter.service';
import { DateTimeTransformerPort } from '../../ports/datetime-transformer.port'; import { DateTimeTransformerPort } from '../../ports/datetime-transformer.port';
import { GeorouterPort } from '../../ports/georouter.port';
import { AlgorithmType } from '../../types/algorithm.types'; import { AlgorithmType } from '../../types/algorithm.types';
import { Route } from '../../types/route.type'; import { Route } from '../../types/route.type';
import { Waypoint } from '../../types/waypoint.type'; import { Waypoint } from '../../types/waypoint.type';
@ -26,6 +26,7 @@ export class MatchQuery extends QueryBase {
seatsRequested?: number; seatsRequested?: number;
strict?: boolean; strict?: boolean;
readonly waypoints: Waypoint[]; readonly waypoints: Waypoint[];
excludedAdId?: string;
algorithmType?: AlgorithmType; algorithmType?: AlgorithmType;
remoteness?: number; remoteness?: number;
useProportion?: boolean; useProportion?: boolean;
@ -40,10 +41,10 @@ export class MatchQuery extends QueryBase {
passengerRoute?: Route; passengerRoute?: Route;
backAzimuth?: number; backAzimuth?: number;
private readonly originWaypoint: Waypoint; private readonly originWaypoint: Waypoint;
routeProvider: GeorouterPort; routeProvider: GeorouterService;
// TODO: remove MatchRequestDto depency (here core domain depends on interface /!\) // TODO: remove MatchRequestDto depency (here core domain depends on interface /!\)
constructor(props: MatchRequestDto, routeProvider: GeorouterPort) { constructor(props: MatchRequestDto, routeProvider: GeorouterService) {
super(); super();
this.id = props.id; this.id = props.id;
this.driver = props.driver; this.driver = props.driver;
@ -56,6 +57,7 @@ export class MatchQuery extends QueryBase {
this.seatsRequested = props.seatsRequested; this.seatsRequested = props.seatsRequested;
this.strict = props.strict; this.strict = props.strict;
this.waypoints = props.waypoints; this.waypoints = props.waypoints;
this.excludedAdId = props.excludedAdId;
this.algorithmType = props.algorithmType; this.algorithmType = props.algorithmType;
this.remoteness = props.remoteness; this.remoteness = props.remoteness;
this.useProportion = props.useProportion; this.useProportion = props.useProportion;

View File

@ -1,11 +1,21 @@
import { Frequency, Role } from '@modules/ad/core/domain/ad.types';
import { Selector } from '../algorithm.abstract';
import { Waypoint } from '../../../types/waypoint.type';
import { Point } from '../../../types/point.type';
import { CandidateEntity } from '@modules/ad/core/domain/candidate.entity';
import { AdEntity } from '@modules/ad/core/domain/ad.entity'; import { AdEntity } from '@modules/ad/core/domain/ad.entity';
import { ScheduleItem } from '@modules/ad/core/domain/value-objects/schedule-item.value-object'; import { Frequency, Role } from '@modules/ad/core/domain/ad.types';
import { CandidateEntity } from '@modules/ad/core/domain/candidate.entity';
import { DateInterval } from '../../../../domain/candidate.types';
import { Point } from '../../../types/point.type';
import { Waypoint } from '../../../types/waypoint.type';
import { Selector } from '../algorithm.abstract';
import { ScheduleItem } from '../match.query';
/**
* This class complements the AdRepository prisma service by turning a match query object into a SQL query,
* with the assumption that the query is passenger-oriented (i.e. it is up to the driver to go out of his way to pick up the passenger).
* The idea is to make a rough filter of the ads in DB to limit the number of ads to be processed more precisely by the application code.
* TODO: Converting the query object into a SQL query is a job for the repository implementation
* (or anything behind the repository interface),
* any logic related to being passenger-oriented should be in the domain layer.
* (though it might be difficult to describe generically the search criteria with a query object)
*/
export class PassengerOrientedSelector extends Selector { export class PassengerOrientedSelector extends Selector {
select = async (): Promise<CandidateEntity[]> => { select = async (): Promise<CandidateEntity[]> => {
const queryStringRoles: QueryStringRole[] = []; const queryStringRoles: QueryStringRole[] = [];
@ -19,6 +29,7 @@ export class PassengerOrientedSelector extends Selector {
query: this._createQueryString(Role.PASSENGER), query: this._createQueryString(Role.PASSENGER),
role: Role.PASSENGER, role: Role.PASSENGER,
}); });
return ( return (
await Promise.all( await Promise.all(
queryStringRoles.map<Promise<AdsRole>>( queryStringRoles.map<Promise<AdsRole>>(
@ -36,7 +47,7 @@ export class PassengerOrientedSelector extends Selector {
id: adEntity.id, id: adEntity.id,
role: adsRole.role == Role.DRIVER ? Role.PASSENGER : Role.DRIVER, role: adsRole.role == Role.DRIVER ? Role.PASSENGER : Role.DRIVER,
frequency: adEntity.getProps().frequency, frequency: adEntity.getProps().frequency,
dateInterval: { dateInterval: this._fixDateInterval({
lowerDate: this._maxDateString( lowerDate: this._maxDateString(
this.query.fromDate, this.query.fromDate,
adEntity.getProps().fromDate, adEntity.getProps().fromDate,
@ -45,7 +56,7 @@ export class PassengerOrientedSelector extends Selector {
this.query.toDate, this.query.toDate,
adEntity.getProps().toDate, adEntity.getProps().toDate,
), ),
}, }),
driverWaypoints: driverWaypoints:
adsRole.role == Role.PASSENGER adsRole.role == Role.PASSENGER
? adEntity.getProps().waypoints ? adEntity.getProps().waypoints
@ -134,8 +145,8 @@ export class PassengerOrientedSelector extends Selector {
[ [
this._whereRole(role), this._whereRole(role),
this._whereStrict(), this._whereStrict(),
this._whereDate(), this._whereDate(role),
this._whereSchedule(role), this._whereExcludedAd(),
this._whereAzimuth(), this._whereAzimuth(),
this._whereProportion(role), this._whereProportion(role),
this._whereRemoteness(role), this._whereRemoteness(role),
@ -153,106 +164,57 @@ export class PassengerOrientedSelector extends Selector {
: `frequency='${Frequency.RECURRENT}'` : `frequency='${Frequency.RECURRENT}'`
: ''; : '';
private _whereDate = (): string => /**
this.query.frequency == Frequency.PUNCTUAL * Generates the WHERE clause checking that the date range of the query intersects with the range of the ad.
? `("fromDate" <= '${this.query.fromDate}' AND "toDate" >= '${this.query.fromDate}')` * Note that driver dates might not be comparable with passenger dates when the trip is by night or very long.
: `(\ * For this reason, the pickup date is adjusted with the driver duration,
(\ * so as to compare with the maximum / minimum driver date that could make sense for the passenger.
"fromDate" <= '${this.query.fromDate}' AND "fromDate" <= '${this.query.toDate}' AND\ * This may return more ads than necessary, but they will be filtered out in further processing.
"toDate" >= '${this.query.toDate}' AND "toDate" >= '${this.query.fromDate}'\ */
) OR (\ private _whereDate = (role: Role): string => {
"fromDate" >= '${this.query.fromDate}' AND "fromDate" <= '${this.query.toDate}' AND\ const maxFromDate = this._maxFromDate(role);
"toDate" <= '${this.query.toDate}' AND "toDate" >= '${this.query.fromDate}'\ const minToDate = this._minToDate(role);
) OR (\ return `("fromDate" <= ${maxFromDate} AND "toDate" >= ${minToDate})`;
"fromDate" <= '${this.query.fromDate}' AND "fromDate" <= '${this.query.toDate}' AND\ };
"toDate" <= '${this.query.toDate}' AND "toDate" >= '${this.query.fromDate}'\
) OR (\
"fromDate" >= '${this.query.fromDate}' AND "fromDate" <= '${this.query.toDate}' AND\
"toDate" >= '${this.query.toDate}' AND "toDate" >= '${this.query.fromDate}'\
)\
)`;
private _whereSchedule = (role: Role): string => { private _maxFromDate = (role: Role): string => {
// no schedule filtering if schedule is not set if (role == Role.DRIVER) {
if (this.query.schedule === undefined) return ''; //When looking for a passenger, we add the duration of the driver route to the latest toDate
const schedule: string[] = []; //to compute the maximum sensible passenger fromDate, in case the pickup date could be on the next day
// we need full dates to compare times, because margins can lead to compare on previous or next day const querySchedule = this.query.schedule;
// - first we establish a base calendar (up to a week) // When there is no schedule (search whole day), we consider the driver accepts to depart until 23:59
const scheduleDates: Date[] = this._datesBetweenBoundaries( const maxScheduleTime =
this.query.fromDate, querySchedule === undefined
this.query.toDate, ? '23:59'
: querySchedule.reduce(
(max, s) => (s.time > max ? s.time : max),
'00:00',
); );
// - then we compare each resulting day of the schedule with each day of calendar, const [h, m] = maxScheduleTime.split(':');
// adding / removing margin depending on the role const maxFromDate = new Date(this.query.toDate);
scheduleDates.map((date: Date) => { maxFromDate.setHours(parseInt(h));
(this.query.schedule as ScheduleItem[]) maxFromDate.setMinutes(parseInt(m));
.filter( maxFromDate.setSeconds(this.query.driverRoute!.duration);
(scheduleItem: ScheduleItem) => date.getUTCDay() == scheduleItem.day, return `'${maxFromDate.getUTCFullYear()}-${maxFromDate.getUTCMonth() + 1}-${maxFromDate.getUTCDate()}'`;
) } else {
.map((scheduleItem: ScheduleItem) => { return `'${this.query.toDate}'`;
switch (role) {
case Role.PASSENGER:
schedule.push(this._wherePassengerSchedule(date, scheduleItem));
break;
case Role.DRIVER:
schedule.push(this._whereDriverSchedule(date, scheduleItem));
break;
} }
}); };
});
if (schedule.length > 0) { private _minToDate = (role: Role): string => {
return ['(', schedule.join(' OR '), ')'].join(''); if (role == Role.PASSENGER) {
// When looking for a driver, we look for a toDate that is one day before the fromDate of the query
// so that the driver will be able to pick up the passenger even during a long trip that starts the day before
const oneDayBeforeFromDate = new Date(this.query.fromDate);
oneDayBeforeFromDate.setDate(oneDayBeforeFromDate.getDate() - 1);
return `'${oneDayBeforeFromDate.getUTCFullYear()}-${oneDayBeforeFromDate.getUTCMonth() + 1}-${oneDayBeforeFromDate.getUTCDate()}'`;
} else {
return `'${this.query.fromDate}'`;
} }
return '';
}; };
private _wherePassengerSchedule = ( private _whereExcludedAd = (): string =>
date: Date, this.query.excludedAdId ? `ad.uuid <> '${this.query.excludedAdId}'` : '';
scheduleItem: ScheduleItem,
): string => {
let maxDepartureDatetime: Date = new Date(date);
maxDepartureDatetime.setHours(parseInt(scheduleItem.time.split(':')[0]));
maxDepartureDatetime.setMinutes(parseInt(scheduleItem.time.split(':')[1]));
maxDepartureDatetime = this._addMargin(
maxDepartureDatetime,
scheduleItem.margin as number,
);
// we want the min departure time of the driver to be before the max departure time of the passenger
return `make_timestamp(\
${maxDepartureDatetime.getUTCFullYear()},\
${maxDepartureDatetime.getUTCMonth() + 1},\
${maxDepartureDatetime.getUTCDate()},\
CAST(EXTRACT(hour from time) as integer),\
CAST(EXTRACT(minute from time) as integer),0) - interval '1 second' * margin <=\
make_timestamp(\
${maxDepartureDatetime.getUTCFullYear()},\
${maxDepartureDatetime.getUTCMonth() + 1},\
${maxDepartureDatetime.getUTCDate()},${maxDepartureDatetime.getUTCHours()},${maxDepartureDatetime.getUTCMinutes()},0)`;
};
private _whereDriverSchedule = (
date: Date,
scheduleItem: ScheduleItem,
): string => {
let minDepartureDatetime: Date = new Date(date);
minDepartureDatetime.setHours(parseInt(scheduleItem.time.split(':')[0]));
minDepartureDatetime.setMinutes(parseInt(scheduleItem.time.split(':')[1]));
minDepartureDatetime = this._addMargin(
minDepartureDatetime,
-(scheduleItem.margin as number),
);
// we want the max departure time of the passenger to be after the min departure time of the driver
return `make_timestamp(\
${minDepartureDatetime.getUTCFullYear()},
${minDepartureDatetime.getUTCMonth() + 1},
${minDepartureDatetime.getUTCDate()},\
CAST(EXTRACT(hour from time) as integer),\
CAST(EXTRACT(minute from time) as integer),0) + interval '1 second' * margin >=\
make_timestamp(\
${minDepartureDatetime.getUTCFullYear()},
${minDepartureDatetime.getUTCMonth() + 1},
${minDepartureDatetime.getUTCDate()},${minDepartureDatetime.getUTCHours()},${minDepartureDatetime.getUTCMinutes()},0)`;
};
private _whereAzimuth = (): string => { private _whereAzimuth = (): string => {
if (!this.query.useAzimuth) return ''; if (!this.query.useAzimuth) return '';
@ -313,37 +275,6 @@ export class PassengerOrientedSelector extends Selector {
} }
}; };
/**
* Returns an array of dates containing all the dates (limited to 7 by default) between 2 boundary dates.
*
* The array length can be limited to a _max_ number of dates (default: 7)
*/
private _datesBetweenBoundaries = (
firstDate: string,
lastDate: string,
max = 7,
): Date[] => {
const fromDate: Date = new Date(firstDate);
const toDate: Date = new Date(lastDate);
const dates: Date[] = [];
let count = 0;
for (
let date = fromDate;
date <= toDate;
date.setUTCDate(date.getUTCDate() + 1)
) {
dates.push(new Date(date));
count++;
if (count == max) break;
}
return dates;
};
private _addMargin = (date: Date, marginInSeconds: number): Date => {
date.setUTCSeconds(marginInSeconds);
return date;
};
private _azimuthRange = ( private _azimuthRange = (
azimuth: number, azimuth: number,
margin: number, margin: number,
@ -354,11 +285,26 @@ export class PassengerOrientedSelector extends Selector {
azimuth + margin > 360 ? azimuth + margin - 360 : azimuth + margin, azimuth + margin > 360 ? azimuth + margin - 360 : azimuth + margin,
}); });
//TODO If the dates are always formatted with '%Y-%m-%d', no conversion to Date is needed
private _maxDateString = (date1: string, date2: string): string => private _maxDateString = (date1: string, date2: string): string =>
new Date(date1) > new Date(date2) ? date1 : date2; new Date(date1) > new Date(date2) ? date1 : date2;
private _minDateString = (date1: string, date2: string): string => private _minDateString = (date1: string, date2: string): string =>
new Date(date1) < new Date(date2) ? date1 : date2; new Date(date1) < new Date(date2) ? date1 : date2;
/**
* When a punctual ad matches a punctual query, it may be on a different date than the query
* (for routes by night), and the range produced by _minDateString and _maxDateString is not correct.
* This function fixes that by inverting the dates if necessary.
*/
private _fixDateInterval(interval: DateInterval): DateInterval {
if (interval.lowerDate > interval.higherDate) {
const tmp = interval.lowerDate;
interval.lowerDate = interval.higherDate;
interval.higherDate = tmp;
}
return interval;
}
} }
export type QueryStringRole = { export type QueryStringRole = {

View File

@ -1,5 +1,6 @@
import { AggregateRoot, AggregateID } from '@mobicoop/ddd-library'; import { AggregateID, AggregateRoot } from '@mobicoop/ddd-library';
import { AdProps, CreateAdProps } from './ad.types'; import { AdProps, CreateAdProps } from './ad.types';
import { AdDeletedDomainEvent } from './events/ad-delete.domain-event';
import { MatcherAdCreatedDomainEvent } from './events/matcher-ad-created.domain-event'; import { MatcherAdCreatedDomainEvent } from './events/matcher-ad-created.domain-event';
export class AdEntity extends AggregateRoot<AdProps> { export class AdEntity extends AggregateRoot<AdProps> {
@ -26,6 +27,14 @@ export class AdEntity extends AggregateRoot<AdProps> {
return ad; return ad;
}; };
delete(): void {
this.addEvent(
new AdDeletedDomainEvent({
aggregateId: this.id,
}),
);
}
validate(): void { validate(): void {
// entity business rules validation to protect it's invariant before saving entity to a database // entity business rules validation to protect it's invariant before saving entity to a database
} }

View File

@ -0,0 +1,94 @@
import { AdEntity } from './ad.entity';
import { Role, UserAd } from './ad.types';
import { GeorouterService } from './georouter.service';
import {
Path,
PathCreator,
PathType,
TypedRoute,
} from './path-creator.service';
import { Point } from './value-objects/point.value-object';
export class AdFactory {
constructor(private readonly routeProvider: GeorouterService) {}
/**
* Create an AdEntity (a "matcher ad", that is: the data needed to match an ad with a match query)
* from a "user ad" (the data provided by the user).
*/
public async create(ad: UserAd): Promise<AdEntity> {
const roles: Role[] = [];
if (ad.driver) roles.push(Role.DRIVER);
if (ad.passenger) roles.push(Role.PASSENGER);
const pathCreator = new PathCreator(
roles,
ad.waypoints.map((wp) => new Point({ lon: wp.lon, lat: wp.lat })),
);
let typedRoutes: TypedRoute[];
try {
typedRoutes = await Promise.all(
pathCreator.getBasePaths().map(async (path: Path) => ({
type: path.type,
route: await this.routeProvider.getRoute({
waypoints: path.waypoints,
}),
})),
);
} catch (e: any) {
throw new Error('Unable to find a route for given waypoints');
}
let driverDistance: number | undefined;
let driverDuration: number | undefined;
let passengerDistance: number | undefined;
let passengerDuration: number | undefined;
let points: Point[];
let fwdAzimuth: number;
let backAzimuth: number;
try {
typedRoutes.forEach((typedRoute: TypedRoute) => {
if ([PathType.DRIVER, PathType.GENERIC].includes(typedRoute.type)) {
driverDistance = typedRoute.route.distance;
driverDuration = typedRoute.route.duration;
points = typedRoute.route.points.map((point) => new Point(point));
fwdAzimuth = typedRoute.route.fwdAzimuth;
backAzimuth = typedRoute.route.backAzimuth;
}
if ([PathType.PASSENGER, PathType.GENERIC].includes(typedRoute.type)) {
passengerDistance = typedRoute.route.distance;
passengerDuration = typedRoute.route.duration;
if (!points) {
points = typedRoute.route.points.map((point) => new Point(point));
}
if (!fwdAzimuth) fwdAzimuth = typedRoute.route.fwdAzimuth;
if (!backAzimuth) backAzimuth = typedRoute.route.backAzimuth;
}
});
} catch (error: any) {
throw new Error('Invalid route');
}
return AdEntity.create({
id: ad.id,
driver: ad.driver,
passenger: ad.passenger,
frequency: ad.frequency,
fromDate: ad.fromDate,
toDate: ad.toDate,
schedule: ad.schedule,
seatsProposed: ad.seatsProposed,
seatsRequested: ad.seatsRequested,
strict: ad.strict,
waypoints: ad.waypoints,
points: points!,
driverDistance,
driverDuration,
passengerDistance,
passengerDuration,
fwdAzimuth: fwdAzimuth!,
backAzimuth: backAzimuth!,
});
}
}

View File

@ -1,6 +1,23 @@
import { PointProps } from './value-objects/point.value-object'; import { PointProps } from './value-objects/point.value-object';
import { ScheduleItemProps } from './value-objects/schedule-item.value-object'; import { ScheduleItemProps } from './value-objects/schedule-item.value-object';
/**
* The data provided by the end-user to publish an ad
*/
export interface UserAd {
id: string;
driver: boolean;
passenger: boolean;
frequency: Frequency;
fromDate: string;
toDate: string;
schedule: ScheduleItemProps[];
seatsProposed: number;
seatsRequested: number;
strict: boolean;
waypoints: PointProps[];
}
// All properties that an Ad has // All properties that an Ad has
export interface AdProps { export interface AdProps {
driver: boolean; driver: boolean;

View File

@ -1,28 +1,30 @@
import { import {
AggregateRoot,
AggregateID, AggregateID,
AggregateRoot,
ArgumentInvalidException, ArgumentInvalidException,
ValueObject,
} from '@mobicoop/ddd-library'; } from '@mobicoop/ddd-library';
import { Role } from './ad.types';
import { CalendarTools } from './calendar-tools.service';
import { import {
CandidateProps, CandidateProps,
CreateCandidateProps, CreateCandidateProps,
DateInterval,
Target, Target,
} from './candidate.types'; } from './candidate.types';
import { ActorTime } from './value-objects/actor-time.value-object';
import { Actor } from './value-objects/actor.value-object';
import { import {
CarpoolPathItem, CarpoolPathItem,
CarpoolPathItemProps, CarpoolPathItemProps,
} from './value-objects/carpool-path-item.value-object'; } from './value-objects/carpool-path-item.value-object';
import { Step, StepProps } from './value-objects/step.value-object'; import { JourneyItem } from './value-objects/journey-item.value-object';
import { Journey, JourneyProps } from './value-objects/journey.value-object';
import { import {
ScheduleItem, ScheduleItem,
ScheduleItemProps, ScheduleItemProps,
} from './value-objects/schedule-item.value-object'; } from './value-objects/schedule-item.value-object';
import { Journey } from './value-objects/journey.value-object'; import { Step, StepProps } from './value-objects/step.value-object';
import { CalendarTools } from './calendar-tools.service';
import { JourneyItem } from './value-objects/journey-item.value-object';
import { Actor } from './value-objects/actor.value-object';
import { ActorTime } from './value-objects/actor-time.value-object';
import { Role } from './ad.types';
export class CandidateEntity extends AggregateRoot<CandidateProps> { export class CandidateEntity extends AggregateRoot<CandidateProps> {
protected readonly _id: AggregateID; protected readonly _id: AggregateID;
@ -63,18 +65,23 @@ export class CandidateEntity extends AggregateRoot<CandidateProps> {
// driver and passenger schedules are eventually mandatory // driver and passenger schedules are eventually mandatory
if (!this.props.driverSchedule) this._createDriverSchedule(); if (!this.props.driverSchedule) this._createDriverSchedule();
if (!this.props.passengerSchedule) this._createPassengerSchedule(); if (!this.props.passengerSchedule) this._createPassengerSchedule();
this.props.journeys = this.props.driverSchedule!.reduce(
(accJourneys: JourneyProps[], driverScheduleItem: ScheduleItem) => {
try { try {
this.props.journeys = (this.props.driverSchedule as ScheduleItemProps[])
// first we create the journeys // first we create the journeys
.map((driverScheduleItem: ScheduleItem) => const journey = this._createJourney(driverScheduleItem);
this._createJourney(driverScheduleItem),
)
// then we filter the ones with invalid pickups // then we filter the ones with invalid pickups
.filter((journey: Journey) => journey.hasValidPickUp()); if (journey.hasValidPickUp()) {
accJourneys.push(journey);
}
} catch (e) { } catch (e) {
// irrelevant journeys fall here // irrelevant journeys fall here
// eg. no available day for the given date range // eg. no available day for the given date range
} }
return accJourneys;
},
new Array<JourneyProps>(),
);
return this; return this;
}; };
@ -96,47 +103,13 @@ export class CandidateEntity extends AggregateRoot<CandidateProps> {
* Create the driver schedule based on the passenger schedule * Create the driver schedule based on the passenger schedule
*/ */
private _createDriverSchedule = (): void => { private _createDriverSchedule = (): void => {
let driverSchedule: ScheduleItemProps[] = this.props.passengerSchedule!.map( const passengerSchedule = new Schedule(
(scheduleItemProps: ScheduleItemProps) => ({ this.props.passengerSchedule!,
day: scheduleItemProps.day,
time: scheduleItemProps.time,
margin: scheduleItemProps.margin,
}),
);
// adjust the driver theoretical schedule :
// we guess the ideal driver departure time based on the duration to
// reach the passenger starting point from the driver starting point
driverSchedule = driverSchedule.map(
(scheduleItemProps: ScheduleItemProps) => {
const driverDate: Date = CalendarTools.firstDate(
scheduleItemProps.day,
this.props.dateInterval, this.props.dateInterval,
); );
const driverStartDatetime: Date = CalendarTools.datetimeWithSeconds( this.props.driverSchedule = passengerSchedule
driverDate, .adjust(-this._passengerStartDuration())
scheduleItemProps.time, .unpack().items;
-this._passengerStartDuration(),
);
return {
day: driverDate.getUTCDay(),
margin: scheduleItemProps.margin,
time: `${driverStartDatetime
.getUTCHours()
.toString()
.padStart(2, '0')}:${driverStartDatetime
.getUTCMinutes()
.toString()
.padStart(2, '0')}`,
};
},
);
this.props.driverSchedule = driverSchedule.map(
(scheduleItemProps: ScheduleItemProps) => ({
day: scheduleItemProps.day,
time: scheduleItemProps.time,
margin: scheduleItemProps.margin,
}),
);
}; };
/** /**
@ -159,47 +132,14 @@ export class CandidateEntity extends AggregateRoot<CandidateProps> {
* Create the passenger schedule based on the driver schedule * Create the passenger schedule based on the driver schedule
*/ */
private _createPassengerSchedule = (): void => { private _createPassengerSchedule = (): void => {
let passengerSchedule: ScheduleItemProps[] = this.props.driverSchedule!.map( const driverSchedule = new Schedule(
(scheduleItemProps: ScheduleItemProps) => ({ this.props.driverSchedule!,
day: scheduleItemProps.day,
time: scheduleItemProps.time,
margin: scheduleItemProps.margin,
}),
);
// adjust the passenger theoretical schedule :
// we guess the ideal passenger departure time based on the duration to
// reach the passenger starting point from the driver starting point
passengerSchedule = passengerSchedule.map(
(scheduleItemProps: ScheduleItemProps) => {
const passengerDate: Date = CalendarTools.firstDate(
scheduleItemProps.day,
this.props.dateInterval, this.props.dateInterval,
); );
const passengeStartDatetime: Date = CalendarTools.datetimeWithSeconds(
passengerDate, this.props.passengerSchedule = driverSchedule
scheduleItemProps.time, .adjust(this._passengerStartDuration())
this._passengerStartDuration(), .unpack().items;
);
return {
day: passengerDate.getUTCDay(),
margin: scheduleItemProps.margin,
time: `${passengeStartDatetime
.getUTCHours()
.toString()
.padStart(2, '0')}:${passengeStartDatetime
.getUTCMinutes()
.toString()
.padStart(2, '0')}`,
};
},
);
this.props.passengerSchedule = passengerSchedule.map(
(scheduleItemProps: ScheduleItemProps) => ({
day: scheduleItemProps.day,
time: scheduleItemProps.time,
margin: scheduleItemProps.margin,
}),
);
}; };
private _createJourney = (driverScheduleItem: ScheduleItem): Journey => private _createJourney = (driverScheduleItem: ScheduleItem): Journey =>
@ -382,6 +322,60 @@ export class CandidateEntity extends AggregateRoot<CandidateProps> {
} }
} }
//TODO Use this class as part of the CandidateEntity aggregate
export class Schedule extends ValueObject<{
items: ScheduleItemProps[];
dateInterval: DateInterval;
}> {
constructor(items: ScheduleItemProps[], dateInterval: DateInterval) {
super({ items, dateInterval });
}
protected validate(): void {}
/**
* Add the given duration to each schedule item
* unless the expected new datetime is not possible,
* in which case the item is removed from the adjusted schedule
* @param duration time increment in seconds (can be negative)
* @returns the new adjusted schedule
*/
adjust(duration: number): Schedule {
const newItems = this.props.items.reduce((acc, scheduleItemProps) => {
try {
const itemDate: Date = CalendarTools.firstDate(
scheduleItemProps.day,
this.props.dateInterval,
);
const driverStartDatetime: Date = CalendarTools.datetimeWithSeconds(
itemDate,
scheduleItemProps.time,
duration,
);
acc.push({
day: driverStartDatetime.getUTCDay(),
margin: scheduleItemProps.margin,
time: this._formatTime(driverStartDatetime),
});
} catch (e) {
// no possible driver date or time
// TODO : find a test case !
}
return acc;
}, new Array<ScheduleItemProps>());
return new Schedule(newItems, this.props.dateInterval);
}
private _formatTime(dateTime: Date) {
return (
dateTime.getUTCHours().toString().padStart(2, '0') +
':' +
dateTime.getUTCMinutes().toString().padStart(2, '0')
);
}
}
type ScheduleItemRange = { type ScheduleItemRange = {
scheduleItem: ScheduleItem; scheduleItem: ScheduleItem;
range: Date[]; range: Date[];

View File

@ -0,0 +1,7 @@
import { DomainEvent, DomainEventProps } from '@mobicoop/ddd-library';
export class AdDeletedDomainEvent extends DomainEvent {
constructor(props: DomainEventProps<AdDeletedDomainEvent>) {
super(props);
}
}

View File

@ -1,5 +1,3 @@
import { Injectable } from '@nestjs/common';
export type Point = { export type Point = {
lon: number; lon: number;
lat: number; lat: number;
@ -25,7 +23,6 @@ export type RouteResponse = {
steps?: Step[]; steps?: Step[];
}; };
@Injectable() export interface GeorouterService {
export abstract class GeorouterPort { getRoute(request: RouteRequest): Promise<RouteResponse>;
abstract getRoute(request: RouteRequest): Promise<RouteResponse>;
} }

View File

@ -2,8 +2,7 @@ import {
ArgumentOutOfRangeException, ArgumentOutOfRangeException,
ValueObject, ValueObject,
} from '@mobicoop/ddd-library'; } from '@mobicoop/ddd-library';
import { Actor, ActorProps } from './actor.value-object'; import { ActorProps } from './actor.value-object';
import { Role } from '../ad.types';
import { Point, PointProps } from './point.value-object'; import { Point, PointProps } from './point.value-object';
/** Note: /** Note:
@ -36,12 +35,5 @@ export class CarpoolPathItem extends ValueObject<CarpoolPathItemProps> {
}); });
if (props.actors.length <= 0) if (props.actors.length <= 0)
throw new ArgumentOutOfRangeException('at least one actor is required'); throw new ArgumentOutOfRangeException('at least one actor is required');
if (
props.actors.filter((actor: Actor) => actor.role == Role.DRIVER).length >
1
)
throw new ArgumentOutOfRangeException(
'a carpoolStep can contain only one driver',
);
} }
} }

View File

@ -1,14 +1,14 @@
import { LoggerBase, MessagePublisherPort } from '@mobicoop/ddd-library';
import { ExtendedPrismaRepositoryBase } from '@mobicoop/ddd-library/dist/db/prisma-repository.base';
import { Inject, Injectable, Logger } from '@nestjs/common'; import { Inject, Injectable, Logger } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter'; 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';
import { Frequency } from '../core/domain/ad.types';
import { SERVICE_NAME } from '@src/app.constants'; import { SERVICE_NAME } from '@src/app.constants';
import { AD_MESSAGE_PUBLISHER } from '../ad.di-tokens';
import { AdMapper } from '../ad.mapper';
import { AdRepositoryPort } from '../core/application/ports/ad.repository.port';
import { AdEntity } from '../core/domain/ad.entity';
import { Frequency } from '../core/domain/ad.types';
import { PrismaService } from './prisma.service';
export type AdModel = { export type AdModel = {
uuid: string; uuid: string;
@ -26,8 +26,6 @@ export type AdModel = {
passengerDistance?: number; passengerDistance?: number;
fwdAzimuth: number; fwdAzimuth: number;
backAzimuth: number; backAzimuth: number;
createdAt: Date;
updatedAt: Date;
}; };
/** /**
@ -36,15 +34,26 @@ export type AdModel = {
export type AdReadModel = AdModel & { export type AdReadModel = AdModel & {
waypoints: string; waypoints: string;
schedule: ScheduleItemModel[]; schedule: ScheduleItemModel[];
createdAt: Date;
updatedAt: Date;
}; };
/** /**
* The record ready to be sent to the persistence system * The record ready to be sent to the persistence system
*/ */
export type AdWriteModel = AdModel & { export type AdWriteModel = AdModel & {
schedule: { schedule: ScheduleWriteModel;
};
export type ScheduleWriteModel = {
deleteMany?: PastCreatedFilter;
create: ScheduleItemModel[]; create: ScheduleItemModel[];
}; };
// used to delete records created in the past,
// because the order of `create` and `deleteMany` is not guaranteed
export type PastCreatedFilter = {
createdAt: { lt: Date };
}; };
export type AdWriteExtraModel = { export type AdWriteExtraModel = {
@ -70,11 +79,15 @@ export type UngroupedAdModel = AdModel &
scheduleItemCreatedAt: Date; scheduleItemCreatedAt: Date;
scheduleItemUpdatedAt: Date; scheduleItemUpdatedAt: Date;
waypoints: string; waypoints: string;
createdAt: Date;
updatedAt: Date;
}; };
export type GroupedAdModel = AdModel & { export type GroupedAdModel = AdModel & {
schedule: ScheduleItemModel[]; schedule: ScheduleItemModel[];
waypoints: string; waypoints: string;
createdAt: Date;
updatedAt: Date;
}; };
/** /**
@ -169,4 +182,12 @@ export class AdRepository
}); });
return adReadModels; return adReadModels;
}; };
async update(
id: string,
entity: AdEntity,
identifier?: string,
): Promise<void> {
this.updateExtra(id, entity, 'ad', identifier);
}
} }

View File

@ -1,26 +1,26 @@
import { Observable, lastValueFrom } from 'rxjs';
import { Inject, Injectable, OnModuleInit } from '@nestjs/common'; import { Inject, Injectable, OnModuleInit } from '@nestjs/common';
import { ClientGrpc } from '@nestjs/microservices'; import { ClientGrpc } from '@nestjs/microservices';
import { GRPC_GEOROUTER_SERVICE_NAME } from '@src/app.constants'; import { GRPC_GEOROUTER_SERVICE_NAME } from '@src/app.constants';
import { Observable, lastValueFrom } from 'rxjs';
import { GEOGRAPHY_PACKAGE } from '../ad.di-tokens';
import { import {
GeorouterPort, GeorouterService,
RouteRequest, RouteRequest,
RouteResponse, RouteResponse,
} from '../core/application/ports/georouter.port'; } from '../core/domain/georouter.service';
import { GEOGRAPHY_PACKAGE } from '../ad.di-tokens';
interface GeorouterService { interface GeorouterPort {
getRoute(request: RouteRequest): Observable<RouteResponse>; getRoute(request: RouteRequest): Observable<RouteResponse>;
} }
@Injectable() @Injectable()
export class Georouter implements GeorouterPort, OnModuleInit { export class Georouter implements GeorouterService, OnModuleInit {
private georouterService: GeorouterService; private georouterService: GeorouterPort;
constructor(@Inject(GEOGRAPHY_PACKAGE) private readonly client: ClientGrpc) {} constructor(@Inject(GEOGRAPHY_PACKAGE) private readonly client: ClientGrpc) {}
onModuleInit() { onModuleInit() {
this.georouterService = this.client.getService<GeorouterService>( this.georouterService = this.client.getService<GeorouterPort>(
GRPC_GEOROUTER_SERVICE_NAME, GRPC_GEOROUTER_SERVICE_NAME,
); );
} }

View File

@ -1,10 +1,11 @@
import { ResponseBase } from '@mobicoop/ddd-library'; import { ResponseBase } from '@mobicoop/ddd-library';
import { JourneyResponseDto } from './journey.response.dto'; import { JourneyResponseDto } from './journey.response.dto';
import { Frequency } from '@modules/ad/core/domain/ad.types';
export class MatchResponseDto extends ResponseBase { export class MatchResponseDto extends ResponseBase {
adId: string; adId: string;
role: string; role: string;
frequency: string; frequency: Frequency;
distance: number; distance: number;
duration: number; duration: number;
initialDistance: number; initialDistance: number;

View File

@ -81,6 +81,10 @@ export class MatchRequestDto {
@ValidateNested({ each: true }) @ValidateNested({ each: true })
waypoints: WaypointDto[]; waypoints: WaypointDto[];
@IsUUID()
@IsOptional()
excludedAdId?: string;
@IsOptional() @IsOptional()
@IsEnum(AlgorithmType) @IsEnum(AlgorithmType)
algorithmType?: AlgorithmType; algorithmType?: AlgorithmType;

View File

@ -1,18 +1,11 @@
import { RpcValidationPipe } from '@mobicoop/ddd-library'; import { RpcValidationPipe } from '@mobicoop/ddd-library';
import { AD_ROUTE_PROVIDER } from '@modules/ad/ad.di-tokens'; import { AD_ROUTE_PROVIDER } from '@modules/ad/ad.di-tokens';
import { GeorouterPort } from '@modules/ad/core/application/ports/georouter.port';
import { MatchQuery } from '@modules/ad/core/application/queries/match/match.query'; import { MatchQuery } from '@modules/ad/core/application/queries/match/match.query';
import { MatchingResult } from '@modules/ad/core/application/queries/match/match.query-handler'; import { MatchingResult } from '@modules/ad/core/application/queries/match/match.query-handler';
import { GeorouterService } from '@modules/ad/core/domain/georouter.service';
import { MatchEntity } from '@modules/ad/core/domain/match.entity'; import { MatchEntity } from '@modules/ad/core/domain/match.entity';
import { MatchMapper } from '@modules/ad/match.mapper'; import { MatchMapper } from '@modules/ad/match.mapper';
import { CacheInterceptor, CacheKey } from '@nestjs/cache-manager'; import { Controller, Inject, UseFilters, UsePipes } from '@nestjs/common';
import {
Controller,
Inject,
UseFilters,
UseInterceptors,
UsePipes,
} from '@nestjs/common';
import { QueryBus } from '@nestjs/cqrs'; import { QueryBus } from '@nestjs/cqrs';
import { GrpcMethod } from '@nestjs/microservices'; import { GrpcMethod } from '@nestjs/microservices';
import { LogCauseExceptionFilter } from '@src/log-cause.exception-filter'; import { LogCauseExceptionFilter } from '@src/log-cause.exception-filter';
@ -31,12 +24,10 @@ export class MatchGrpcController {
constructor( constructor(
private readonly queryBus: QueryBus, private readonly queryBus: QueryBus,
@Inject(AD_ROUTE_PROVIDER) @Inject(AD_ROUTE_PROVIDER)
private readonly routeProvider: GeorouterPort, private readonly routeProvider: GeorouterService,
private readonly matchMapper: MatchMapper, private readonly matchMapper: MatchMapper,
) {} ) {}
@CacheKey('MatcherServiceMatch')
@UseInterceptors(CacheInterceptor)
@GrpcMethod('MatcherService', 'Match') @GrpcMethod('MatcherService', 'Match')
async match(data: MatchRequestDto): Promise<MatchingPaginatedResponseDto> { async match(data: MatchRequestDto): Promise<MatchingPaginatedResponseDto> {
const matchingResult: MatchingResult = await this.queryBus.execute( const matchingResult: MatchingResult = await this.queryBus.execute(

View File

@ -16,16 +16,17 @@ message MatchRequest {
repeated ScheduleItem schedule = 7; repeated ScheduleItem schedule = 7;
bool strict = 8; bool strict = 8;
repeated Waypoint waypoints = 9; repeated Waypoint waypoints = 9;
AlgorithmType algorithmType = 10; string excludedAdId = 10;
int32 remoteness = 11; AlgorithmType algorithmType = 11;
bool useProportion = 12; int32 remoteness = 12;
float proportion = 13; bool useProportion = 13;
bool useAzimuth = 14; float proportion = 14;
int32 azimuthMargin = 15; bool useAzimuth = 15;
float maxDetourDistanceRatio = 16; int32 azimuthMargin = 16;
float maxDetourDurationRatio = 17; float maxDetourDistanceRatio = 17;
optional int32 page = 18; float maxDetourDurationRatio = 18;
optional int32 perPage = 19; optional int32 page = 19;
optional int32 perPage = 20;
} }
message ScheduleItem { message ScheduleItem {
@ -58,15 +59,16 @@ enum AlgorithmType {
message Match { message Match {
string adId = 1; string adId = 1;
string role = 2; string role = 2;
int32 distance = 3; Frequency frequency = 3;
int32 duration = 4; int32 distance = 4;
int32 initialDistance = 5; int32 duration = 5;
int32 initialDuration = 6; int32 initialDistance = 6;
int32 distanceDetour = 7; int32 initialDuration = 7;
int32 durationDetour = 8; int32 distanceDetour = 8;
double distanceDetourPercentage = 9; int32 durationDetour = 9;
double durationDetourPercentage = 10; double distanceDetourPercentage = 10;
repeated Journey journeys = 11; double durationDetourPercentage = 11;
repeated Journey journeys = 12;
} }
message Journey { message Journey {

View File

@ -0,0 +1,32 @@
import { RabbitSubscribe } from '@mobicoop/message-broker-module';
import { DeleteAdCommand } from '@modules/ad/core/application/commands/delete-ad/delete-ad.command';
import { Injectable } from '@nestjs/common';
import { CommandBus } from '@nestjs/cqrs';
import {
AD_DELETED_MESSAGE_HANDLER,
AD_DELETED_ROUTING_KEY,
} from '@src/app.constants';
import { AdReference } from './ad.types';
@Injectable()
export class AdDeletedMessageHandler {
constructor(private readonly commandBus: CommandBus) {}
@RabbitSubscribe({
name: AD_DELETED_MESSAGE_HANDLER,
routingKey: AD_DELETED_ROUTING_KEY,
})
public async adDeleted(message: string): Promise<void> {
try {
const deletedAd: AdReference = JSON.parse(message);
await this.commandBus.execute(
new DeleteAdCommand({
id: deletedAd.aggregateId,
}),
);
} catch (error: any) {
// do not throw error to acknowledge incoming message
// error handling should be done in the command handler, if relevant
}
}
}

View File

@ -0,0 +1,28 @@
import { RabbitSubscribe } from '@mobicoop/message-broker-module';
import { UpdateAdCommand } from '@modules/ad/core/application/commands/update-ad/update-ad.command';
import { Injectable } from '@nestjs/common';
import { CommandBus } from '@nestjs/cqrs';
import {
AD_UPDATED_MESSAGE_HANDLER,
AD_UPDATED_ROUTING_KEY,
} from '@src/app.constants';
import { Ad } from './ad.types';
@Injectable()
export class AdUpdatedMessageHandler {
constructor(private readonly commandBus: CommandBus) {}
@RabbitSubscribe({
name: AD_UPDATED_MESSAGE_HANDLER,
routingKey: AD_UPDATED_ROUTING_KEY,
})
public async adUpdated(message: string) {
try {
const updatedAd: { data: Ad } = JSON.parse(message);
await this.commandBus.execute(new UpdateAdCommand(updatedAd.data));
} catch (error: any) {
// do not throw error to acknowledge incoming message
// error handling should be done in the command handler, if relevant
}
}
}

View File

@ -1,7 +1,10 @@
import { Frequency } from '@modules/ad/core/domain/ad.types'; import { Frequency } from '@modules/ad/core/domain/ad.types';
export type Ad = { export type AdReference = {
aggregateId: string; aggregateId: string;
};
export type Ad = AdReference & {
driver: boolean; driver: boolean;
passenger: boolean; passenger: boolean;
frequency: Frequency; frequency: Frequency;

View File

@ -0,0 +1,110 @@
import { CreateAdProps, Frequency } from '@modules/ad/core/domain/ad.types';
import { PointProps } from '@modules/ad/core/domain/value-objects/point.value-object';
import { ScheduleItemProps } from '@modules/ad/core/domain/value-objects/schedule-item.value-object';
import { v4 as uuidv4 } from 'uuid';
export const Nice: PointProps = {
lat: 43.7102,
lon: 7.262,
};
export const Marseille: PointProps = {
lat: 43.2965,
lon: 5.3698,
};
export const SaintRaphael: PointProps = {
lat: 43.4268,
lon: 6.769,
};
export const Toulon: PointProps = {
lat: 43.1167,
lon: 5.95,
};
export function monday(time: string): ScheduleItemProps {
return { day: 1, time: time, margin: 900 };
}
export function wednesday(time: string): ScheduleItemProps {
return { day: 3, time: time, margin: 900 };
}
export function thursday(time: string): ScheduleItemProps {
return { day: 4, time: time, margin: 900 };
}
export function weekdays(time: string): ScheduleItemProps[] {
return [1, 2, 3, 4, 5].map<ScheduleItemProps>((day) => ({
day: day,
time: time,
margin: 900,
}));
}
function createAdPropsDefaults(): CreateAdProps {
return {
id: uuidv4(),
driver: false,
passenger: false,
frequency: Frequency.PUNCTUAL,
fromDate: '',
toDate: '',
schedule: [],
seatsProposed: 1,
seatsRequested: 1,
strict: false,
waypoints: [],
points: [],
driverDuration: 0,
driverDistance: 0,
passengerDuration: 0,
passengerDistance: 0,
fwdAzimuth: 0,
backAzimuth: 0,
};
}
export function driverNiceMarseille(
frequency: Frequency,
dates: string[],
schedule: ScheduleItemProps[],
): CreateAdProps {
return {
...createAdPropsDefaults(),
driver: true,
frequency: frequency,
fromDate: dates[0],
toDate: dates[1],
schedule: schedule,
waypoints: [Nice, Marseille],
points: [Nice, SaintRaphael, Toulon, Marseille],
driverDuration: 7668,
driverDistance: 199000,
passengerDuration: 7668,
passengerDistance: 199000,
fwdAzimuth: 273,
backAzimuth: 93,
};
}
export function passengerToulonMarseille(
frequency: Frequency,
dates: string[],
schedule: ScheduleItemProps[],
): CreateAdProps {
return {
...createAdPropsDefaults(),
passenger: true,
frequency: frequency,
fromDate: dates[0],
toDate: dates[1],
schedule: schedule,
waypoints: [Toulon, Marseille],
points: [Toulon, Marseille],
driverDuration: 2460,
driverDistance: 64000,
passengerDuration: 2460,
passengerDistance: 64000,
};
}

View File

@ -1,61 +1,16 @@
import {
AD_DIRECTION_ENCODER,
AD_MESSAGE_PUBLISHER,
AD_REPOSITORY,
} from '@modules/ad/ad.di-tokens';
import { AdMapper } from '@modules/ad/ad.mapper';
import { AdEntity } from '@modules/ad/core/domain/ad.entity'; import { AdEntity } from '@modules/ad/core/domain/ad.entity';
import { CreateAdProps, Frequency } from '@modules/ad/core/domain/ad.types'; import { Frequency } from '@modules/ad/core/domain/ad.types';
import { AdRepository } from '@modules/ad/infrastructure/ad.repository'; import { AdRepository } from '@modules/ad/infrastructure/ad.repository';
import { PrismaService } from '@modules/ad/infrastructure/prisma.service'; import { PrismaService } from '@modules/ad/infrastructure/prisma.service';
import { PostgresDirectionEncoder } from '@modules/geography/infrastructure/postgres-direction-encoder'; import { driverNiceMarseille, wednesday, weekdays } from './ad.fixtures';
import { ConfigModule } from '@nestjs/config'; import { integrationTestingModule } from './integration.setup';
import { EventEmitterModule } from '@nestjs/event-emitter';
import { Test } from '@nestjs/testing';
describe('Ad Repository', () => { describe('Ad Repository', () => {
let prismaService: PrismaService; let prismaService: PrismaService;
let adRepository: AdRepository; let adRepository: AdRepository;
const mockMessagePublisher = {
publish: jest.fn().mockImplementation(),
};
const mockLogger = {
log: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
};
beforeAll(async () => { beforeAll(async () => {
const module = await Test.createTestingModule({ ({ prismaService, adRepository } = await integrationTestingModule());
imports: [
EventEmitterModule.forRoot(),
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);
adRepository = module.get<AdRepository>(AD_REPOSITORY);
}); });
afterAll(async () => { afterAll(async () => {
@ -70,60 +25,12 @@ describe('Ad Repository', () => {
it('should create a punctual ad', async () => { it('should create a punctual ad', async () => {
const beforeCount = await prismaService.ad.count(); const beforeCount = await prismaService.ad.count();
const createAdProps: CreateAdProps = { const createAdProps = driverNiceMarseille(
id: 'b4b56444-f8d3-4110-917c-e37bba77f383', Frequency.PUNCTUAL,
driver: true, ['2023-02-01', '2023-02-01'],
passenger: false, [wednesday('08:30')],
frequency: Frequency.PUNCTUAL, );
fromDate: '2023-02-01', const adToCreate = AdEntity.create(createAdProps);
toDate: '2023-02-01',
schedule: [
{
day: 3,
time: '12:05',
margin: 900,
},
],
seatsProposed: 3,
seatsRequested: 1,
strict: false,
waypoints: [
{
lon: 43.7102,
lat: 7.262,
},
{
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.insertExtra(adToCreate, 'ad'); await adRepository.insertExtra(adToCreate, 'ad');
const afterCount = await prismaService.ad.count(); const afterCount = await prismaService.ad.count();
@ -134,80 +41,13 @@ describe('Ad Repository', () => {
it('should create a recurrent ad', async () => { it('should create a recurrent ad', async () => {
const beforeCount = await prismaService.ad.count(); const beforeCount = await prismaService.ad.count();
const createAdProps: CreateAdProps = { const createAdProps = driverNiceMarseille(
id: 'b4b56444-f8d3-4110-917c-e37bba77f383', Frequency.RECURRENT,
driver: true, ['2023-02-01', '2024-01-31'],
passenger: false, weekdays('08:30'),
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: [
{
lon: 43.7102,
lat: 7.262,
},
{
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); const adToCreate = AdEntity.create(createAdProps);
await adRepository.insertExtra(adToCreate, 'ad'); await adRepository.insertExtra(adToCreate, 'ad');
const afterCount = await prismaService.ad.count(); const afterCount = await prismaService.ad.count();

View File

@ -0,0 +1,57 @@
import {
AD_DIRECTION_ENCODER,
AD_MESSAGE_PUBLISHER,
AD_REPOSITORY,
} from '@modules/ad/ad.di-tokens';
import { AdMapper } from '@modules/ad/ad.mapper';
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';
export async function integrationTestingModule(): Promise<{
prismaService: PrismaService;
adRepository: AdRepository;
}> {
const mockMessagePublisher = {
publish: jest.fn().mockImplementation(),
};
const mockLogger = {
log: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
};
const module = await Test.createTestingModule({
imports: [
EventEmitterModule.forRoot(),
ConfigModule.forRoot({ isGlobal: true }),
],
providers: [
PrismaService,
AdMapper,
{
provide: AD_REPOSITORY,
useClass: AdRepository,
},
{
provide: AD_MESSAGE_PUBLISHER,
useValue: mockMessagePublisher,
},
{
provide: AD_DIRECTION_ENCODER,
useClass: PostgresDirectionEncoder,
},
],
})
.setLogger(mockLogger)
.compile();
return {
prismaService: module.get<PrismaService>(PrismaService),
adRepository: module.get<AdRepository>(AD_REPOSITORY),
};
}

View File

@ -0,0 +1,467 @@
import { MatchQuery } from '@modules/ad/core/application/queries/match/match.query';
import { PassengerOrientedSelector } from '@modules/ad/core/application/queries/match/selector/passenger-oriented.selector';
import { AlgorithmType } from '@modules/ad/core/application/types/algorithm.types';
import { AdEntity } from '@modules/ad/core/domain/ad.entity';
import { CreateAdProps, Frequency } from '@modules/ad/core/domain/ad.types';
import { ScheduleItemProps } from '@modules/ad/core/domain/value-objects/schedule-item.value-object';
import { AdRepository } from '@modules/ad/infrastructure/ad.repository';
import { PrismaService } from '@modules/ad/infrastructure/prisma.service';
import { WaypointDto } from '@modules/ad/interface/grpc-controllers/dtos/waypoint.dto';
import { bareMockGeorouter } from '../unit/georouter.mock';
import {
Marseille,
Nice,
SaintRaphael,
Toulon,
driverNiceMarseille,
monday,
passengerToulonMarseille,
thursday,
wednesday,
} from './ad.fixtures';
import { integrationTestingModule } from './integration.setup';
function baseMatchQuery(
frequency: Frequency,
dates: [string, string],
scheduleItems: ScheduleItemProps[],
waypoints: WaypointDto[],
): MatchQuery {
return new MatchQuery(
{
algorithmType: AlgorithmType.PASSENGER_ORIENTED,
driver: false,
passenger: false,
frequency: frequency,
fromDate: dates[0],
toDate: dates[1],
useAzimuth: false,
useProportion: false,
remoteness: 15000,
schedule: scheduleItems,
strict: false,
waypoints: waypoints,
},
bareMockGeorouter,
);
}
function passengerQueryToulonMarseille(
frequency: Frequency,
dates: [string, string],
scheduleItems: ScheduleItemProps[],
): MatchQuery {
const matchQuery = baseMatchQuery(frequency, dates, scheduleItems, [
{ position: 0, ...Toulon },
{ position: 1, ...Marseille },
]);
matchQuery.passenger = true;
matchQuery.passengerRoute = {
distance: 64000,
duration: 2460,
points: [Toulon, Marseille],
// Not used by this query
fwdAzimuth: 0,
backAzimuth: 0,
distanceAzimuth: 0,
};
return matchQuery;
}
function driverQueryNiceMarseille(
frequency: Frequency,
dates: [string, string],
scheduleItems: ScheduleItemProps[],
): MatchQuery {
const matchQuery = baseMatchQuery(frequency, dates, scheduleItems, [
{ position: 0, ...Nice },
{ position: 1, ...Marseille },
]);
matchQuery.driver = true;
matchQuery.driverRoute = {
distance: 199000,
duration: 7668,
points: [Nice, SaintRaphael, Toulon, Marseille],
// Not used by this query
fwdAzimuth: 0,
backAzimuth: 0,
distanceAzimuth: 0,
};
return matchQuery;
}
describe('PassengerOriented selector', () => {
let prismaService: PrismaService;
let adRepository: AdRepository;
const insertAd = async (adProps: CreateAdProps): Promise<void> => {
const ad = AdEntity.create(adProps);
return adRepository.insertExtra(ad, 'ad');
};
beforeAll(async () => {
({ prismaService, adRepository } = await integrationTestingModule());
});
afterAll(async () => {
await prismaService.$disconnect();
});
beforeEach(async () => {
await prismaService.ad.deleteMany();
});
describe('select', () => {
it('should find a driver that departs on the same day', async () => {
await insertAd(
driverNiceMarseille(
Frequency.PUNCTUAL,
['2023-02-01', '2023-02-01'],
[wednesday('08:30')],
),
);
const passengerOrientedSelector = new PassengerOrientedSelector(
passengerQueryToulonMarseille(
Frequency.PUNCTUAL,
['2023-02-01', '2023-02-01'],
[wednesday('10:00')],
),
adRepository,
);
const candidates = await passengerOrientedSelector.select();
expect(candidates.length).toBe(1);
});
it('should find a passenger that departs on the same day', async () => {
await insertAd(
passengerToulonMarseille(
Frequency.PUNCTUAL,
['2023-02-01', '2023-02-01'],
[wednesday('10:00')],
),
);
const passengerOrientedSelector = new PassengerOrientedSelector(
driverQueryNiceMarseille(
Frequency.PUNCTUAL,
['2023-02-01', '2023-02-01'],
[wednesday('08:30')],
),
adRepository,
);
const candidates = await passengerOrientedSelector.select();
expect(candidates.length).toBe(1);
});
it('should find a driver that departs the day before', async () => {
await insertAd(
driverNiceMarseille(
Frequency.PUNCTUAL,
['2023-02-01', '2023-02-01'],
[wednesday('23:45')],
),
);
const passengerOrientedSelector = new PassengerOrientedSelector(
passengerQueryToulonMarseille(
Frequency.PUNCTUAL,
['2023-02-02', '2023-02-02'],
[thursday('01:15')],
),
adRepository,
);
const candidates = await passengerOrientedSelector.select();
expect(candidates.length).toBe(1);
});
it('should find a passenger that departs the day after', async () => {
await insertAd(
passengerToulonMarseille(
Frequency.PUNCTUAL,
['2023-02-02', '2023-02-02'],
[thursday('01:15')],
),
);
const passengerOrientedSelector = new PassengerOrientedSelector(
driverQueryNiceMarseille(
Frequency.PUNCTUAL,
['2023-02-01', '2023-02-01'],
[wednesday('23:45')],
),
adRepository,
);
const candidates = await passengerOrientedSelector.select();
expect(candidates.length).toBe(1);
});
it('should find a driver that departs shortly after midnight', async () => {
await insertAd(
driverNiceMarseille(
Frequency.PUNCTUAL,
['2023-02-02', '2023-02-02'],
//01:30 in Nice is 00:30 in UTC
[thursday('01:30')],
),
);
const passengerOrientedSelector = new PassengerOrientedSelector(
passengerQueryToulonMarseille(
Frequency.PUNCTUAL,
['2023-02-02', '2023-02-02'],
[thursday('03:00')],
),
adRepository,
);
const candidates = await passengerOrientedSelector.select();
expect(candidates.length).toBe(1);
});
it('should find a passenger that departs shortly after midnight', async () => {
await insertAd(
passengerToulonMarseille(
Frequency.PUNCTUAL,
['2023-02-02', '2023-02-02'],
[thursday('03:00')],
),
);
const passengerOrientedSelector = new PassengerOrientedSelector(
driverQueryNiceMarseille(
Frequency.PUNCTUAL,
['2023-02-02', '2023-02-02'],
[thursday('01:30')],
),
adRepository,
);
const candidates = await passengerOrientedSelector.select();
expect(candidates.length).toBe(1);
});
it('should NOT find a driver that departs the day after', async () => {
await insertAd(
driverNiceMarseille(
Frequency.PUNCTUAL,
['2023-02-02', '2023-02-02'],
[thursday('08:30')],
),
);
const passengerOrientedSelector = new PassengerOrientedSelector(
passengerQueryToulonMarseille(
Frequency.PUNCTUAL,
['2023-02-01', '2023-02-01'],
[wednesday('10:00')],
),
adRepository,
);
const candidates = await passengerOrientedSelector.select();
expect(candidates.length).toBe(0);
});
it('should NOT find a passenger that departs the day before', async () => {
await insertAd(
passengerToulonMarseille(
Frequency.PUNCTUAL,
['2023-02-01', '2023-02-01'],
[wednesday('10:00')],
),
);
const passengerOrientedSelector = new PassengerOrientedSelector(
driverQueryNiceMarseille(
Frequency.PUNCTUAL,
['2023-02-02', '2023-02-02'],
[thursday('08:30')],
),
adRepository,
);
const candidates = await passengerOrientedSelector.select();
expect(candidates.length).toBe(0);
});
it('should find a recurring driver that interesects', async () => {
await Promise.all([
insertAd(
driverNiceMarseille(
Frequency.RECURRENT,
['2023-02-01', '2023-02-28'],
[wednesday('08:30')],
),
),
insertAd(
driverNiceMarseille(
Frequency.RECURRENT,
['2023-02-01', '2023-02-18'],
[wednesday('08:30')],
),
),
insertAd(
driverNiceMarseille(
Frequency.RECURRENT,
['2023-02-12', '2023-02-28'],
[wednesday('08:30')],
),
),
insertAd(
driverNiceMarseille(
Frequency.RECURRENT,
['2023-02-12', '2023-02-18'],
[wednesday('08:30')],
),
),
]);
const passengerOrientedSelector = new PassengerOrientedSelector(
passengerQueryToulonMarseille(
Frequency.RECURRENT,
['2023-02-10', '2023-02-20'],
[wednesday('10:00')],
),
adRepository,
);
const candidates = await passengerOrientedSelector.select();
expect(candidates.length).toBe(4);
});
it("should NOT find a recurring driver that doesn't interesect", async () => {
await Promise.all([
insertAd(
driverNiceMarseille(
Frequency.RECURRENT,
['2023-02-01', '2023-02-10'],
[wednesday('08:30')],
),
),
insertAd(
driverNiceMarseille(
Frequency.RECURRENT,
['2023-02-20', '2023-02-28'],
[wednesday('08:30')],
),
),
]);
const passengerOrientedSelector = new PassengerOrientedSelector(
passengerQueryToulonMarseille(
Frequency.RECURRENT,
['2023-02-12', '2023-02-18'],
[wednesday('10:00')],
),
adRepository,
);
const candidates = await passengerOrientedSelector.select();
expect(candidates.length).toBe(0);
});
it('should find a recurring passenger that interesects', async () => {
await Promise.all([
insertAd(
passengerToulonMarseille(
Frequency.RECURRENT,
['2023-02-01', '2023-02-28'],
[wednesday('10:00')],
),
),
insertAd(
passengerToulonMarseille(
Frequency.RECURRENT,
['2023-02-01', '2023-02-18'],
[wednesday('10:00')],
),
),
insertAd(
passengerToulonMarseille(
Frequency.RECURRENT,
['2023-02-12', '2023-02-28'],
[wednesday('10:00')],
),
),
insertAd(
passengerToulonMarseille(
Frequency.RECURRENT,
['2023-02-12', '2023-02-18'],
[wednesday('10:00')],
),
),
]);
const passengerOrientedSelector = new PassengerOrientedSelector(
driverQueryNiceMarseille(
Frequency.RECURRENT,
['2023-02-10', '2023-02-20'],
[wednesday('08:30')],
),
adRepository,
);
const candidates = await passengerOrientedSelector.select();
expect(candidates.length).toBe(4);
});
it("should NOT find a recurring passenger that doesn't interesect", async () => {
await Promise.all([
insertAd(
passengerToulonMarseille(
Frequency.RECURRENT,
['2023-02-01', '2023-02-10'],
[wednesday('10:00')],
),
),
insertAd(
passengerToulonMarseille(
Frequency.RECURRENT,
['2023-02-20', '2023-02-28'],
[wednesday('10:00')],
),
),
]);
const passengerOrientedSelector = new PassengerOrientedSelector(
driverQueryNiceMarseille(
Frequency.RECURRENT,
['2023-02-12', '2023-02-18'],
[wednesday('08:30')],
),
adRepository,
);
const candidates = await passengerOrientedSelector.select();
expect(candidates.length).toBe(0);
});
it('should find a borderline driver that departs the day before a recurring query', async () => {
await insertAd(
driverNiceMarseille(
Frequency.PUNCTUAL,
['2023-02-01', '2023-02-01'],
[wednesday('23:45')],
),
);
const passengerOrientedSelector = new PassengerOrientedSelector(
passengerQueryToulonMarseille(
Frequency.RECURRENT,
['2023-02-02', '2023-02-28'],
[monday('13:45'), thursday('01:15')],
),
adRepository,
);
const candidates = await passengerOrientedSelector.select();
expect(candidates.length).toBe(1);
});
it('should find a borderline passenger that departs the day after a recurring query', async () => {
await insertAd(
passengerToulonMarseille(
Frequency.PUNCTUAL,
['2023-02-02', '2023-02-02'],
[thursday('01:15')],
),
);
const passengerOrientedSelector = new PassengerOrientedSelector(
driverQueryNiceMarseille(
Frequency.RECURRENT,
['2023-01-01', '2023-02-01'],
[monday('13:45'), wednesday('23:45')],
),
adRepository,
);
const candidates = await passengerOrientedSelector.select();
expect(candidates.length).toBe(1);
});
});
});

View File

@ -1,46 +1,10 @@
import { AdEntity } from '@modules/ad/core/domain/ad.entity'; import { AdEntity } from '@modules/ad/core/domain/ad.entity';
import { CreateAdProps, Frequency } from '@modules/ad/core/domain/ad.types'; import { createAdProps } from './ad.fixtures';
import { PointProps } from '@modules/ad/core/domain/value-objects/point.value-object';
const originPointProps: PointProps = {
lat: 48.689445,
lon: 6.17651,
};
const destinationPointProps: PointProps = {
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: [originPointProps, destinationPointProps],
driverDistance: 23000,
driverDuration: 900,
passengerDistance: 23000,
passengerDuration: 900,
fwdAzimuth: 283,
backAzimuth: 93,
points: [],
};
describe('Ad entity create', () => { describe('Ad entity create', () => {
describe('create', () => {
it('should create a new entity', async () => { it('should create a new entity', async () => {
const ad: AdEntity = AdEntity.create(createAdProps); const ad: AdEntity = AdEntity.create(createAdProps());
expect(ad.id.length).toBe(36); expect(ad.id.length).toBe(36);
expect(ad.getProps().schedule.length).toBe(1); expect(ad.getProps().schedule.length).toBe(1);
expect(ad.getProps().schedule[0].day).toBe(3); expect(ad.getProps().schedule[0].day).toBe(3);
@ -50,3 +14,4 @@ describe('Ad entity create', () => {
expect(ad.getProps().driverDistance).toBe(23000); expect(ad.getProps().driverDistance).toBe(23000);
}); });
}); });
});

View File

@ -0,0 +1,40 @@
import { CreateAdProps, Frequency } from '@modules/ad/core/domain/ad.types';
import { PointProps } from '@modules/ad/core/domain/value-objects/point.value-object';
const originPointProps: PointProps = {
lat: 48.689445,
lon: 6.17651,
};
const destinationPointProps: PointProps = {
lat: 48.8566,
lon: 2.3522,
};
export function createAdProps(): CreateAdProps {
return {
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: [originPointProps, destinationPointProps],
driverDistance: 23000,
driverDuration: 900,
passengerDistance: 23000,
passengerDuration: 900,
fwdAzimuth: 283,
backAzimuth: 93,
points: [],
};
}

View File

@ -37,7 +37,7 @@ const waypointsSet2: PointProps[] = [
}, },
]; ];
const schedule1: ScheduleItemProps[] = [ const mondayAt7: ScheduleItemProps[] = [
{ {
day: 1, day: 1,
time: '07:00', time: '07:00',
@ -45,7 +45,7 @@ const schedule1: ScheduleItemProps[] = [
}, },
]; ];
const schedule2: ScheduleItemProps[] = [ const mondayAt710: ScheduleItemProps[] = [
{ {
day: 1, day: 1,
time: '07:10', time: '07:10',
@ -53,7 +53,7 @@ const schedule2: ScheduleItemProps[] = [
}, },
]; ];
const schedule3: ScheduleItemProps[] = [ const weekdayMornings: ScheduleItemProps[] = [
{ {
day: 1, day: 1,
time: '06:30', time: '06:30',
@ -81,7 +81,7 @@ const schedule3: ScheduleItemProps[] = [
}, },
]; ];
const schedule4: ScheduleItemProps[] = [ const schooldayMornings: ScheduleItemProps[] = [
{ {
day: 1, day: 1,
time: '06:50', time: '06:50',
@ -104,7 +104,7 @@ const schedule4: ScheduleItemProps[] = [
}, },
]; ];
const schedule5: ScheduleItemProps[] = [ const saturdayNightAndMondayMorning: ScheduleItemProps[] = [
{ {
day: 0, day: 0,
time: '00:02', time: '00:02',
@ -117,7 +117,7 @@ const schedule5: ScheduleItemProps[] = [
}, },
]; ];
const schedule6: ScheduleItemProps[] = [ const mondayAndSaturdayNights: ScheduleItemProps[] = [
{ {
day: 1, day: 1,
time: '23:10', time: '23:10',
@ -130,7 +130,7 @@ const schedule6: ScheduleItemProps[] = [
}, },
]; ];
const schedule7: ScheduleItemProps[] = [ const thursdayEvening: ScheduleItemProps[] = [
{ {
day: 4, day: 4,
time: '19:00', time: '19:00',
@ -266,8 +266,8 @@ describe('Candidate entity', () => {
passengerWaypoints: waypointsSet2, passengerWaypoints: waypointsSet2,
driverDistance: 350145, driverDistance: 350145,
driverDuration: 13548, driverDuration: 13548,
driverSchedule: schedule1, driverSchedule: mondayAt7,
passengerSchedule: schedule2, passengerSchedule: mondayAt710,
spacetimeDetourRatio, spacetimeDetourRatio,
}); });
expect(candidateEntity.id.length).toBe(36); expect(candidateEntity.id.length).toBe(36);
@ -286,8 +286,8 @@ describe('Candidate entity', () => {
passengerWaypoints: waypointsSet2, passengerWaypoints: waypointsSet2,
driverDistance: 350145, driverDistance: 350145,
driverDuration: 13548, driverDuration: 13548,
driverSchedule: schedule1, driverSchedule: mondayAt7,
passengerSchedule: schedule2, passengerSchedule: mondayAt710,
spacetimeDetourRatio, spacetimeDetourRatio,
}).setCarpoolPath(carpoolPath1); }).setCarpoolPath(carpoolPath1);
expect(candidateEntity.getProps().carpoolPath).toHaveLength(2); expect(candidateEntity.getProps().carpoolPath).toHaveLength(2);
@ -306,8 +306,8 @@ describe('Candidate entity', () => {
passengerWaypoints: waypointsSet2, passengerWaypoints: waypointsSet2,
driverDistance: 350145, driverDistance: 350145,
driverDuration: 13548, driverDuration: 13548,
driverSchedule: schedule1, driverSchedule: mondayAt7,
passengerSchedule: schedule2, passengerSchedule: mondayAt710,
spacetimeDetourRatio, spacetimeDetourRatio,
}).setMetrics(352688, 14587); }).setMetrics(352688, 14587);
expect(candidateEntity.getProps().distance).toBe(352688); expect(candidateEntity.getProps().distance).toBe(352688);
@ -328,8 +328,8 @@ describe('Candidate entity', () => {
passengerWaypoints: waypointsSet2, passengerWaypoints: waypointsSet2,
driverDistance: 350145, driverDistance: 350145,
driverDuration: 13548, driverDuration: 13548,
driverSchedule: schedule1, driverSchedule: mondayAt7,
passengerSchedule: schedule2, passengerSchedule: mondayAt710,
spacetimeDetourRatio, spacetimeDetourRatio,
}).setMetrics(458690, 13980); }).setMetrics(458690, 13980);
expect(candidateEntity.isDetourValid()).toBeFalsy(); expect(candidateEntity.isDetourValid()).toBeFalsy();
@ -347,8 +347,8 @@ describe('Candidate entity', () => {
passengerWaypoints: waypointsSet2, passengerWaypoints: waypointsSet2,
driverDistance: 350145, driverDistance: 350145,
driverDuration: 13548, driverDuration: 13548,
driverSchedule: schedule1, driverSchedule: mondayAt7,
passengerSchedule: schedule2, passengerSchedule: mondayAt710,
spacetimeDetourRatio, spacetimeDetourRatio,
}).setMetrics(352368, 18314); }).setMetrics(352368, 18314);
expect(candidateEntity.isDetourValid()).toBeFalsy(); expect(candidateEntity.isDetourValid()).toBeFalsy();
@ -369,8 +369,8 @@ describe('Candidate entity', () => {
passengerWaypoints: waypointsSet2, passengerWaypoints: waypointsSet2,
driverDistance: 350145, driverDistance: 350145,
driverDuration: 13548, driverDuration: 13548,
driverSchedule: schedule1, driverSchedule: mondayAt7,
passengerSchedule: schedule2, passengerSchedule: mondayAt710,
spacetimeDetourRatio, spacetimeDetourRatio,
}) })
.setCarpoolPath(carpoolPath2) .setCarpoolPath(carpoolPath2)
@ -392,7 +392,7 @@ describe('Candidate entity', () => {
driverDistance: 350145, driverDistance: 350145,
driverDuration: 13548, driverDuration: 13548,
driverSchedule: undefined, driverSchedule: undefined,
passengerSchedule: schedule2, passengerSchedule: mondayAt710,
spacetimeDetourRatio, spacetimeDetourRatio,
}) })
.setCarpoolPath(carpoolPath2) .setCarpoolPath(carpoolPath2)
@ -424,7 +424,7 @@ describe('Candidate entity', () => {
passengerWaypoints: waypointsSet2, passengerWaypoints: waypointsSet2,
driverDistance: 350145, driverDistance: 350145,
driverDuration: 13548, driverDuration: 13548,
driverSchedule: schedule1, driverSchedule: mondayAt7,
passengerSchedule: undefined, passengerSchedule: undefined,
spacetimeDetourRatio, spacetimeDetourRatio,
}) })
@ -480,8 +480,8 @@ describe('Candidate entity', () => {
passengerWaypoints: waypointsSet2, passengerWaypoints: waypointsSet2,
driverDistance: 350145, driverDistance: 350145,
driverDuration: 13548, driverDuration: 13548,
driverSchedule: schedule3, driverSchedule: weekdayMornings,
passengerSchedule: schedule4, passengerSchedule: schooldayMornings,
spacetimeDetourRatio, spacetimeDetourRatio,
}) })
.setCarpoolPath(carpoolPath2) .setCarpoolPath(carpoolPath2)
@ -517,8 +517,8 @@ describe('Candidate entity', () => {
passengerWaypoints: waypointsSet2, passengerWaypoints: waypointsSet2,
driverDistance: 350145, driverDistance: 350145,
driverDuration: 13548, driverDuration: 13548,
driverSchedule: schedule5, driverSchedule: saturdayNightAndMondayMorning,
passengerSchedule: schedule6, passengerSchedule: mondayAndSaturdayNights,
spacetimeDetourRatio, spacetimeDetourRatio,
}) })
.setCarpoolPath(carpoolPath2) .setCarpoolPath(carpoolPath2)
@ -550,6 +550,33 @@ describe('Candidate entity', () => {
)[0].journeyItems[1].actorTimes[1].firstMinDatetime.getUTCMinutes(), )[0].journeyItems[1].actorTimes[1].firstMinDatetime.getUTCMinutes(),
).toBe(42); ).toBe(42);
}); });
it('should create a journey for a punctual search from a recurrent driver schedule', () => {
const candidateEntity: CandidateEntity = CandidateEntity.create({
id: '5600ccfb-ab69-4d03-aa30-0fbe84fcedc0',
role: Role.PASSENGER,
frequency: Frequency.PUNCTUAL,
dateInterval: {
lowerDate: '2024-04-01', //This is a Monday
higherDate: '2024-04-01',
},
driverWaypoints: waypointsSet1,
passengerWaypoints: waypointsSet2,
driverDistance: 350145,
driverDuration: 13548,
driverSchedule: weekdayMornings,
passengerSchedule: mondayAt7,
spacetimeDetourRatio,
})
.setCarpoolPath(carpoolPath2)
.setSteps(steps)
.createJourneys();
expect(candidateEntity.getProps().journeys).toHaveLength(1);
expect(
(
candidateEntity.getProps().journeys as Journey[]
)[0].firstDate.getDate(),
).toBe(1);
});
it('should not create journeys if dates does not match', () => { it('should not create journeys if dates does not match', () => {
const candidateEntity: CandidateEntity = CandidateEntity.create({ const candidateEntity: CandidateEntity = CandidateEntity.create({
@ -564,8 +591,8 @@ describe('Candidate entity', () => {
passengerWaypoints: waypointsSet2, passengerWaypoints: waypointsSet2,
driverDistance: 350145, driverDistance: 350145,
driverDuration: 13548, driverDuration: 13548,
driverSchedule: schedule1, driverSchedule: mondayAt7,
passengerSchedule: schedule7, passengerSchedule: thursdayEvening,
spacetimeDetourRatio, spacetimeDetourRatio,
}) })
.setCarpoolPath(carpoolPath2) .setCarpoolPath(carpoolPath2)
@ -588,8 +615,8 @@ describe('Candidate entity', () => {
passengerWaypoints: waypointsSet2, passengerWaypoints: waypointsSet2,
driverDistance: 350145, driverDistance: 350145,
driverDuration: 13548, driverDuration: 13548,
driverSchedule: schedule1, driverSchedule: mondayAt7,
passengerSchedule: schedule7, passengerSchedule: thursdayEvening,
spacetimeDetourRatio, spacetimeDetourRatio,
}) })
.setCarpoolPath(carpoolPath2) .setCarpoolPath(carpoolPath2)

View File

@ -33,22 +33,4 @@ describe('Carpool Path Item value object', () => {
}); });
}).toThrow(ArgumentOutOfRangeException); }).toThrow(ArgumentOutOfRangeException);
}); });
it('should throw an exception if actors contains more than one driver', () => {
expect(() => {
new CarpoolPathItem({
lat: 48.689445,
lon: 6.17651,
actors: [
new Actor({
role: Role.DRIVER,
target: Target.NEUTRAL,
}),
new Actor({
role: Role.DRIVER,
target: Target.START,
}),
],
});
}).toThrow(ArgumentOutOfRangeException);
});
}); });

View File

@ -1,18 +1,17 @@
import { Test, TestingModule } from '@nestjs/testing'; import { AggregateID, ConflictException } from '@mobicoop/ddd-library';
import { import {
AD_MESSAGE_PUBLISHER, AD_MESSAGE_PUBLISHER,
AD_REPOSITORY, AD_REPOSITORY,
AD_ROUTE_PROVIDER, AD_ROUTE_PROVIDER,
} from '@modules/ad/ad.di-tokens'; } 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 { CreateAdCommand } from '@modules/ad/core/application/commands/create-ad/create-ad.command';
import { CreateAdService } from '@modules/ad/core/application/commands/create-ad/create-ad.service';
import { AdEntity } from '@modules/ad/core/domain/ad.entity';
import { AdAlreadyExistsException } from '@modules/ad/core/domain/ad.errors'; import { AdAlreadyExistsException } from '@modules/ad/core/domain/ad.errors';
import { CreateAdProps, Frequency } from '@modules/ad/core/domain/ad.types';
import { GeorouterService } from '@modules/ad/core/domain/georouter.service';
import { PointProps } from '@modules/ad/core/domain/value-objects/point.value-object'; import { PointProps } from '@modules/ad/core/domain/value-objects/point.value-object';
import { GeorouterPort } from '@modules/ad/core/application/ports/georouter.port'; import { Test, TestingModule } from '@nestjs/testing';
const originWaypoint: PointProps = { const originWaypoint: PointProps = {
lat: 48.689445, lat: 48.689445,
@ -62,7 +61,7 @@ const mockAdRepository = {
}), }),
}; };
const mockRouteProvider: GeorouterPort = { const mockRouteProvider: GeorouterService = {
getRoute: jest getRoute: jest
.fn() .fn()
.mockImplementationOnce(() => { .mockImplementationOnce(() => {

View File

@ -0,0 +1,41 @@
import { AD_REPOSITORY } from '@modules/ad/ad.di-tokens';
import { DeleteAdCommand } from '@modules/ad/core/application/commands/delete-ad/delete-ad.command';
import { DeleteAdService } from '@modules/ad/core/application/commands/delete-ad/delete-ad.service';
import { AdEntity } from '@modules/ad/core/domain/ad.entity';
import { Test, TestingModule } from '@nestjs/testing';
import { createAdProps } from './ad.fixtures';
const ad: AdEntity = AdEntity.create(createAdProps());
const mockAdRepository = {
findOneById: jest.fn().mockImplementation(() => ad),
delete: jest.fn(),
};
describe('DeleteAdService', () => {
let deleteAdService: DeleteAdService;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
{
provide: AD_REPOSITORY,
useValue: mockAdRepository,
},
DeleteAdService,
],
}).compile();
deleteAdService = module.get<DeleteAdService>(DeleteAdService);
});
it('should be defined', () => {
expect(deleteAdService).toBeDefined();
});
it('should execute the delete logic and delete the ad from the repository', async () => {
jest.spyOn(ad, 'delete');
await deleteAdService.execute(new DeleteAdCommand(ad.id));
expect(ad.delete).toHaveBeenCalled();
expect(mockAdRepository.delete).toHaveBeenCalledWith(ad);
});
});

View File

@ -1,8 +1,8 @@
import { import {
Domain,
KeyType,
Configurator, Configurator,
Domain,
GetConfigurationRepositoryPort, GetConfigurationRepositoryPort,
KeyType,
} from '@mobicoop/configuration-module'; } from '@mobicoop/configuration-module';
import { import {
CARPOOL_CONFIG_DEPARTURE_TIME_MARGIN, CARPOOL_CONFIG_DEPARTURE_TIME_MARGIN,
@ -16,8 +16,9 @@ import {
AD_REPOSITORY, AD_REPOSITORY,
INPUT_DATETIME_TRANSFORMER, INPUT_DATETIME_TRANSFORMER,
MATCHING_REPOSITORY, MATCHING_REPOSITORY,
TIMEZONE_FINDER,
TIME_CONVERTER,
} from '@modules/ad/ad.di-tokens'; } from '@modules/ad/ad.di-tokens';
import { DateTimeTransformerPort } from '@modules/ad/core/application/ports/datetime-transformer.port';
import { MatchingRepositoryPort } from '@modules/ad/core/application/ports/matching.repository.port'; import { MatchingRepositoryPort } from '@modules/ad/core/application/ports/matching.repository.port';
import { MatchQuery } from '@modules/ad/core/application/queries/match/match.query'; import { MatchQuery } from '@modules/ad/core/application/queries/match/match.query';
import { import {
@ -30,6 +31,9 @@ import { Frequency, Role } from '@modules/ad/core/domain/ad.types';
import { Target } from '@modules/ad/core/domain/candidate.types'; import { Target } from '@modules/ad/core/domain/candidate.types';
import { MatchEntity } from '@modules/ad/core/domain/match.entity'; import { MatchEntity } from '@modules/ad/core/domain/match.entity';
import { MatchingEntity } from '@modules/ad/core/domain/matching.entity'; import { MatchingEntity } from '@modules/ad/core/domain/matching.entity';
import { InputDateTimeTransformer } from '@modules/ad/infrastructure/input-datetime-transformer';
import { TimeConverter } from '@modules/ad/infrastructure/time-converter';
import { TimezoneFinder } from '@modules/ad/infrastructure/timezone-finder';
import { import {
MATCH_CONFIG_ALGORITHM, MATCH_CONFIG_ALGORITHM,
MATCH_CONFIG_AZIMUTH_MARGIN, MATCH_CONFIG_AZIMUTH_MARGIN,
@ -344,13 +348,6 @@ const mockConfigurationRepository: GetConfigurationRepositoryPort = {
), ),
}; };
const mockInputDateTimeTransformer: DateTimeTransformerPort = {
fromDate: jest.fn(),
toDate: jest.fn(),
day: jest.fn(),
time: jest.fn(),
};
const mockRouteProvider = simpleMockGeorouter; const mockRouteProvider = simpleMockGeorouter;
describe('Match Query Handler', () => { describe('Match Query Handler', () => {
@ -372,9 +369,17 @@ describe('Match Query Handler', () => {
provide: AD_CONFIGURATION_REPOSITORY, provide: AD_CONFIGURATION_REPOSITORY,
useValue: mockConfigurationRepository, useValue: mockConfigurationRepository,
}, },
{
provide: TIMEZONE_FINDER,
useClass: TimezoneFinder,
},
{
provide: TIME_CONVERTER,
useClass: TimeConverter,
},
{ {
provide: INPUT_DATETIME_TRANSFORMER, provide: INPUT_DATETIME_TRANSFORMER,
useValue: mockInputDateTimeTransformer, useClass: InputDateTimeTransformer,
}, },
], ],
}).compile(); }).compile();

View File

@ -1,5 +1,4 @@
import { DateTimeTransformerPort } from '@modules/ad/core/application/ports/datetime-transformer.port'; import { DateTimeTransformerPort } from '@modules/ad/core/application/ports/datetime-transformer.port';
import { GeorouterPort } from '@modules/ad/core/application/ports/georouter.port';
import { import {
MatchQuery, MatchQuery,
ScheduleItem, ScheduleItem,
@ -7,6 +6,7 @@ import {
import { AlgorithmType } from '@modules/ad/core/application/types/algorithm.types'; import { AlgorithmType } from '@modules/ad/core/application/types/algorithm.types';
import { Waypoint } from '@modules/ad/core/application/types/waypoint.type'; import { Waypoint } from '@modules/ad/core/application/types/waypoint.type';
import { Frequency } from '@modules/ad/core/domain/ad.types'; import { Frequency } from '@modules/ad/core/domain/ad.types';
import { GeorouterService } from '@modules/ad/core/domain/georouter.service';
import { simpleMockGeorouter } from '../georouter.mock'; import { simpleMockGeorouter } from '../georouter.mock';
const originWaypoint: Waypoint = { const originWaypoint: Waypoint = {
@ -61,7 +61,7 @@ const mockInputDateTimeTransformer: DateTimeTransformerPort = {
time: jest.fn().mockImplementation(() => '23:05'), time: jest.fn().mockImplementation(() => '23:05'),
}; };
const mockRouteProvider: GeorouterPort = { const mockRouteProvider: GeorouterService = {
getRoute: jest getRoute: jest
.fn() .fn()
.mockImplementationOnce(simpleMockGeorouter.getRoute) .mockImplementationOnce(simpleMockGeorouter.getRoute)

View File

@ -47,6 +47,7 @@ const matchQuery = new MatchQuery(
], ],
strict: false, strict: false,
waypoints: [originWaypoint, destinationWaypoint], waypoints: [originWaypoint, destinationWaypoint],
excludedAdId: '758618c6-dd82-4199-a548-0205161b04d7',
}, },
bareMockGeorouter, bareMockGeorouter,
); );
@ -71,27 +72,7 @@ matchQuery.driverRoute = {
}, },
], ],
}; };
matchQuery.passengerRoute = { matchQuery.passengerRoute = { ...matchQuery.driverRoute };
distance: 150120,
duration: 6540,
fwdAzimuth: 276,
backAzimuth: 96,
distanceAzimuth: 148321,
points: [
{
lat: 48.689445,
lon: 6.17651,
},
{
lat: 48.7566,
lon: 4.3522,
},
{
lat: 48.8566,
lon: 2.3522,
},
],
};
const mockMatcherRepository: AdRepositoryPort = { const mockMatcherRepository: AdRepositoryPort = {
insertExtra: jest.fn(), insertExtra: jest.fn(),

View File

@ -1,7 +1,3 @@
import {
RouteRequest,
RouteResponse,
} from '@modules/ad/core/application/ports/georouter.port';
import { import {
RouteCompleter, RouteCompleter,
RouteCompleterType, RouteCompleterType,
@ -12,6 +8,10 @@ import { Waypoint } from '@modules/ad/core/application/types/waypoint.type';
import { Frequency, Role } from '@modules/ad/core/domain/ad.types'; import { Frequency, Role } from '@modules/ad/core/domain/ad.types';
import { CandidateEntity } from '@modules/ad/core/domain/candidate.entity'; import { CandidateEntity } from '@modules/ad/core/domain/candidate.entity';
import { Target } from '@modules/ad/core/domain/candidate.types'; import { Target } from '@modules/ad/core/domain/candidate.types';
import {
RouteRequest,
RouteResponse,
} from '@modules/ad/core/domain/georouter.service';
import { Actor } from '@modules/ad/core/domain/value-objects/actor.value-object'; import { Actor } from '@modules/ad/core/domain/value-objects/actor.value-object';
import { Step } from '@modules/geography/core/domain/route.types'; import { Step } from '@modules/geography/core/domain/route.types';
import { simpleMockGeorouter } from '../georouter.mock'; import { simpleMockGeorouter } from '../georouter.mock';

View File

@ -0,0 +1,94 @@
import {
AD_MESSAGE_PUBLISHER,
AD_REPOSITORY,
AD_ROUTE_PROVIDER,
} from '@modules/ad/ad.di-tokens';
import { UpdateAdCommand } from '@modules/ad/core/application/commands/update-ad/update-ad.command';
import { UpdateAdService } from '@modules/ad/core/application/commands/update-ad/update-ad.service';
import { GeorouterService } from '@modules/ad/core/domain/georouter.service';
import { Test, TestingModule } from '@nestjs/testing';
import { createAdProps } from './ad.fixtures';
const mockAdRepository = {
update: jest.fn().mockImplementation((id) => {
if (id === '42') {
throw 'Bad id!';
}
}),
};
const mockRouteProvider: GeorouterService = {
getRoute: jest.fn().mockImplementation(() => ({
distance: 350101,
duration: 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,
},
],
})),
};
const mockMessagePublisher = {
publish: jest.fn().mockImplementation(),
};
describe('update-ad.service', () => {
let updateAdService: UpdateAdService;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
{
provide: AD_REPOSITORY,
useValue: mockAdRepository,
},
{
provide: AD_ROUTE_PROVIDER,
useValue: mockRouteProvider,
},
{
provide: AD_MESSAGE_PUBLISHER,
useValue: mockMessagePublisher,
},
UpdateAdService,
],
}).compile();
updateAdService = module.get<UpdateAdService>(UpdateAdService);
});
it('should be defined', () => {
expect(updateAdService).toBeDefined();
});
describe('execute', () => {
it('should call the repository update method', async () => {
const updateAdCommand = new UpdateAdCommand(createAdProps());
await updateAdService.execute(updateAdCommand);
expect(mockAdRepository.update).toHaveBeenCalled();
});
it('should emit an event when an error occurs', async () => {
const commandProps = createAdProps();
commandProps.id = '42';
const updateAdCommand = new UpdateAdCommand(commandProps);
await expect(updateAdService.execute(updateAdCommand)).rejects.toBe(
'Bad id!',
);
expect(mockMessagePublisher.publish).toHaveBeenCalled();
});
});
});

View File

@ -1,10 +1,10 @@
import { GeorouterPort } from '@modules/ad/core/application/ports/georouter.port'; import { GeorouterService } from '@modules/ad/core/domain/georouter.service';
export const bareMockGeorouter: GeorouterPort = { export const bareMockGeorouter: GeorouterService = {
getRoute: jest.fn(), getRoute: jest.fn(),
}; };
export const simpleMockGeorouter: GeorouterPort = { export const simpleMockGeorouter: GeorouterService = {
getRoute: jest.fn().mockImplementation(() => ({ getRoute: jest.fn().mockImplementation(() => ({
distance: 350101, distance: 350101,
duration: 14422, duration: 14422,

View File

@ -0,0 +1,44 @@
import { AdDeletedMessageHandler } from '@modules/ad/interface/message-handlers/ad-deleted.message-handler';
import { CommandBus } from '@nestjs/cqrs';
import { Test, TestingModule } from '@nestjs/testing';
const adDeletedMessage =
'{"aggregateId":"4eb6a6af-ecfd-41c3-9118-473a507014d4"}';
const mockCommandBus = {
execute: jest.fn(),
};
describe('Ad Deleted Message Handler', () => {
let adDeletedMessageHandler: AdDeletedMessageHandler;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
{
provide: CommandBus,
useValue: mockCommandBus,
},
AdDeletedMessageHandler,
],
}).compile();
adDeletedMessageHandler = module.get<AdDeletedMessageHandler>(
AdDeletedMessageHandler,
);
});
afterEach(async () => {
jest.clearAllMocks();
});
it('should be defined', () => {
expect(adDeletedMessageHandler).toBeDefined();
});
it('should call the delete command', async () => {
jest.spyOn(mockCommandBus, 'execute');
await adDeletedMessageHandler.adDeleted(adDeletedMessage);
expect(mockCommandBus.execute).toHaveBeenCalledTimes(1);
});
});

View File

@ -0,0 +1,43 @@
import { AdUpdatedMessageHandler } from '@modules/ad/interface/message-handlers/ad-updated.message-handler';
import { CommandBus } from '@nestjs/cqrs';
import { Test, TestingModule } from '@nestjs/testing';
const adUpdatedMessage =
'{"data": {"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 Updated Message Handler', () => {
let adUpdatedMessageHandler: AdUpdatedMessageHandler;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
{
provide: CommandBus,
useValue: mockCommandBus,
},
AdUpdatedMessageHandler,
],
}).compile();
adUpdatedMessageHandler = module.get<AdUpdatedMessageHandler>(
AdUpdatedMessageHandler,
);
});
afterEach(async () => {
jest.clearAllMocks();
});
it('should be defined', () => {
expect(adUpdatedMessageHandler).toBeDefined();
});
it('should update an ad', async () => {
await adUpdatedMessageHandler.adUpdated(adUpdatedMessage);
expect(mockCommandBus.execute).toHaveBeenCalledTimes(1);
});
});

View File

@ -1,17 +1,23 @@
import { Module, Provider } from '@nestjs/common';
import { MESSAGE_PUBLISHER } from './messager.di-tokens';
import { import {
MessageBrokerModule, MessageBrokerModule,
MessageBrokerModuleOptions, MessageBrokerModuleOptions,
MessageBrokerPublisher, MessageBrokerPublisher,
} from '@mobicoop/message-broker-module'; } from '@mobicoop/message-broker-module';
import { Module, Provider } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config'; import { ConfigModule, ConfigService } from '@nestjs/config';
import { import {
AD_CREATED_MESSAGE_HANDLER, AD_CREATED_MESSAGE_HANDLER,
AD_CREATED_QUEUE, AD_CREATED_QUEUE,
AD_CREATED_ROUTING_KEY, AD_CREATED_ROUTING_KEY,
AD_DELETED_MESSAGE_HANDLER,
AD_DELETED_QUEUE,
AD_DELETED_ROUTING_KEY,
AD_UPDATED_MESSAGE_HANDLER,
AD_UPDATED_QUEUE,
AD_UPDATED_ROUTING_KEY,
SERVICE_NAME, SERVICE_NAME,
} from '@src/app.constants'; } from '@src/app.constants';
import { MESSAGE_PUBLISHER } from './messager.di-tokens';
const imports = [ const imports = [
MessageBrokerModule.forRootAsync({ MessageBrokerModule.forRootAsync({
@ -33,6 +39,14 @@ const imports = [
routingKey: AD_CREATED_ROUTING_KEY, routingKey: AD_CREATED_ROUTING_KEY,
queue: AD_CREATED_QUEUE, queue: AD_CREATED_QUEUE,
}, },
[AD_UPDATED_MESSAGE_HANDLER]: {
routingKey: AD_UPDATED_ROUTING_KEY,
queue: AD_UPDATED_QUEUE,
},
[AD_DELETED_MESSAGE_HANDLER]: {
routingKey: AD_DELETED_ROUTING_KEY,
queue: AD_DELETED_QUEUE,
},
}, },
}), }),
}), }),

View File

@ -1,4 +1,4 @@
{ {
"extends": "./tsconfig.json", "extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"] "exclude": ["node_modules", "tests", "dist", "**/*spec.ts"]
} }