88 Commits

Author SHA1 Message Date
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
Romain Thouvenin
f759581157 Move build job to a common template 2024-04-02 11:42:54 +02:00
Romain Thouvenin
08b5af7511 Update CI script for release-branch workflow 2024-04-02 11:23:34 +02:00
Romain Thouvenin
4581af5e9f Define an exception filter to log the cause of exceptions 2024-04-02 10:27:53 +02:00
Romain Thouvenin
7f7a51d19b Let Nest exception filter handle unexpected exceptions 2024-04-02 10:27:53 +02:00
Sylvain Briat
739d05b095 improve candidate entity for empty driver or passenger schedule 2024-03-29 15:10:10 +01:00
Sylvain Briat
212b609e26 add array min size check on schedule property 2024-03-29 15:10:10 +01:00
Romain Thouvenin
bd6fc1576b Apply 1 suggestion(s) to 1 file(s) 2024-03-29 14:04:57 +00:00
Sylvain Briat
53df6183bd fix driverTime in journey item 2024-03-29 11:56:42 +01:00
Sylvain Briat
ffeb009497 fix readme for optional schedule 2024-03-28 17:12:30 +00:00
Sylvain Briat
3786fcc2c2 improve TODO explanation in journey value object 2024-03-28 17:12:30 +00:00
Sylvain Briat
e53c12ba74 fix readme for optional schedule 2024-03-28 17:12:30 +00:00
Sylvain Briat
c5a5e33256 update readme for optional schedule 2024-03-28 17:12:30 +00:00
Sylvain Briat
924547c316 update mtching mapper to handle empty schedules 2024-03-28 17:12:30 +00:00
Sylvain Briat
5f8dd8b4a0 handle empty schedule in candidates 2024-03-28 17:12:30 +00:00
Sylvain Briat
90ae3cf9cb update passenger oriented selector to handle empty schedule; improve punctual where part 2024-03-28 17:12:30 +00:00
Sylvain Briat
6b9bf53b4a set schedule to optional in match query and query handler 2024-03-28 17:12:30 +00:00
Sylvain Briat
4fd2950027 set schedule to optional in match request 2024-03-28 17:12:30 +00:00
Romain Thouvenin
2ce2a46c95 Allow remote debugging of dev container 2024-03-28 16:59:28 +01:00
Romain Thouvenin
579415c300 Rename GeorouterProvider[Port] to Georouter[Port] 2024-03-19 08:04:28 +01:00
Romain Thouvenin
357746e843 Rename geocoder service to geography 2024-03-14 16:47:54 +01:00
Romain Thouvenin
96c30cb1cc Remove most of the geography module and delegate it to external gRPC microservice 2024-03-14 10:19:15 +01:00
Sylvain Briat
d09bad60f7 1.5.5 2024-02-08 16:18:16 +00:00
Sylvain Briat
3be95fb58c fix wrong carpool crew for a driver query 2024-02-08 16:18:16 +00:00
Fanch
085de292c6 copy file from v3 gitlab template repo 2024-02-05 19:16:08 +01:00
Sylvain Briat
0d537cd6a4 Merge branch 'fixStatus' into 'main'
Removed useless status in matching sql request

See merge request v3/service/matcher!29
2024-02-02 07:53:55 +00:00
Sylvain Briat
50c5b99e54 1.5.4 2024-02-01 16:59:58 +01:00
Sylvain Briat
c42ddef7e4 Removed useless status in matching sql request 2024-02-01 16:55:52 +01:00
Sylvain Briat
21c2bc663c Merge branch 'use_prisma_deploy' into 'main'
use prisma deploy as default for migrate, add migrate:dev command

See merge request v3/service/matcher!28
2024-01-31 14:18:42 +00:00
Fanch
4089619807 use prisma deploy as default for migrate, add migrate:dev command 2024-01-31 12:47:27 +01:00
Fanch
0c272795e9 Merge branch 'fix_test_install' into 'main'
Fix test install

See merge request v3/service/matcher!26
2024-01-24 15:13:16 +00:00
Fanch
6fa8594fa6 use node lts image for docker 2024-01-22 16:57:44 +01:00
Fanch
319cc7b7a7 use full registry path for docker image 2024-01-17 10:25:39 +01:00
Sylvain Briat
c392d87f43 Merge branch 'updatePackages' into 'main'
Update packages

See merge request v3/service/matcher!27
2024-01-17 08:07:07 +00:00
Sylvain Briat
d1942c954a pretty 2024-01-17 08:58:14 +01:00
Sylvain Briat
6df331f990 1.5.3 2024-01-17 08:54:50 +01:00
Sylvain Briat
d47de20588 update packages 2024-01-17 08:54:41 +01:00
Sylvain Briat
957eb93f3e Merge branch 'upgradeGH' into 'main'
Upgrade gh

See merge request v3/service/matcher!25
2023-12-18 14:55:03 +00:00
Sylvain Briat
4ccfba12fa 1.5.2 2023-12-18 15:50:30 +01:00
Sylvain Briat
3aaccfa48f upgrade graphhopper api params, secure broker 2023-12-18 15:49:44 +01:00
Sylvain Briat
d172cac7f4 Merge branch 'fixAdValidation' into 'main'
Fix ad validation

See merge request v3/service/matcher!24
2023-12-07 10:20:14 +00:00
Sylvain Briat
0729670574 1.5.1 2023-12-07 11:14:08 +01:00
Sylvain Briat
ce4107ddd7 fix broker queues and keys 2023-12-07 11:14:01 +01:00
Sylvain Briat
3503e53d79 Merge branch 'adCreatedEvent' into 'main'
Send messages when a matcher ad is created, or when a matcher ad creation has failed

See merge request v3/service/matcher!23
2023-12-06 14:21:24 +00:00
Sylvain Briat
ecab239928 1.5.0 2023-12-06 15:16:38 +01:00
Sylvain Briat
80fac59c43 send messages when matcher ad is created, or when matcher ad creation has failed 2023-12-06 15:16:29 +01:00
Sylvain Briat
73f660bf6d Merge branch 'fixMatchRequestDto' into 'main'
Fix match request dto

See merge request v3/service/matcher!22
2023-11-21 15:36:57 +00:00
Sylvain Briat
df92231f04 1.4.4 2023-11-21 16:32:41 +01:00
Sylvain Briat
de239848c3 fix bad validation for distance and duration ratio, and for proportion 2023-11-21 16:32:32 +01:00
Sylvain Briat
59596fadee Merge branch 'fixEmptyJourneys' into 'main'
Fix empty journeys

See merge request v3/service/matcher!21
2023-11-21 15:15:39 +00:00
Sylvain Briat
eee0fd070a 1.4.3 2023-11-21 16:10:25 +01:00
Sylvain Briat
970260f0d2 fix crash when no date is available in recurrent for a journey 2023-11-21 16:10:19 +01:00
Sylvain Briat
f21fa0e9b0 Merge branch 'updateProto' into 'main'
Update proto

See merge request v3/service/matcher!20
2023-11-06 16:35:15 +00:00
Sylvain Briat
a5bb249193 remove forgotten console log 2023-11-06 17:31:05 +01:00
Sylvain Briat
fef0779aaa 1.4.2 2023-11-06 17:27:56 +01:00
Sylvain Briat
fb3f1cf4df updateProto 2023-11-06 17:27:49 +01:00
Sylvain Briat
703865dc38 Merge branch 'fixReadme' into 'main'
Fix readme

See merge request v3/service/matcher!19
2023-11-06 09:10:22 +00:00
Sylvain Briat
75bb82094d 1.4.1 2023-11-06 10:05:24 +01:00
Sylvain Briat
8d2964659b remove match id from doc 2023-11-06 10:05:10 +01:00
Sylvain Briat
8eb1b51457 Merge branch 'removeMatchId' into 'main'
Remove useless match id from matching in proto file

See merge request v3/service/matcher!18
2023-11-06 08:59:54 +00:00
Sylvain Briat
a1ff6f9f45 1.4.0 2023-11-06 09:55:14 +01:00
Sylvain Briat
c09b1b9863 remove useless match id from matching data 2023-11-06 09:54:59 +01:00
Sylvain Briat
337c28370d Merge branch 'security' into 'main'
Add Gitlab Security checks

See merge request v3/service/matcher!17
2023-11-04 09:40:40 +00:00
Sylvain Briat
14c43afb34 try secret detection security --check 2023-11-04 10:31:05 +01:00
Sylvain Briat
73ed3a948e try sast security --check 2023-11-04 10:25:17 +01:00
Sylvain Briat
b0718e83df Merge branch 'fixMatchMapperUnitTest' into 'main'
fix bad matchMapper unit test

See merge request v3/service/matcher!16
2023-11-03 10:31:31 +00:00
Sylvain Briat
4547dcf655 fix bad matchMapper unit test 2023-11-03 11:25:02 +01:00
Sylvain Briat
c5e58db5a6 Merge branch 'fixJourneyVO' into 'main'
Fix journey vo

See merge request v3/service/matcher!15
2023-11-02 16:19:14 +00:00
Sylvain Briat
d4412a0cf1 1.3.1 2023-11-02 17:13:29 +01:00
Sylvain Briat
5ca5200f1a fix journey value object : useless driver neutral target for passenger start 2023-11-02 17:13:16 +01:00
Sylvain Briat
62e4015ea7 Merge branch 'removeConfigurationPackageV3' into 'main'
Update default configuration management

See merge request v3/service/matcher!14
2023-10-31 15:42:25 +00:00
Sylvain Briat
c3e03e179c fix repository signature in unit test 2023-10-31 16:39:07 +01:00
Sylvain Briat
07e44a259b fix repository signature in unit test 2023-10-31 16:34:00 +01:00
112 changed files with 4295 additions and 4437 deletions

View File

@@ -8,7 +8,7 @@ HEALTH_SERVICE_PORT=6005
DATABASE_URL="postgresql://mobicoop:mobicoop@v3-db:5432/mobicoop?schema=matcher" DATABASE_URL="postgresql://mobicoop:mobicoop@v3-db:5432/mobicoop?schema=matcher"
# MESSAGE BROKER # MESSAGE BROKER
MESSAGE_BROKER_URI=amqp://v3-broker:5672 MESSAGE_BROKER_URI=amqp://mobicoop:mobicoop@v3-broker:5672
MESSAGE_BROKER_EXCHANGE=mobicoop MESSAGE_BROKER_EXCHANGE=mobicoop
MESSAGE_BROKER_EXCHANGE_DURABILITY=true MESSAGE_BROKER_EXCHANGE_DURABILITY=true

View File

@@ -4,51 +4,10 @@ stages:
- test - test
- build - build
############## include:
# TEST STAGE # - template: Security/SAST.gitlab-ci.yml
############## - template: Security/Secret-Detection.gitlab-ci.yml
- project: mobicoop/v3/gitlab-templates
test: file:
stage: test - /ci/release.build-job.yml
image: docker/compose:latest - /ci/service.test-job.yml
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
###############
# BUILD STAGE #
###############
build:
stage: build
image: docker:20.10.22
variables:
DOCKER_TLS_CERTDIR: ''
services:
- docker:dind
before_script:
- echo -n $CI_REGISTRY_PASSWORD | docker login -u $CI_REGISTRY_USER --password-stdin $CI_REGISTRY
script:
- export VERSION=$(docker run --rm -v "$PWD":/usr/src/app:ro -w /usr/src/app node:slim node -p "require('./package.json').version")
- docker pull $CI_REGISTRY_IMAGE:latest || true
- >
docker build
--pull
--cache-from $CI_REGISTRY_IMAGE:latest
--tag $CI_REGISTRY_IMAGE:$VERSION
--tag $CI_REGISTRY_IMAGE:latest
.
- docker push $CI_REGISTRY_IMAGE:$VERSION
- docker push $CI_REGISTRY_IMAGE:latest
only:
- main

View File

@@ -0,0 +1,55 @@
_Replace italic text by your own description_
## Feature Merge Request
### Why this Merge Request
_This merge request addresses, and describe the problem or user story being addressed._
### What is implemented, what is the chosen solution
_Explain the fix or solution implemented. Which other solution have been envisaged._
### Related issues and impact on other project in codebase
_Provide links to the related issues, feature requests and merge request (from Gitlab and Redmine)._
_And Link to other project Impacted._
### Other Information
_Include any extra information or considerations for reviewers._
## Checklists
### Merge Request
- [ ] Target branch identified.
- [ ] Code based on last version of target branch.
- [ ] Description filled.
- [ ] Impact on other project codebase identified.
- [ ] Documentation reflects the changes made.
- [ ] Test run in gitlab pipeline and locally.
- [ ] One or more reviewer is defined
### Code Review
- [ ] Code follows project coding guidelines.
- [ ] Code follows project designed architecture.
- [ ] Code is easily readable.
- [ ] Everything new have an explicit and pertinent name (variable, method, file ...)
- [ ] No redundant/duplicate code (unless explain by architecture choice)
- [ ] Commit are all related to MR and well written (Atomic commit).
- [ ] New code is tested and covered by automated test.
- [ ] No useless logging or debugging code.
- [ ] No code can be replaced by library or framework code.
### TODO before merge
- [ ] _add any task here_
- [ ] ...
### TODO after merge
- [ ] _add any task here_
- [ ] ...

View File

@@ -0,0 +1,62 @@
_Replace italic text by your own description_
## Release Merge Request
### Why this Merge Request
_This merge request addresses, and describe the problem or user story being addressed._
### What is implemented, what is the chosen solution
_Explain the fix or solution implemented. Which other solution have been envisaged._
### Related issues and impact on other project in codebase
_Provide links to the related issues, feature requests and merge request (from Gitlab and Redmine)._
_And Link to other project Impacted._
### Other Information
_Include any extra information or considerations for reviewers._
## Checklists
### Merge Request
- [ ] Target branch identified.
- [ ] Code based on last version of target branch.
- [ ] Description filled.
- [ ] Impact on other project codebase identified.
- [ ] Documentation reflects the changes made.
- [ ] Test run in gitlab pipeline and locally.
- [ ] One or more reviewer is defined
### Code Review
- [ ] Code follows project coding guidelines.
- [ ] Code follows project designed architecture.
- [ ] Code is easily readable.
- [ ] Everything new have an explicit and pertinent name (variable, method, file ...)
- [ ] No redundant/duplicate code (unless explain by architecture choice)
- [ ] Commit are all related to MR and well written (Atomic commit).
- [ ] New code is tested and covered by automated test.
- [ ] No useless logging or debugging code.
- [ ] No code can be replaced by library or framework code.
### Change Management
- [ ] Release is planned
- [ ] Merge Request to be included are identified
- [ ] Concerned Team are aware of the change
- [ ] No other change on the same day (if possible)
### TODO before merge
- [ ] _add any task here_
- [ ] ...
### TODO after merge
- [ ] _add any task here_
- [ ] ...

View File

@@ -0,0 +1,37 @@
_Replace italic text by your own description_
## Small Fix Merge Request
### Why this Merge Request
_This merge request addresses, and describe the problem or user story being addressed._
### What is implemented, what is the chosen solution
_Explain the fix or solution implemented. Which other solution have been envisaged._
### Related issues and impact on other project in codebase
_Provide links to the related issues, feature requests and merge request (from Gitlab and Redmine)._
_And Link to other project Impacted._
### Other Information
_Include any extra information or considerations for reviewers._
## Checklists
### Merge Request
- [ ] Target branch identified.
- [ ] Code based on last version of target branch.
- [ ] Description filled.
- [ ] Impact on other project codebase identified.
- [ ] Test run in gitlab pipeline and locally.
### Code Review
- [ ] Code is easily readable.
- [ ] Commit are all related to MR and well written (Atomic commit).
- [ ] No useless logging or debugging code.

View File

@@ -4,3 +4,4 @@ node_modules
dist dist
coverage coverage
.prettierrc.json .prettierrc.json
.gitlab

View File

@@ -2,7 +2,7 @@
# BUILD FOR LOCAL DEVELOPMENT # BUILD FOR LOCAL DEVELOPMENT
################### ###################
FROM node:18-alpine3.16 As development FROM docker.io/node:lts-hydrogen As development
# Create app directory # Create app directory
WORKDIR /usr/src/app WORKDIR /usr/src/app
@@ -29,7 +29,7 @@ USER node
# BUILD FOR PRODUCTION # BUILD FOR PRODUCTION
################### ###################
FROM node:18-alpine3.16 As build FROM docker.io/node:lts-hydrogen As build
WORKDIR /usr/src/app WORKDIR /usr/src/app
@@ -63,7 +63,7 @@ USER node
# PRODUCTION # PRODUCTION
################### ###################
FROM node:18-alpine3.16 As production FROM docker.io/node:lts-hydrogen As production
# Copy package.json to be able to execute migration command # Copy package.json to be able to execute migration command
COPY --chown=node:node package*.json ./ COPY --chown=node:node package*.json ./

View File

@@ -156,13 +156,18 @@ The app exposes the following [gRPC](https://grpc.io/) services :
- **strict** (boolean, optional): if set to true, allow matching only with similar frequency ads (_default : false_) - **strict** (boolean, optional): if set to true, allow matching only with similar frequency ads (_default : false_)
- **fromDate**: start date for recurrent ad, carpool date for punctual ad - **fromDate**: start date for recurrent ad, carpool date for punctual ad
- **toDate**: end date for recurrent ad, same as fromDate for punctual ad - **toDate**: end date for recurrent ad, same as fromDate for punctual ad
- **schedule**: an array of schedule items, a schedule item containing : - **schedule**: an optional array of schedule items, a schedule item containing :
- the week day as a number, from 0 (sunday) to 6 (saturday) if the ad is recurrent (default to fromDate day for punctual search) - the week day as a number, from 0 (sunday) to 6 (saturday) if the ad is recurrent (default to fromDate day for punctual search)
- the departure time (as HH:MM) - the departure time (as HH:MM)
- the margin around the departure time in seconds (optional) (_default : 900_) - the margin around the departure time in seconds (optional) (_default : 900_)
_If the schedule is not set, the driver departure time is guessed to be the ideal departure time to reach the passenger, and the passenger departure time is guessed to be the ideal pick up time for the driver_
- **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
@@ -181,7 +186,6 @@ If the matching is successful, you will get a result, containing :
- **page**: the number of the page that is returned (may be different than the number of the required page, if number of results does not match with perPage parameter) - **page**: the number of the page that is returned (may be different than the number of the required page, if number of results does not match with perPage parameter)
- **perPage**: the number of results per page (as it may not be specified in the request) - **perPage**: the number of results per page (as it may not be specified in the request)
- **data**: an array of the results themselves, each including: - **data**: an array of the results themselves, each including:
- **id**: an id for the result
- **adId**: the id of the ad that matches - **adId**: the id of the ad that matches
- **role**: the role of the ad owner in that match - **role**: the role of the ad owner in that match
- **distance**: the distance in metres of the resulting carpool - **distance**: the distance in metres of the resulting carpool
@@ -215,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).

View File

@@ -6,7 +6,7 @@ SERVICE_PORT=5005
DATABASE_URL="postgresql://mobicoop:mobicoop@v3-db:5432/mobicoop?schema=public" DATABASE_URL="postgresql://mobicoop:mobicoop@v3-db:5432/mobicoop?schema=public"
# MESSAGE BROKER # MESSAGE BROKER
MESSAGE_BROKER_URI=amqp://v3-broker:5672 MESSAGE_BROKER_URI=amqp://mobicoop:mobicoop@v3-broker:5672
MESSAGE_BROKER_EXCHANGE=mobicoop MESSAGE_BROKER_EXCHANGE=mobicoop
# REDIS # REDIS
@@ -15,9 +15,9 @@ REDIS_PASSWORD=redis
REDIS_PORT=6379 REDIS_PORT=6379
# IMAGES # IMAGES
BROKER_IMAGE=rabbitmq:3-alpine BROKER_IMAGE=docker.io/rabbitmq:3-alpine
REDIS_IMAGE=redis:7.0-alpine REDIS_IMAGE=docker.io/redis:7.0-alpine
POSTGRES_IMAGE=postgis/postgis:15-3.3 POSTGRES_IMAGE=docker.io/postgis/postgis:15-3.3
# DEFAULT CONFIGURATION # DEFAULT CONFIGURATION
@@ -54,6 +54,3 @@ MAX_DETOUR_DURATION_RATIO=0.3
GEOROUTER_TYPE=graphhopper GEOROUTER_TYPE=graphhopper
# georouter url # georouter url
GEOROUTER_URL=http://localhost:8989 GEOROUTER_URL=http://localhost:8989

View File

@@ -2,7 +2,7 @@
# BUILD FOR CI TESTING # BUILD FOR CI TESTING
################### ###################
FROM node:18-alpine3.16 FROM docker.io/node:lts-hydrogen
# Create app directory # Create app directory
WORKDIR /usr/src/app WORKDIR /usr/src/app

View File

@@ -11,10 +11,11 @@ services:
- .:/usr/src/app - .:/usr/src/app
env_file: env_file:
- .env - .env
command: npm run start:dev command: npm run start:debug
ports: ports:
- ${SERVICE_PORT:-5005}:${SERVICE_PORT:-5005} - ${SERVICE_PORT:-5005}:${SERVICE_PORT:-5005}
- ${HEALTH_SERVICE_PORT:-6005}:${HEALTH_SERVICE_PORT:-6005} - ${HEALTH_SERVICE_PORT:-6005}:${HEALTH_SERVICE_PORT:-6005}
- 9225:9229
networks: networks:
v3-network: v3-network:
aliases: aliases:

4344
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{ {
"name": "@mobicoop/matcher", "name": "@mobicoop/matcher",
"version": "1.3.0", "version": "1.6.0",
"description": "Mobicoop V3 Matcher", "description": "Mobicoop V3 Matcher",
"author": "sbriat", "author": "sbriat",
"private": true, "private": true,
@@ -11,7 +11,7 @@
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start", "start": "nest start",
"start:dev": "nest start --watch", "start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch", "start:debug": "nest start --debug 0.0.0.0:9229 --watch",
"start:prod": "node dist/main", "start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"lint:check": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix-dry-run --ignore-path .gitignore", "lint:check": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix-dry-run --ignore-path .gitignore",
@@ -24,69 +24,69 @@
"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:cov": "jest --testPathPattern 'tests/unit/' --coverage",
"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 dev'", "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:test": "dotenv -e .env.test -- npx prisma migrate deploy", "migrate:test": "dotenv -e .env.test -- npx prisma migrate deploy",
"migrate:test:ci": "dotenv -e ci/.env.ci -- npx prisma migrate deploy", "migrate:test:ci": "dotenv -e ci/.env.ci -- npx prisma migrate deploy",
"migrate:deploy": "npx prisma migrate deploy" "migrate:deploy": "npx prisma migrate deploy"
}, },
"dependencies": { "dependencies": {
"@grpc/grpc-js": "^1.9.9", "@grpc/grpc-js": "^1.9.14",
"@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": "^7.2.1", "@mobicoop/configuration-module": "^8.0.0",
"@mobicoop/ddd-library": "^2.1.1", "@mobicoop/ddd-library": "^2.4.3",
"@mobicoop/health-module": "^2.3.1", "@mobicoop/health-module": "^2.3.2",
"@mobicoop/message-broker-module": "^2.1.1", "@mobicoop/message-broker-module": "^2.1.2",
"@nestjs/axios": "^3.0.1", "@nestjs/axios": "^3.0.1",
"@nestjs/cache-manager": "^2.1.0", "@nestjs/cache-manager": "^2.2.0",
"@nestjs/common": "^10.2.7", "@nestjs/common": "^10.3.0",
"@nestjs/config": "^3.1.1", "@nestjs/config": "^3.1.1",
"@nestjs/core": "^10.2.7", "@nestjs/core": "^10.3.0",
"@nestjs/cqrs": "^10.2.6", "@nestjs/cqrs": "^10.2.6",
"@nestjs/event-emitter": "^2.0.2", "@nestjs/event-emitter": "^2.0.3",
"@nestjs/microservices": "^10.2.7", "@nestjs/microservices": "^10.3.0",
"@nestjs/platform-express": "^10.2.7", "@nestjs/platform-express": "^10.3.0",
"@nestjs/terminus": "^10.1.1", "@nestjs/terminus": "^10.2.0",
"@prisma/client": "^5.5.2", "@prisma/client": "^5.8.1",
"axios": "^1.6.0", "axios": "^1.6.5",
"cache-manager": "^5.2.4", "cache-manager": "^5.3.2",
"cache-manager-ioredis-yet": "^1.2.2", "cache-manager-ioredis-yet": "^1.2.2",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.14.0", "class-validator": "^0.14.1",
"geo-tz": "^7.0.7", "geo-tz": "^8.0.0",
"geographiclib-geodesic": "^2.0.0", "geographiclib-geodesic": "^2.0.0",
"got": "^13.0.0", "got": "^14.0.0",
"ioredis": "^5.3.2", "ioredis": "^5.3.2",
"nestjs-request-context": "^3.0.0", "nestjs-request-context": "^3.0.0",
"reflect-metadata": "^0.1.13",
"rxjs": "^7.8.1", "rxjs": "^7.8.1",
"timezonecomplete": "^5.12.4" "timezonecomplete": "^5.12.4"
}, },
"devDependencies": { "devDependencies": {
"@nestjs/cli": "^10.2.1", "@nestjs/cli": "^10.3.0",
"@nestjs/schematics": "^10.0.3", "@nestjs/schematics": "^10.1.0",
"@nestjs/testing": "^10.2.7", "@nestjs/testing": "^10.3.0",
"@types/express": "^4.17.20", "@types/express": "^4.17.21",
"@types/jest": "29.5.7", "@types/jest": "29.5.11",
"@types/node": "20.8.10", "@types/node": "20.11.5",
"@types/supertest": "^2.0.15", "@types/supertest": "^6.0.2",
"@types/uuid": "^9.0.6", "@types/uuid": "^9.0.7",
"@typescript-eslint/eslint-plugin": "^6.9.1", "@typescript-eslint/eslint-plugin": "^6.19.0",
"@typescript-eslint/parser": "^6.9.1", "@typescript-eslint/parser": "^6.19.0",
"dotenv-cli": "^7.3.0", "dotenv-cli": "^7.3.0",
"eslint": "^8.52.0", "eslint": "^8.56.0",
"eslint-config-prettier": "^9.0.0", "eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.0.1", "eslint-plugin-prettier": "^5.1.3",
"jest": "29.7.0", "jest": "29.7.0",
"prettier": "^3.0.3", "prettier": "^3.2.3",
"prisma": "^5.5.2", "prisma": "^5.8.1",
"source-map-support": "^0.5.21", "source-map-support": "^0.5.21",
"supertest": "^6.3.3", "supertest": "^6.3.4",
"ts-jest": "29.1.1", "ts-jest": "29.1.1",
"ts-loader": "^9.5.0", "ts-loader": "^9.5.1",
"ts-node": "^10.9.1", "ts-node": "^10.9.2",
"tsconfig-paths": "4.2.0", "tsconfig-paths": "4.2.0",
"typescript": "^5.2.2" "typescript": "^5.3.3"
}, },
"jest": { "jest": {
"moduleFileExtensions": [ "moduleFileExtensions": [

View File

@@ -3,24 +3,21 @@ export const SERVICE_NAME = 'matcher';
// grpc // grpc
export const GRPC_PACKAGE_NAME = 'matcher'; export const GRPC_PACKAGE_NAME = 'matcher';
export const GRPC_GEOGRAPHY_PACKAGE_NAME = 'geography';
export const GRPC_GEOROUTER_SERVICE_NAME = 'GeorouterService';
// messaging // messaging output
export const MATCHER_AD_CREATED_ROUTING_KEY = 'matcher-ad.created';
export const MATCHER_AD_CREATION_FAILED_ROUTING_KEY =
'matcher-ad.creation-failed';
// 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_MESSAGE_HANDLER = 'adDeleted';
export const AD_DELETED_ROUTING_KEY = 'ad.deleted'; export const AD_DELETED_ROUTING_KEY = 'ad.deleted';
export const AD_DELETED_QUEUE = 'matcher-ad-deleted'; export const AD_DELETED_QUEUE = 'matcher.ad.deleted';
// configuration
export const SERVICE_CONFIGURATION_SET_QUEUE = 'matcher-configuration-set';
export const SERVICE_CONFIGURATION_DELETE_QUEUE =
'matcher-configuration-delete';
export const SERVICE_CONFIGURATION_PROPAGATE_QUEUE =
'matcher-configuration-propagate';
// health // health
export const GRPC_HEALTH_PACKAGE_NAME = 'health'; export const GRPC_HEALTH_PACKAGE_NAME = 'health';

View File

@@ -10,6 +10,7 @@ import { MESSAGE_PUBLISHER } from '@modules/messager/messager.di-tokens';
import { HealthModuleOptions } from '@mobicoop/health-module/dist/core/domain/types/health.types'; import { HealthModuleOptions } from '@mobicoop/health-module/dist/core/domain/types/health.types';
import { MessagePublisherPort } from '@mobicoop/ddd-library'; import { MessagePublisherPort } from '@mobicoop/ddd-library';
import { GeographyModule } from '@modules/geography/geography.module'; import { GeographyModule } from '@modules/geography/geography.module';
import producerServicesConfig from './config/producer-services.config';
import { import {
HEALTH_AD_REPOSITORY, HEALTH_AD_REPOSITORY,
HEALTH_CRITICAL_LOGGING_KEY, HEALTH_CRITICAL_LOGGING_KEY,
@@ -18,7 +19,7 @@ import {
@Module({ @Module({
imports: [ imports: [
ConfigModule.forRoot({ isGlobal: true }), ConfigModule.forRoot({ isGlobal: true, load: [producerServicesConfig] }),
EventEmitterModule.forRoot(), EventEmitterModule.forRoot(),
RequestContextModule, RequestContextModule,
HealthModule.forRootAsync({ HealthModule.forRootAsync({

View File

@@ -0,0 +1,8 @@
import { registerAs } from '@nestjs/config';
export default registerAs('producerServices', () => ({
geographyUrl: process.env.GEOGRAPHY_SERVICE_URL ?? 'v3-geography-api',
geographyPort: process.env.GEOGRAPHY_SERVICE_PORT
? parseInt(process.env.GEOGRAPHY_SERVICE_PORT, 10)
: 5007,
}));

View File

@@ -0,0 +1,24 @@
import { ArgumentsHost, Catch, Logger } from '@nestjs/common';
import { BaseRpcExceptionFilter } from '@nestjs/microservices';
import { Observable } from 'rxjs';
@Catch()
export class LogCauseExceptionFilter extends BaseRpcExceptionFilter {
private static readonly causeLogger = new Logger('RpcExceptionsHandler');
catch(exception: any, host: ArgumentsHost): Observable<any> {
const response = super.catch(exception, host);
const cause = exception.cause;
if (cause) {
if (this.isError(cause)) {
LogCauseExceptionFilter.causeLogger.error(
'Caused by: ' + cause.message,
cause.stack,
);
} else {
LogCauseExceptionFilter.causeLogger.error('Caused by: ' + cause);
}
}
return response;
}
}

View File

@@ -1,7 +1,4 @@
import { import { KeyType, Type } from '@mobicoop/configuration-module';
ConfigurationDomainGet,
ConfigurationType,
} from '@mobicoop/configuration-module';
export const CARPOOL_CONFIG_ROLE = 'role'; export const CARPOOL_CONFIG_ROLE = 'role';
export const CARPOOL_CONFIG_SEATS_PROPOSED = 'seatsProposed'; export const CARPOOL_CONFIG_SEATS_PROPOSED = 'seatsProposed';
@@ -9,25 +6,25 @@ export const CARPOOL_CONFIG_SEATS_REQUESTED = 'seatsRequested';
export const CARPOOL_CONFIG_DEPARTURE_TIME_MARGIN = 'departureTimeMargin'; export const CARPOOL_CONFIG_DEPARTURE_TIME_MARGIN = 'departureTimeMargin';
export const CARPOOL_CONFIG_STRICT_FREQUENCY = 'strictFrequency'; export const CARPOOL_CONFIG_STRICT_FREQUENCY = 'strictFrequency';
export const CarpoolConfig: ConfigurationDomainGet[] = [ export const CarpoolKeyTypes: KeyType[] = [
{ {
key: CARPOOL_CONFIG_ROLE, key: CARPOOL_CONFIG_ROLE,
type: ConfigurationType.STRING, type: Type.STRING,
}, },
{ {
key: CARPOOL_CONFIG_SEATS_PROPOSED, key: CARPOOL_CONFIG_SEATS_PROPOSED,
type: ConfigurationType.INT, type: Type.INT,
}, },
{ {
key: CARPOOL_CONFIG_SEATS_REQUESTED, key: CARPOOL_CONFIG_SEATS_REQUESTED,
type: ConfigurationType.INT, type: Type.INT,
}, },
{ {
key: CARPOOL_CONFIG_DEPARTURE_TIME_MARGIN, key: CARPOOL_CONFIG_DEPARTURE_TIME_MARGIN,
type: ConfigurationType.INT, type: Type.INT,
}, },
{ {
key: CARPOOL_CONFIG_STRICT_FREQUENCY, key: CARPOOL_CONFIG_STRICT_FREQUENCY,
type: ConfigurationType.BOOLEAN, type: Type.BOOLEAN,
}, },
]; ];

View File

@@ -18,3 +18,4 @@ export const OUTPUT_DATETIME_TRANSFORMER = Symbol(
export const AD_CONFIGURATION_REPOSITORY = Symbol( export const AD_CONFIGURATION_REPOSITORY = Symbol(
'AD_CONFIGURATION_REPOSITORY', 'AD_CONFIGURATION_REPOSITORY',
); );
export const GEOGRAPHY_PACKAGE = Symbol('GEOGRAPHY_PACKAGE');

View File

@@ -1,19 +1,19 @@
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,
} from './infrastructure/ad.repository';
/** /**
* Mapper constructs objects that are used in different layers: * Mapper constructs objects that are used in different layers:
@@ -97,7 +97,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 +111,14 @@ export class AdMapper
margin: scheduleItem.margin, margin: scheduleItem.margin,
}), }),
), ),
waypoints: this.directionEncoder waypoints: record.waypoints
.decode(record.waypoints) ? this.directionEncoder
.map((coordinates, index) => ({ .decode(record.waypoints)
position: index, .map((coordinates, index) => ({
...coordinates, position: index,
})), ...coordinates,
}))
: [],
fwdAzimuth: record.fwdAzimuth, fwdAzimuth: record.fwdAzimuth,
backAzimuth: record.backAzimuth, backAzimuth: record.backAzimuth,
points: [], points: [],

View File

@@ -1,51 +1,71 @@
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,
AD_GET_BASIC_ROUTE_CONTROLLER,
TIMEZONE_FINDER,
TIME_CONVERTER,
INPUT_DATETIME_TRANSFORMER,
AD_GET_DETAILED_ROUTE_CONTROLLER,
OUTPUT_DATETIME_TRANSFORMER,
MATCHING_REPOSITORY,
AD_CONFIGURATION_REPOSITORY,
} 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 { GetBasicRouteController } from '@modules/geography/interface/controllers/get-basic-route.controller';
import { RouteProvider } from './infrastructure/route-provider';
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 { GetDetailedRouteController } from '@modules/geography/interface/controllers/get-detailed-route.controller';
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 { GRPC_GEOGRAPHY_PACKAGE_NAME } from '@src/app.constants';
import { redisStore } from 'cache-manager-ioredis-yet';
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 { 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 { MatchMapper } from './match.mapper';
import { MatchingMapper } from './matching.mapper';
const imports = [ const imports = [
CqrsModule, CqrsModule,
ClientsModule.registerAsync([
{
name: GEOGRAPHY_PACKAGE,
imports: [ConfigModule],
inject: [ConfigService],
useFactory: async (configService: ConfigService) => ({
transport: Transport.GRPC,
options: {
package: GRPC_GEOGRAPHY_PACKAGE_NAME,
protoPath: join(__dirname, '/infrastructure/georouter.proto'),
url: `${configService.get<string>(
'producerServices.geographyUrl',
)}:${configService.get<string>('producerServices.geographyPort')}`,
},
}),
},
]),
CacheModule.registerAsync<RedisClientOptions>({ CacheModule.registerAsync<RedisClientOptions>({
imports: [ConfigModule], imports: [ConfigModule],
useFactory: async (configService: ConfigService) => ({ useFactory: async (configService: ConfigService) => ({
@@ -78,9 +98,13 @@ const imports = [
const grpcControllers = [MatchGrpcController]; const grpcControllers = [MatchGrpcController];
const messageHandlers = [AdCreatedMessageHandler]; const messageHandlers = [AdCreatedMessageHandler, AdDeletedMessageHandler];
const commandHandlers: Provider[] = [CreateAdService]; const eventHandlers: Provider[] = [
PublishMessageWhenMatcherAdIsCreatedDomainEventHandler,
];
const commandHandlers: Provider[] = [CreateAdService, DeleteAdService];
const queryHandlers: Provider[] = [MatchQueryHandler]; const queryHandlers: Provider[] = [MatchQueryHandler];
@@ -117,15 +141,7 @@ const adapters: Provider[] = [
}, },
{ {
provide: AD_ROUTE_PROVIDER, provide: AD_ROUTE_PROVIDER,
useClass: RouteProvider, useClass: Georouter,
},
{
provide: AD_GET_BASIC_ROUTE_CONTROLLER,
useClass: GetBasicRouteController,
},
{
provide: AD_GET_DETAILED_ROUTE_CONTROLLER,
useClass: GetDetailedRouteController,
}, },
{ {
provide: TIMEZONE_FINDER, provide: TIMEZONE_FINDER,
@@ -150,6 +166,7 @@ const adapters: Provider[] = [
controllers: [...grpcControllers], controllers: [...grpcControllers],
providers: [ providers: [
...messageHandlers, ...messageHandlers,
...eventHandlers,
...commandHandlers, ...commandHandlers,
...queryHandlers, ...queryHandlers,
...mappers, ...mappers,

View File

@@ -1,12 +1,19 @@
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import { CreateAdCommand } from './create-ad.command'; import { CreateAdCommand } from './create-ad.command';
import { Inject } from '@nestjs/common'; import { Inject } from '@nestjs/common';
import { AD_REPOSITORY, AD_ROUTE_PROVIDER } from '@modules/ad/ad.di-tokens'; 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 { AdEntity } from '@modules/ad/core/domain/ad.entity';
import { AdRepositoryPort } from '../../ports/ad.repository.port'; import { AdRepositoryPort } from '../../ports/ad.repository.port';
import { AggregateID, ConflictException } from '@mobicoop/ddd-library'; import {
AggregateID,
ConflictException,
MessagePublisherPort,
} from '@mobicoop/ddd-library';
import { AdAlreadyExistsException } from '@modules/ad/core/domain/ad.errors'; import { AdAlreadyExistsException } from '@modules/ad/core/domain/ad.errors';
import { RouteProviderPort } from '../../ports/route-provider.port';
import { Role } from '@modules/ad/core/domain/ad.types'; import { Role } from '@modules/ad/core/domain/ad.types';
import { import {
Path, Path,
@@ -17,14 +24,19 @@ import {
import { Waypoint } from '../../types/waypoint.type'; import { Waypoint } from '../../types/waypoint.type';
import { Point as PointValueObject } from '@modules/ad/core/domain/value-objects/point.value-object'; import { Point as PointValueObject } from '@modules/ad/core/domain/value-objects/point.value-object';
import { Point } from '@modules/geography/core/domain/route.types'; import { Point } from '@modules/geography/core/domain/route.types';
import { MatcherAdCreationFailedIntegrationEvent } from '../../events/matcher-ad-creation-failed.integration-event';
import { MATCHER_AD_CREATION_FAILED_ROUTING_KEY } from '@src/app.constants';
import { GeorouterPort } from '../../ports/georouter.port';
@CommandHandler(CreateAdCommand) @CommandHandler(CreateAdCommand)
export class CreateAdService implements ICommandHandler { export class CreateAdService implements ICommandHandler {
constructor( constructor(
@Inject(AD_MESSAGE_PUBLISHER)
private readonly messagePublisher: MessagePublisherPort,
@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: RouteProviderPort, private readonly routeProvider: GeorouterPort,
) {} ) {}
async execute(command: CreateAdCommand): Promise<AggregateID> { async execute(command: CreateAdCommand): Promise<AggregateID> {
@@ -44,17 +56,6 @@ export class CreateAdService implements ICommandHandler {
); );
let typedRoutes: TypedRoute[]; let typedRoutes: TypedRoute[];
try {
typedRoutes = await Promise.all(
pathCreator.getBasePaths().map(async (path: Path) => ({
type: path.type,
route: await this.routeProvider.getBasic(path.waypoints),
})),
);
} catch (e: any) {
throw new Error('Unable to find a route for given waypoints');
}
let driverDistance: number | undefined; let driverDistance: number | undefined;
let driverDuration: number | undefined; let driverDuration: number | undefined;
let passengerDistance: number | undefined; let passengerDistance: number | undefined;
@@ -62,25 +63,26 @@ export class CreateAdService implements ICommandHandler {
let points: PointValueObject[] | undefined; let points: PointValueObject[] | undefined;
let fwdAzimuth: number | undefined; let fwdAzimuth: number | undefined;
let backAzimuth: number | undefined; let backAzimuth: number | undefined;
try { try {
typedRoutes.forEach((typedRoute: TypedRoute) => { try {
if ([PathType.DRIVER, PathType.GENERIC].includes(typedRoute.type)) { typedRoutes = await Promise.all(
driverDistance = typedRoute.route.distance; pathCreator.getBasePaths().map(async (path: Path) => ({
driverDuration = typedRoute.route.duration; type: path.type,
points = typedRoute.route.points.map( route: await this.routeProvider.getRoute({
(point: Point) => waypoints: path.waypoints,
new PointValueObject({ }),
lon: point.lon, })),
lat: point.lat, );
}), } catch (e: any) {
); throw new Error('Unable to find a route for given waypoints');
fwdAzimuth = typedRoute.route.fwdAzimuth; }
backAzimuth = typedRoute.route.backAzimuth;
} try {
if ([PathType.PASSENGER, PathType.GENERIC].includes(typedRoute.type)) { typedRoutes.forEach((typedRoute: TypedRoute) => {
passengerDistance = typedRoute.route.distance; if ([PathType.DRIVER, PathType.GENERIC].includes(typedRoute.type)) {
passengerDuration = typedRoute.route.duration; driverDistance = typedRoute.route.distance;
if (!points) driverDuration = typedRoute.route.duration;
points = typedRoute.route.points.map( points = typedRoute.route.points.map(
(point: Point) => (point: Point) =>
new PointValueObject({ new PointValueObject({
@@ -88,40 +90,74 @@ export class CreateAdService implements ICommandHandler {
lat: point.lat, lat: point.lat,
}), }),
); );
if (!fwdAzimuth) fwdAzimuth = typedRoute.route.fwdAzimuth; fwdAzimuth = typedRoute.route.fwdAzimuth;
if (!backAzimuth) backAzimuth = typedRoute.route.backAzimuth; backAzimuth = typedRoute.route.backAzimuth;
} }
}); if (
} catch (error: any) { [PathType.PASSENGER, PathType.GENERIC].includes(typedRoute.type)
throw new Error('Invalid route'); ) {
} passengerDistance = typedRoute.route.distance;
const ad = AdEntity.create({ passengerDuration = typedRoute.route.duration;
id: command.id, if (!points)
driver: command.driver, points = typedRoute.route.points.map(
passenger: command.passenger, (point: Point) =>
frequency: command.frequency, new PointValueObject({
fromDate: command.fromDate, lon: point.lon,
toDate: command.toDate, lat: point.lat,
schedule: command.schedule, }),
seatsProposed: command.seatsProposed, );
seatsRequested: command.seatsRequested, if (!fwdAzimuth) fwdAzimuth = typedRoute.route.fwdAzimuth;
strict: command.strict, if (!backAzimuth) backAzimuth = typedRoute.route.backAzimuth;
waypoints: command.waypoints, }
points: points as PointValueObject[], });
driverDistance, } catch (error: any) {
driverDuration, throw new Error('Invalid route');
passengerDistance,
passengerDuration,
fwdAzimuth: fwdAzimuth as number,
backAzimuth: backAzimuth as number,
});
try {
await this.repository.insertExtra(ad, 'ad');
return ad.id;
} catch (error: any) {
if (error instanceof ConflictException) {
throw new AdAlreadyExistsException(error);
} }
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 {
await this.repository.insertExtra(ad, 'ad');
return ad.id;
} catch (error: any) {
if (error instanceof ConflictException) {
throw new AdAlreadyExistsException(error);
}
throw error;
}
} catch (error: any) {
const matcherAdCreationFailedIntegrationEvent =
new MatcherAdCreationFailedIntegrationEvent({
id: command.id,
metadata: {
correlationId: command.id,
timestamp: Date.now(),
},
cause: error.message,
});
this.messagePublisher.publish(
MATCHER_AD_CREATION_FAILED_ROUTING_KEY,
JSON.stringify(matcherAdCreationFailedIntegrationEvent),
);
throw error; throw error;
} }
} }

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,34 @@
import { Inject, Injectable } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { MatcherAdCreatedDomainEvent } from '../../domain/events/matcher-ad-created.domain-event';
import { MessagePublisherPort } from '@mobicoop/ddd-library';
import { AD_MESSAGE_PUBLISHER } from '@modules/ad/ad.di-tokens';
import { MATCHER_AD_CREATED_ROUTING_KEY } from '@src/app.constants';
import { MatcherAdCreatedIntegrationEvent } from '../events/matcher-ad-created.integration-event';
@Injectable()
export class PublishMessageWhenMatcherAdIsCreatedDomainEventHandler {
constructor(
@Inject(AD_MESSAGE_PUBLISHER)
private readonly messagePublisher: MessagePublisherPort,
) {}
@OnEvent(MatcherAdCreatedDomainEvent.name, { async: true, promisify: true })
async handle(event: MatcherAdCreatedDomainEvent): Promise<any> {
const matcherAdCreatedIntegrationEvent =
new MatcherAdCreatedIntegrationEvent({
id: event.aggregateId,
driverDuration: event.driverDuration,
driverDistance: event.driverDistance,
passengerDuration: event.passengerDuration,
passengerDistance: event.passengerDistance,
fwdAzimuth: event.fwdAzimuth,
backAzimuth: event.backAzimuth,
metadata: event.metadata,
});
this.messagePublisher.publish(
MATCHER_AD_CREATED_ROUTING_KEY,
JSON.stringify(matcherAdCreatedIntegrationEvent),
);
}
}

View File

@@ -0,0 +1,20 @@
import { IntegrationEvent, IntegrationEventProps } from '@mobicoop/ddd-library';
export class MatcherAdCreatedIntegrationEvent extends IntegrationEvent {
readonly driverDuration?: number;
readonly driverDistance?: number;
readonly passengerDuration?: number;
readonly passengerDistance?: number;
readonly fwdAzimuth: number;
readonly backAzimuth: number;
constructor(props: IntegrationEventProps<MatcherAdCreatedIntegrationEvent>) {
super(props);
this.driverDuration = props.driverDuration;
this.driverDistance = props.driverDistance;
this.passengerDuration = props.passengerDuration;
this.passengerDistance = props.passengerDistance;
this.fwdAzimuth = props.fwdAzimuth;
this.backAzimuth = props.backAzimuth;
}
}

View File

@@ -0,0 +1,12 @@
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,31 @@
import { Injectable } from '@nestjs/common';
export type Point = {
lon: number;
lat: number;
};
export type Step = Point & {
duration: number;
distance?: number;
};
export type RouteRequest = {
waypoints: Point[];
detailsSettings?: { points: boolean; steps: boolean };
};
export type RouteResponse = {
distance: number;
duration: number;
fwdAzimuth: number;
backAzimuth: number;
distanceAzimuth: number;
points: Point[];
steps?: Step[];
};
@Injectable()
export abstract class GeorouterPort {
abstract getRoute(request: RouteRequest): Promise<RouteResponse>;
}

View File

@@ -1,19 +0,0 @@
import { Point } from '../types/point.type';
import { Route } from '../types/route.type';
export interface RouteProviderPort {
/**
* Get a basic route :
* - simple points (coordinates only)
* - overall duration
* - overall distance
*/
getBasic(waypoints: Point[]): Promise<Route>;
/**
* Get a detailed route :
* - detailed points (coordinates and time / distance to reach the point)
* - overall duration
* - overall distance
*/
getDetailed(waypoints: Point[]): Promise<Route>;
}

View File

@@ -21,7 +21,6 @@ export abstract class Algorithm {
for (const processor of this.processors) { for (const processor of this.processors) {
this.candidates = await processor.execute(this.candidates); this.candidates = await processor.execute(this.candidates);
} }
// console.log(JSON.stringify(this.candidates, null, 2));
return this.candidates.map((candidate: CandidateEntity) => return this.candidates.map((candidate: CandidateEntity) =>
MatchEntity.create({ MatchEntity.create({
adId: candidate.id, adId: candidate.id,

View File

@@ -33,7 +33,7 @@ export class PassengerOrientedCarpoolPathCompleter extends Completer {
}), }),
), ),
candidate.getProps().role == Role.PASSENGER candidate.getProps().role == Role.PASSENGER
? candidate.getProps().driverWaypoints.map( ? candidate.getProps().passengerWaypoints.map(
(waypoint: PointProps) => (waypoint: PointProps) =>
new Point({ new Point({
lon: waypoint.lon, lon: waypoint.lon,

View File

@@ -3,6 +3,7 @@ import { Completer } from './completer.abstract';
import { MatchQuery } from '../match.query'; import { MatchQuery } from '../match.query';
import { Step } from '../../../types/step.type'; 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';
export class RouteCompleter extends Completer { export class RouteCompleter extends Completer {
protected readonly type: RouteCompleterType; protected readonly type: RouteCompleterType;
@@ -18,23 +19,20 @@ export class RouteCompleter extends Completer {
candidates.map(async (candidate: CandidateEntity) => { candidates.map(async (candidate: CandidateEntity) => {
switch (this.type) { switch (this.type) {
case RouteCompleterType.BASIC: case RouteCompleterType.BASIC:
const basicCandidateRoute = await this.query.routeProvider.getBasic( const basicCandidateRoute = await this._getRoute(candidate, {
(candidate.getProps().carpoolPath as CarpoolPathItem[]).map( points: true,
(carpoolPathItem: CarpoolPathItem) => carpoolPathItem, steps: false,
), });
);
candidate.setMetrics( candidate.setMetrics(
basicCandidateRoute.distance, basicCandidateRoute.distance,
basicCandidateRoute.duration, basicCandidateRoute.duration,
); );
break; break;
case RouteCompleterType.DETAILED: case RouteCompleterType.DETAILED:
const detailedCandidateRoute = const detailedCandidateRoute = await this._getRoute(candidate, {
await this.query.routeProvider.getDetailed( points: true,
(candidate.getProps().carpoolPath as CarpoolPathItem[]).map( steps: true,
(carpoolPathItem: CarpoolPathItem) => carpoolPathItem, });
),
);
candidate.setSteps(detailedCandidateRoute.steps as Step[]); candidate.setSteps(detailedCandidateRoute.steps as Step[]);
break; break;
} }
@@ -43,6 +41,17 @@ export class RouteCompleter extends Completer {
); );
return candidates; return candidates;
}; };
_getRoute = async (
candidate: CandidateEntity,
detailsSettings: { points: boolean; steps: boolean },
): Promise<RouteResponse> =>
this.query.routeProvider.getRoute({
waypoints: (candidate.getProps().carpoolPath as CarpoolPathItem[]).map(
(carpoolPathItem: CarpoolPathItem) => carpoolPathItem,
),
detailsSettings: detailsSettings,
});
} }
export enum RouteCompleterType { export enum RouteCompleterType {

View File

@@ -17,7 +17,7 @@ import { Paginator } from '@mobicoop/ddd-library';
import { MatchingEntity } from '@modules/ad/core/domain/matching.entity'; import { MatchingEntity } from '@modules/ad/core/domain/matching.entity';
import { MatchingRepositoryPort } from '../../ports/matching.repository.port'; import { MatchingRepositoryPort } from '../../ports/matching.repository.port';
import { import {
ConfigurationDomain, Domain,
Configurator, Configurator,
GetConfigurationRepositoryPort, GetConfigurationRepositoryPort,
} from '@mobicoop/configuration-module'; } from '@mobicoop/configuration-module';
@@ -27,7 +27,7 @@ import {
CARPOOL_CONFIG_SEATS_PROPOSED, CARPOOL_CONFIG_SEATS_PROPOSED,
CARPOOL_CONFIG_SEATS_REQUESTED, CARPOOL_CONFIG_SEATS_REQUESTED,
CARPOOL_CONFIG_STRICT_FREQUENCY, CARPOOL_CONFIG_STRICT_FREQUENCY,
CarpoolConfig, CarpoolKeyTypes,
} from '@modules/ad/ad.constants'; } from '@modules/ad/ad.constants';
import { import {
MATCH_CONFIG_ALGORITHM, MATCH_CONFIG_ALGORITHM,
@@ -57,18 +57,12 @@ export class MatchQueryHandler implements IQueryHandler {
execute = async (query: MatchQuery): Promise<MatchingResult> => { execute = async (query: MatchQuery): Promise<MatchingResult> => {
const carpoolConfigurator: Configurator = const carpoolConfigurator: Configurator =
await this.configurationRepository.mget( await this.configurationRepository.mget(Domain.CARPOOL, CarpoolKeyTypes);
ConfigurationDomain.CARPOOL,
CarpoolConfig,
);
const matchConfigurator: Configurator = const matchConfigurator: Configurator =
await this.configurationRepository.mget( await this.configurationRepository.mget(Domain.MATCH, MatchConfig);
ConfigurationDomain.MATCH,
MatchConfig,
);
const paginationConfigurator: Configurator = const paginationConfigurator: Configurator =
await this.configurationRepository.mget( await this.configurationRepository.mget(
ConfigurationDomain.PAGINATION, Domain.PAGINATION,
PaginationConfig, PaginationConfig,
); );
query query
@@ -171,7 +165,7 @@ export class MatchQueryHandler implements IQueryHandler {
frequency: query.frequency, frequency: query.frequency,
fromDate: query.fromDate, fromDate: query.fromDate,
toDate: query.toDate, toDate: query.toDate,
schedule: query.schedule.map((scheduleItem: ScheduleItem) => ({ schedule: query.schedule?.map((scheduleItem: ScheduleItem) => ({
day: scheduleItem.day as number, day: scheduleItem.day as number,
time: scheduleItem.time, time: scheduleItem.time,
margin: scheduleItem.margin as number, margin: scheduleItem.margin as number,

View File

@@ -1,10 +1,5 @@
import { QueryBase } from '@mobicoop/ddd-library'; import { QueryBase } from '@mobicoop/ddd-library';
import { AlgorithmType } from '../../types/algorithm.types';
import { Waypoint } from '../../types/waypoint.type';
import { Frequency, Role } from '@modules/ad/core/domain/ad.types'; import { Frequency, Role } from '@modules/ad/core/domain/ad.types';
import { MatchRequestDto } from '@modules/ad/interface/grpc-controllers/dtos/match.request.dto';
import { DateTimeTransformerPort } from '../../ports/datetime-transformer.port';
import { RouteProviderPort } from '../../ports/route-provider.port';
import { import {
Path, Path,
PathCreator, PathCreator,
@@ -12,7 +7,12 @@ import {
TypedRoute, TypedRoute,
} 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 { DateTimeTransformerPort } from '../../ports/datetime-transformer.port';
import { GeorouterPort } from '../../ports/georouter.port';
import { AlgorithmType } from '../../types/algorithm.types';
import { Route } from '../../types/route.type'; import { Route } from '../../types/route.type';
import { Waypoint } from '../../types/waypoint.type';
export class MatchQuery extends QueryBase { export class MatchQuery extends QueryBase {
id?: string; id?: string;
@@ -21,11 +21,12 @@ export class MatchQuery extends QueryBase {
readonly frequency: Frequency; readonly frequency: Frequency;
fromDate: string; fromDate: string;
toDate: string; toDate: string;
schedule: ScheduleItem[]; schedule?: ScheduleItem[];
seatsProposed?: number; seatsProposed?: number;
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,9 +41,10 @@ export class MatchQuery extends QueryBase {
passengerRoute?: Route; passengerRoute?: Route;
backAzimuth?: number; backAzimuth?: number;
private readonly originWaypoint: Waypoint; private readonly originWaypoint: Waypoint;
routeProvider: RouteProviderPort; routeProvider: GeorouterPort;
constructor(props: MatchRequestDto, routeProvider: RouteProviderPort) { // TODO: remove MatchRequestDto depency (here core domain depends on interface /!\)
constructor(props: MatchRequestDto, routeProvider: GeorouterPort) {
super(); super();
this.id = props.id; this.id = props.id;
this.driver = props.driver; this.driver = props.driver;
@@ -55,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;
@@ -72,7 +75,7 @@ export class MatchQuery extends QueryBase {
} }
setMissingMarginDurations = (defaultMarginDuration: number): MatchQuery => { setMissingMarginDurations = (defaultMarginDuration: number): MatchQuery => {
this.schedule.forEach((day: ScheduleItem) => { this.schedule?.forEach((day: ScheduleItem) => {
if (day.margin === undefined) day.margin = defaultMarginDuration; if (day.margin === undefined) day.margin = defaultMarginDuration;
}); });
return this; return this;
@@ -135,6 +138,8 @@ export class MatchQuery extends QueryBase {
setDatesAndSchedule = ( setDatesAndSchedule = (
datetimeTransformer: DateTimeTransformerPort, datetimeTransformer: DateTimeTransformerPort,
): MatchQuery => { ): MatchQuery => {
// no transformation if schedule is not set
if (this.schedule === undefined) return this;
const initialFromDate: string = this.fromDate; const initialFromDate: string = this.fromDate;
this.fromDate = datetimeTransformer.fromDate( this.fromDate = datetimeTransformer.fromDate(
{ {
@@ -207,7 +212,9 @@ export class MatchQuery extends QueryBase {
await Promise.all( await Promise.all(
pathCreator.getBasePaths().map(async (path: Path) => ({ pathCreator.getBasePaths().map(async (path: Path) => ({
type: path.type, type: path.type,
route: await this.routeProvider.getBasic(path.waypoints), route: await this.routeProvider.getRoute({
waypoints: path.waypoints,
}),
})), })),
) )
).forEach((typedRoute: TypedRoute) => { ).forEach((typedRoute: TypedRoute) => {
@@ -222,7 +229,9 @@ export class MatchQuery extends QueryBase {
} }
}); });
} catch (e: any) { } catch (e: any) {
throw new Error('Unable to find a route for given waypoints'); throw new Error('Unable to find a route for given waypoints', {
cause: e,
});
} }
return this; return this;
}; };

View File

@@ -19,7 +19,6 @@ 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>>(
@@ -74,7 +73,7 @@ export class PassengerOrientedSelector extends Selector {
driverSchedule: driverSchedule:
adsRole.role == Role.PASSENGER adsRole.role == Role.PASSENGER
? adEntity.getProps().schedule ? adEntity.getProps().schedule
: this.query.schedule.map((scheduleItem: ScheduleItem) => ({ : this.query.schedule?.map((scheduleItem: ScheduleItem) => ({
day: scheduleItem.day as number, day: scheduleItem.day as number,
time: scheduleItem.time, time: scheduleItem.time,
margin: scheduleItem.margin as number, margin: scheduleItem.margin as number,
@@ -82,7 +81,7 @@ export class PassengerOrientedSelector extends Selector {
passengerSchedule: passengerSchedule:
adsRole.role == Role.DRIVER adsRole.role == Role.DRIVER
? adEntity.getProps().schedule ? adEntity.getProps().schedule
: this.query.schedule.map((scheduleItem: ScheduleItem) => ({ : this.query.schedule?.map((scheduleItem: ScheduleItem) => ({
day: scheduleItem.day as number, day: scheduleItem.day as number,
time: scheduleItem.time, time: scheduleItem.time,
margin: scheduleItem.margin as number, margin: scheduleItem.margin as number,
@@ -137,6 +136,7 @@ export class PassengerOrientedSelector extends Selector {
this._whereStrict(), this._whereStrict(),
this._whereDate(), this._whereDate(),
this._whereSchedule(role), this._whereSchedule(role),
this._whereExcludedAd(),
this._whereAzimuth(), this._whereAzimuth(),
this._whereProportion(role), this._whereProportion(role),
this._whereRemoteness(role), this._whereRemoteness(role),
@@ -155,7 +155,9 @@ export class PassengerOrientedSelector extends Selector {
: ''; : '';
private _whereDate = (): string => private _whereDate = (): string =>
`(\ this.query.frequency == Frequency.PUNCTUAL
? `("fromDate" <= '${this.query.fromDate}' AND "toDate" >= '${this.query.fromDate}')`
: `(\
(\ (\
"fromDate" <= '${this.query.fromDate}' AND "fromDate" <= '${this.query.toDate}' AND\ "fromDate" <= '${this.query.fromDate}' AND "fromDate" <= '${this.query.toDate}' AND\
"toDate" >= '${this.query.toDate}' AND "toDate" >= '${this.query.fromDate}'\ "toDate" >= '${this.query.toDate}' AND "toDate" >= '${this.query.fromDate}'\
@@ -172,9 +174,11 @@ export class PassengerOrientedSelector extends Selector {
)`; )`;
private _whereSchedule = (role: Role): string => { private _whereSchedule = (role: Role): string => {
// no schedule filtering if schedule is not set
if (this.query.schedule === undefined) return '';
const schedule: string[] = []; const schedule: string[] = [];
// we need full dates to compare times, because margins can lead to compare on previous or next day // we need full dates to compare times, because margins can lead to compare on previous or next day
// -first we establish a base calendar (up to a week) // - first we establish a base calendar (up to a week)
const scheduleDates: Date[] = this._datesBetweenBoundaries( const scheduleDates: Date[] = this._datesBetweenBoundaries(
this.query.fromDate, this.query.fromDate,
this.query.toDate, this.query.toDate,
@@ -182,7 +186,7 @@ export class PassengerOrientedSelector extends Selector {
// - then we compare each resulting day of the schedule with each day of calendar, // - then we compare each resulting day of the schedule with each day of calendar,
// adding / removing margin depending on the role // adding / removing margin depending on the role
scheduleDates.map((date: Date) => { scheduleDates.map((date: Date) => {
this.query.schedule (this.query.schedule as ScheduleItem[])
.filter( .filter(
(scheduleItem: ScheduleItem) => date.getUTCDay() == scheduleItem.day, (scheduleItem: ScheduleItem) => date.getUTCDay() == scheduleItem.day,
) )
@@ -203,6 +207,9 @@ export class PassengerOrientedSelector extends Selector {
return ''; return '';
}; };
private _whereExcludedAd = (): string =>
this.query.excludedAdId ? `ad.uuid <> '${this.query.excludedAdId}'` : '';
private _wherePassengerSchedule = ( private _wherePassengerSchedule = (
date: Date, date: Date,
scheduleItem: ScheduleItem, scheduleItem: ScheduleItem,
@@ -310,6 +317,11 @@ 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 = ( private _datesBetweenBoundaries = (
firstDate: string, firstDate: string,
lastDate: string, lastDate: string,

View File

@@ -1,14 +1,40 @@
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';
export class AdEntity extends AggregateRoot<AdProps> { export class AdEntity extends AggregateRoot<AdProps> {
protected readonly _id: AggregateID; protected readonly _id: AggregateID;
static create = (create: CreateAdProps): AdEntity => { static create = (create: CreateAdProps): AdEntity => {
const props: AdProps = { ...create }; const props: AdProps = { ...create };
return new AdEntity({ id: create.id, props }); const ad = new AdEntity({ id: create.id, props });
ad.addEvent(
new MatcherAdCreatedDomainEvent({
metadata: {
correlationId: create.id,
timestamp: Date.now(),
},
aggregateId: create.id,
driverDistance: create.driverDistance,
driverDuration: create.driverDuration,
passengerDistance: create.passengerDistance,
passengerDuration: create.passengerDuration,
fwdAzimuth: create.fwdAzimuth,
backAzimuth: create.backAzimuth,
}),
);
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

@@ -1,21 +1,30 @@
import { AggregateRoot, AggregateID } from '@mobicoop/ddd-library'; import {
AggregateID,
AggregateRoot,
ArgumentInvalidException,
ValueObject,
} 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 { ScheduleItem } from './value-objects/schedule-item.value-object';
import { Journey } from './value-objects/journey.value-object';
import { CalendarTools } from './calendar-tools.service';
import { JourneyItem } from './value-objects/journey-item.value-object'; import { JourneyItem } from './value-objects/journey-item.value-object';
import { Actor } from './value-objects/actor.value-object'; import { Journey, JourneyProps } from './value-objects/journey.value-object';
import { ActorTime } from './value-objects/actor-time.value-object'; import {
import { Role } from './ad.types'; ScheduleItem,
ScheduleItemProps,
} from './value-objects/schedule-item.value-object';
import { Step, StepProps } from './value-objects/step.value-object';
export class CandidateEntity extends AggregateRoot<CandidateProps> { export class CandidateEntity extends AggregateRoot<CandidateProps> {
protected readonly _id: AggregateID; protected readonly _id: AggregateID;
@@ -53,13 +62,26 @@ export class CandidateEntity extends AggregateRoot<CandidateProps> {
* This is a tedious process : additional information can be found in deeper methods ! * This is a tedious process : additional information can be found in deeper methods !
*/ */
createJourneys = (): CandidateEntity => { createJourneys = (): CandidateEntity => {
this.props.journeys = this.props.driverSchedule // driver and passenger schedules are eventually mandatory
// first we create the journeys if (!this.props.driverSchedule) this._createDriverSchedule();
.map((driverScheduleItem: ScheduleItem) => if (!this.props.passengerSchedule) this._createPassengerSchedule();
this._createJourney(driverScheduleItem), this.props.journeys = this.props.driverSchedule!.reduce(
) (accJourneys: JourneyProps[], driverScheduleItem: ScheduleItem) => {
// then we filter the ones with invalid pickups try {
.filter((journey: Journey) => journey.hasValidPickUp()); // first we create the journeys
const journey = this._createJourney(driverScheduleItem);
// then we filter the ones with invalid pickups
if (journey.hasValidPickUp()) {
accJourneys.push(journey);
}
} catch (e) {
// irrelevant journeys fall here
// eg. no available day for the given date range
}
return accJourneys;
},
new Array<JourneyProps>(),
);
return this; return this;
}; };
@@ -77,6 +99,49 @@ export class CandidateEntity extends AggregateRoot<CandidateProps> {
(1 + this.props.spacetimeDetourRatio.maxDistanceDetourRatio) (1 + this.props.spacetimeDetourRatio.maxDistanceDetourRatio)
: false; : false;
/**
* Create the driver schedule based on the passenger schedule
*/
private _createDriverSchedule = (): void => {
const passengerSchedule = new Schedule(
this.props.passengerSchedule!,
this.props.dateInterval,
);
this.props.driverSchedule = passengerSchedule
.adjust(-this._passengerStartDuration())
.unpack().items;
};
/**
* Return the duration to reach the passenger starting point from the driver starting point
*/
private _passengerStartDuration = (): number => {
let passengerStartStepIndex = 0;
this.props.carpoolPath?.forEach(
(carpoolPathItem: CarpoolPathItem, index: number) => {
carpoolPathItem.actors.forEach((actor: Actor) => {
if (actor.role == Role.PASSENGER && actor.target == Target.START)
passengerStartStepIndex = index;
});
},
);
return this.props.steps![passengerStartStepIndex].duration;
};
/**
* Create the passenger schedule based on the driver schedule
*/
private _createPassengerSchedule = (): void => {
const driverSchedule = new Schedule(
this.props.driverSchedule!,
this.props.dateInterval,
);
this.props.passengerSchedule = driverSchedule
.adjust(this._passengerStartDuration())
.unpack().items;
};
private _createJourney = (driverScheduleItem: ScheduleItem): Journey => private _createJourney = (driverScheduleItem: ScheduleItem): Journey =>
new Journey({ new Journey({
firstDate: CalendarTools.firstDate( firstDate: CalendarTools.firstDate(
@@ -211,7 +276,7 @@ export class CandidateEntity extends AggregateRoot<CandidateProps> {
* Find the passenger schedule item with the minimum duration between a given date and the dates of the passenger schedule * Find the passenger schedule item with the minimum duration between a given date and the dates of the passenger schedule
*/ */
private _minPassengerScheduleItemGapForDate = (date: Date): ScheduleItemGap => private _minPassengerScheduleItemGapForDate = (date: Date): ScheduleItemGap =>
this.props.passengerSchedule (this.props.passengerSchedule as ScheduleItemProps[])
// first map the passenger schedule to "real" dates (we use unix epoch date as base) // first map the passenger schedule to "real" dates (we use unix epoch date as base)
.map( .map(
(scheduleItem: ScheduleItem) => (scheduleItem: ScheduleItem) =>
@@ -250,6 +315,64 @@ export class CandidateEntity extends AggregateRoot<CandidateProps> {
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
if (!this.props.driverSchedule && !this.props.passengerSchedule)
throw new ArgumentInvalidException(
'at least the driver or the passenger schedule is required',
);
}
}
//TODO Use this class as part of the CandidateEntity aggregate
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: itemDate.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')
);
} }
} }

View File

@@ -11,8 +11,8 @@ export interface CandidateProps {
frequency: Frequency; frequency: Frequency;
driverWaypoints: PointProps[]; driverWaypoints: PointProps[];
passengerWaypoints: PointProps[]; passengerWaypoints: PointProps[];
driverSchedule: ScheduleItemProps[]; driverSchedule?: ScheduleItemProps[];
passengerSchedule: ScheduleItemProps[]; passengerSchedule?: ScheduleItemProps[];
driverDistance: number; driverDistance: number;
driverDuration: number; driverDuration: number;
dateInterval: DateInterval; dateInterval: DateInterval;
@@ -33,8 +33,8 @@ export interface CreateCandidateProps {
driverDuration: number; driverDuration: number;
driverWaypoints: PointProps[]; driverWaypoints: PointProps[];
passengerWaypoints: PointProps[]; passengerWaypoints: PointProps[];
driverSchedule: ScheduleItemProps[]; driverSchedule?: ScheduleItemProps[];
passengerSchedule: ScheduleItemProps[]; passengerSchedule?: ScheduleItemProps[];
spacetimeDetourRatio: SpacetimeDetourRatio; spacetimeDetourRatio: SpacetimeDetourRatio;
dateInterval: DateInterval; dateInterval: DateInterval;
} }

View File

@@ -235,8 +235,8 @@ export class CarpoolPathCreator {
index == 0 index == 0
? Target.START ? Target.START
: index == waypoints.length - 1 : index == waypoints.length - 1
? Target.FINISH ? Target.FINISH
: Target.INTERMEDIATE; : Target.INTERMEDIATE;
/** /**
* Consolidate carpoolPath by removing duplicate actors (eg. driver with neutral and start or finish target) * Consolidate carpoolPath by removing duplicate actors (eg. driver with neutral and start or finish target)

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

@@ -0,0 +1,20 @@
import { DomainEvent, DomainEventProps } from '@mobicoop/ddd-library';
export class MatcherAdCreatedDomainEvent extends DomainEvent {
readonly driverDuration?: number;
readonly driverDistance?: number;
readonly passengerDuration?: number;
readonly passengerDistance?: number;
readonly fwdAzimuth: number;
readonly backAzimuth: number;
constructor(props: DomainEventProps<MatcherAdCreatedDomainEvent>) {
super(props);
this.driverDuration = props.driverDuration;
this.driverDistance = props.driverDistance;
this.passengerDuration = props.passengerDuration;
this.passengerDistance = props.passengerDistance;
this.fwdAzimuth = props.fwdAzimuth;
this.backAzimuth = props.backAzimuth;
}
}

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

@@ -42,8 +42,8 @@ export class JourneyItem extends ValueObject<JourneyItemProps> {
(actorTime: ActorTime) => actorTime.role == Role.DRIVER, (actorTime: ActorTime) => actorTime.role == Role.DRIVER,
) as ActorTime ) as ActorTime
).firstDatetime; ).firstDatetime;
return `${driverTime.getHours().toString().padStart(2, '0')}:${driverTime return `${driverTime.getUTCHours().toString().padStart(2, '0')}:${driverTime
.getMinutes() .getUTCMinutes()
.toString() .toString()
.padStart(2, '0')}`; .padStart(2, '0')}`;
}; };

View File

@@ -38,24 +38,45 @@ export class Journey extends ValueObject<JourneyProps> {
actorTime.target == Target.START, actorTime.target == Target.START,
) as ActorTime, ) as ActorTime,
) as JourneyItem; ) as JourneyItem;
const passengerDepartureActorTime = const passengerDepartureActorTime: ActorTime =
passengerDepartureJourneyItem.actorTimes.find( passengerDepartureJourneyItem.actorTimes.find(
(actorTime: ActorTime) => (actorTime: ActorTime) =>
actorTime.role == Role.PASSENGER && actorTime.target == Target.START, actorTime.role == Role.PASSENGER && actorTime.target == Target.START,
) as ActorTime; ) as ActorTime;
const driverNeutralActorTime = const driverActorTime = passengerDepartureJourneyItem.actorTimes.find(
passengerDepartureJourneyItem.actorTimes.find( (actorTime: ActorTime) => actorTime.role == Role.DRIVER,
(actorTime: ActorTime) => ) as ActorTime;
actorTime.role == Role.DRIVER && actorTime.target == Target.NEUTRAL, // TODO : check if the following conditions are even to the ones used in the return
) as ActorTime; // 4 possibilities to be valid :
// - 1 : the driver time boundaries are within the passenger time boundaries
// - 2 : the max driver time boundary is within the passenger time boundaries
// - 3 : the min driver time boundary is within the passenger time boundaries
// - 4 : the passenger time boundaries are within the driver time boundaries
// return (
// // 1
// (driverActorTime.firstMinDatetime >=
// passengerDepartureActorTime.firstMinDatetime &&
// driverActorTime.firstMaxDatetime <=
// passengerDepartureActorTime.firstMaxDatetime) ||
// // 2 & 4
// (driverActorTime.firstMinDatetime <=
// passengerDepartureActorTime.firstMinDatetime &&
// driverActorTime.firstMaxDatetime >=
// passengerDepartureActorTime.firstMinDatetime) ||
// // 3
// (driverActorTime.firstMinDatetime >=
// passengerDepartureActorTime.firstMinDatetime &&
// driverActorTime.firstMinDatetime <=
// passengerDepartureActorTime.firstMaxDatetime)
// );
return ( return (
(passengerDepartureActorTime.firstMinDatetime <= (passengerDepartureActorTime.firstMinDatetime <=
driverNeutralActorTime.firstMaxDatetime && driverActorTime.firstMaxDatetime &&
driverNeutralActorTime.firstMaxDatetime <= driverActorTime.firstMaxDatetime <=
passengerDepartureActorTime.firstMaxDatetime) || passengerDepartureActorTime.firstMaxDatetime) ||
(passengerDepartureActorTime.firstMinDatetime <= (passengerDepartureActorTime.firstMinDatetime <=
driverNeutralActorTime.firstMinDatetime && driverActorTime.firstMinDatetime &&
driverNeutralActorTime.firstMinDatetime <= driverActorTime.firstMinDatetime <=
passengerDepartureActorTime.firstMaxDatetime) passengerDepartureActorTime.firstMaxDatetime)
); );
}; };

View File

@@ -14,7 +14,7 @@ export interface MatchQueryProps {
frequency: Frequency; frequency: Frequency;
fromDate: string; fromDate: string;
toDate: string; toDate: string;
schedule: ScheduleItemProps[]; schedule?: ScheduleItemProps[];
seatsProposed: number; seatsProposed: number;
seatsRequested: number; seatsRequested: number;
strict: boolean; strict: boolean;
@@ -50,7 +50,7 @@ export class MatchQuery extends ValueObject<MatchQueryProps> {
return this.props.toDate; return this.props.toDate;
} }
get schedule(): ScheduleItemProps[] { get schedule(): ScheduleItemProps[] | undefined {
return this.props.schedule; return this.props.schedule;
} }

View File

@@ -0,0 +1,39 @@
syntax = "proto3";
package geography;
service GeorouterService {
rpc GetRoute(RouteRequest) returns (Route);
}
message RouteRequest {
repeated Point waypoints = 1;
optional DetailsSettings detailsSettings = 2;
}
message Point {
double lon = 1;
double lat = 2;
}
message DetailsSettings {
bool points = 1;
bool steps = 2;
}
message Route {
int32 distance = 1;
int32 duration = 2;
int32 fwdAzimuth = 3;
int32 backAzimuth = 4;
int32 distanceAzimuth = 5;
repeated Point points = 6;
repeated Step steps = 7;
}
message Step {
double lon = 1;
double lat = 2;
int32 duration = 3;
int32 distance = 4;
}

View File

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

View File

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

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

@@ -2,10 +2,10 @@ import {
ArrayMinSize, ArrayMinSize,
IsArray, IsArray,
IsBoolean, IsBoolean,
IsDecimal,
IsEnum, IsEnum,
IsISO8601, IsISO8601,
IsInt, IsInt,
IsNumber,
IsOptional, IsOptional,
IsUUID, IsUUID,
Max, Max,
@@ -58,8 +58,9 @@ export class MatchRequestDto {
@Type(() => ScheduleItemDto) @Type(() => ScheduleItemDto)
@IsArray() @IsArray()
@ArrayMinSize(1) @ArrayMinSize(1)
@IsOptional()
@ValidateNested({ each: true }) @ValidateNested({ each: true })
schedule: ScheduleItemDto[]; schedule?: ScheduleItemDto[];
@IsOptional() @IsOptional()
@IsInt() @IsInt()
@@ -80,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;
@@ -93,7 +98,7 @@ export class MatchRequestDto {
useProportion?: boolean; useProportion?: boolean;
@IsOptional() @IsOptional()
@IsDecimal() @IsNumber()
@Min(0) @Min(0)
@Max(1) @Max(1)
proportion?: number; proportion?: number;
@@ -109,13 +114,13 @@ export class MatchRequestDto {
azimuthMargin?: number; azimuthMargin?: number;
@IsOptional() @IsOptional()
@IsDecimal() @IsNumber()
@Min(0) @Min(0)
@Max(1) @Max(1)
maxDetourDistanceRatio?: number; maxDetourDistanceRatio?: number;
@IsOptional() @IsOptional()
@IsDecimal() @IsNumber()
@Min(0) @Min(0)
@Max(1) @Max(1)
maxDetourDurationRatio?: number; maxDetourDurationRatio?: number;

View File

@@ -1,18 +1,18 @@
import { Controller, Inject, UseInterceptors, UsePipes } from '@nestjs/common';
import { GrpcMethod, RpcException } from '@nestjs/microservices';
import { RpcValidationPipe } from '@mobicoop/ddd-library'; import { RpcValidationPipe } from '@mobicoop/ddd-library';
import { RpcExceptionCode } from '@mobicoop/ddd-library';
import { MatchingPaginatedResponseDto } from '../dtos/matching.paginated.response.dto';
import { QueryBus } from '@nestjs/cqrs';
import { MatchRequestDto } from './dtos/match.request.dto';
import { MatchQuery } from '@modules/ad/core/application/queries/match/match.query';
import { MatchEntity } from '@modules/ad/core/domain/match.entity';
import { AD_ROUTE_PROVIDER } from '@modules/ad/ad.di-tokens'; import { AD_ROUTE_PROVIDER } from '@modules/ad/ad.di-tokens';
import { RouteProviderPort } from '@modules/ad/core/application/ports/route-provider.port'; import { GeorouterPort } from '@modules/ad/core/application/ports/georouter.port';
import { MatchMapper } from '@modules/ad/match.mapper'; 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 { CacheInterceptor, CacheKey } from '@nestjs/cache-manager'; import { MatchEntity } from '@modules/ad/core/domain/match.entity';
import { MatchMapper } from '@modules/ad/match.mapper';
import { Controller, Inject, UseFilters, UsePipes } from '@nestjs/common';
import { QueryBus } from '@nestjs/cqrs';
import { GrpcMethod } from '@nestjs/microservices';
import { LogCauseExceptionFilter } from '@src/log-cause.exception-filter';
import { MatchingPaginatedResponseDto } from '../dtos/matching.paginated.response.dto';
import { MatchRequestDto } from './dtos/match.request.dto';
@UseFilters(LogCauseExceptionFilter)
@UsePipes( @UsePipes(
new RpcValidationPipe({ new RpcValidationPipe({
whitelist: false, whitelist: false,
@@ -24,32 +24,23 @@ export class MatchGrpcController {
constructor( constructor(
private readonly queryBus: QueryBus, private readonly queryBus: QueryBus,
@Inject(AD_ROUTE_PROVIDER) @Inject(AD_ROUTE_PROVIDER)
private readonly routeProvider: RouteProviderPort, private readonly routeProvider: GeorouterPort,
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> {
try { const matchingResult: MatchingResult = await this.queryBus.execute(
const matchingResult: MatchingResult = await this.queryBus.execute( new MatchQuery(data, this.routeProvider),
new MatchQuery(data, this.routeProvider), );
); return new MatchingPaginatedResponseDto({
return new MatchingPaginatedResponseDto({ id: matchingResult.id,
id: matchingResult.id, data: matchingResult.matches.map((match: MatchEntity) =>
data: matchingResult.matches.map((match: MatchEntity) => this.matchMapper.toResponse(match),
this.matchMapper.toResponse(match), ),
), page: matchingResult.page,
page: matchingResult.page, perPage: matchingResult.perPage,
perPage: matchingResult.perPage, total: matchingResult.total,
total: matchingResult.total, });
});
} catch (e) {
throw new RpcException({
code: RpcExceptionCode.UNKNOWN,
message: e.message,
});
}
} }
} }

View File

@@ -16,15 +16,15 @@ 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;
int32 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;
int32 identifier = 18; float maxDetourDurationRatio = 18;
optional int32 page = 19; optional int32 page = 19;
optional int32 perPage = 20; optional int32 perPage = 20;
} }
@@ -57,9 +57,9 @@ enum AlgorithmType {
} }
message Match { message Match {
string id = 1; string adId = 1;
string adId = 2; string role = 2;
string role = 3; Frequency frequency = 3;
int32 distance = 4; int32 distance = 4;
int32 duration = 5; int32 duration = 5;
int32 initialDistance = 6; int32 initialDistance = 6;

View File

@@ -3,7 +3,10 @@ import { RabbitSubscribe } from '@mobicoop/message-broker-module';
import { CommandBus } from '@nestjs/cqrs'; import { CommandBus } from '@nestjs/cqrs';
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 { Ad } from './ad.types'; import { Ad } from './ad.types';
import { AD_CREATED_MESSAGE_HANDLER } from '@src/app.constants'; import {
AD_CREATED_MESSAGE_HANDLER,
AD_CREATED_ROUTING_KEY,
} from '@src/app.constants';
@Injectable() @Injectable()
export class AdCreatedMessageHandler { export class AdCreatedMessageHandler {
@@ -11,6 +14,7 @@ export class AdCreatedMessageHandler {
@RabbitSubscribe({ @RabbitSubscribe({
name: AD_CREATED_MESSAGE_HANDLER, name: AD_CREATED_MESSAGE_HANDLER,
routingKey: AD_CREATED_ROUTING_KEY,
}) })
public async adCreated(message: string) { public async adCreated(message: string) {
try { try {
@@ -30,8 +34,9 @@ export class AdCreatedMessageHandler {
waypoints: createdAd.waypoints, waypoints: createdAd.waypoints,
}), }),
); );
} catch (e: any) { } catch (error: any) {
console.log(e); // do not throw error to acknowledge incoming message
// error handling should be done in the command handler, if relevant
} }
} }
} }

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

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

@@ -1,7 +1,4 @@
import { import { KeyType, Type } from '@mobicoop/configuration-module';
ConfigurationDomainGet,
ConfigurationType,
} from '@mobicoop/configuration-module';
export const MATCH_CONFIG_ALGORITHM = 'algorithm'; export const MATCH_CONFIG_ALGORITHM = 'algorithm';
export const MATCH_CONFIG_REMOTENESS = 'remoteness'; export const MATCH_CONFIG_REMOTENESS = 'remoteness';
@@ -13,44 +10,44 @@ export const MATCH_CONFIG_MAX_DETOUR_DISTANCE_RATIO = 'maxDetourDistanceRatio';
export const MATCH_CONFIG_MAX_DETOUR_DURATION_RATIO = 'maxDetourDurationRatio'; export const MATCH_CONFIG_MAX_DETOUR_DURATION_RATIO = 'maxDetourDurationRatio';
export const PAGINATION_CONFIG_PER_PAGE = 'perPage'; export const PAGINATION_CONFIG_PER_PAGE = 'perPage';
export const MatchConfig: ConfigurationDomainGet[] = [ export const MatchConfig: KeyType[] = [
{ {
key: MATCH_CONFIG_ALGORITHM, key: MATCH_CONFIG_ALGORITHM,
type: ConfigurationType.STRING, type: Type.STRING,
}, },
{ {
key: MATCH_CONFIG_REMOTENESS, key: MATCH_CONFIG_REMOTENESS,
type: ConfigurationType.INT, type: Type.INT,
}, },
{ {
key: MATCH_CONFIG_USE_PROPORTION, key: MATCH_CONFIG_USE_PROPORTION,
type: ConfigurationType.BOOLEAN, type: Type.BOOLEAN,
}, },
{ {
key: MATCH_CONFIG_PROPORTION, key: MATCH_CONFIG_PROPORTION,
type: ConfigurationType.FLOAT, type: Type.FLOAT,
}, },
{ {
key: MATCH_CONFIG_USE_AZIMUTH, key: MATCH_CONFIG_USE_AZIMUTH,
type: ConfigurationType.BOOLEAN, type: Type.BOOLEAN,
}, },
{ {
key: MATCH_CONFIG_AZIMUTH_MARGIN, key: MATCH_CONFIG_AZIMUTH_MARGIN,
type: ConfigurationType.INT, type: Type.INT,
}, },
{ {
key: MATCH_CONFIG_MAX_DETOUR_DISTANCE_RATIO, key: MATCH_CONFIG_MAX_DETOUR_DISTANCE_RATIO,
type: ConfigurationType.FLOAT, type: Type.FLOAT,
}, },
{ {
key: MATCH_CONFIG_MAX_DETOUR_DURATION_RATIO, key: MATCH_CONFIG_MAX_DETOUR_DURATION_RATIO,
type: ConfigurationType.FLOAT, type: Type.FLOAT,
}, },
]; ];
export const PaginationConfig: ConfigurationDomainGet[] = [ export const PaginationConfig: KeyType[] = [
{ {
key: PAGINATION_CONFIG_PER_PAGE, key: PAGINATION_CONFIG_PER_PAGE,
type: ConfigurationType.INT, type: Type.INT,
}, },
]; ];

View File

@@ -64,7 +64,7 @@ export class MatchingMapper
toDate: entity.getProps().query.toDate, toDate: entity.getProps().query.toDate,
schedule: entity schedule: entity
.getProps() .getProps()
.query.schedule.map((scheduleItem: ScheduleItem) => ({ .query.schedule?.map((scheduleItem: ScheduleItem) => ({
day: scheduleItem.day, day: scheduleItem.day,
time: scheduleItem.time, time: scheduleItem.time,
margin: scheduleItem.margin, margin: scheduleItem.margin,

View File

@@ -1,52 +1,17 @@
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', () => {
it('should create a new entity', async () => { describe('create', () => {
const ad: AdEntity = AdEntity.create(createAdProps); it('should create a new entity', async () => {
expect(ad.id.length).toBe(36); const ad: AdEntity = AdEntity.create(createAdProps());
expect(ad.getProps().schedule.length).toBe(1); expect(ad.id.length).toBe(36);
expect(ad.getProps().schedule[0].day).toBe(3); expect(ad.getProps().schedule.length).toBe(1);
expect(ad.getProps().schedule[0].time).toBe('08:30'); expect(ad.getProps().schedule[0].day).toBe(3);
expect(ad.getProps().driver).toBeTruthy(); expect(ad.getProps().schedule[0].time).toBe('08:30');
expect(ad.getProps().passenger).toBeTruthy(); expect(ad.getProps().driver).toBeTruthy();
expect(ad.getProps().driverDistance).toBe(23000); expect(ad.getProps().passenger).toBeTruthy();
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

@@ -1,5 +1,4 @@
import { AdRepositoryPort } from '@modules/ad/core/application/ports/ad.repository.port'; import { AdRepositoryPort } from '@modules/ad/core/application/ports/ad.repository.port';
import { RouteProviderPort } from '@modules/ad/core/application/ports/route-provider.port';
import { import {
Algorithm, Algorithm,
Selector, Selector,
@@ -9,6 +8,7 @@ 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 { MatchEntity } from '@modules/ad/core/domain/match.entity'; import { MatchEntity } from '@modules/ad/core/domain/match.entity';
import { bareMockGeorouter } from '../georouter.mock';
const originWaypoint: Waypoint = { const originWaypoint: Waypoint = {
position: 0, position: 0,
@@ -29,11 +29,6 @@ const destinationWaypoint: Waypoint = {
country: 'France', country: 'France',
}; };
const mockRouteProvider: RouteProviderPort = {
getBasic: jest.fn(),
getDetailed: jest.fn(),
};
const matchQuery = new MatchQuery( const matchQuery = new MatchQuery(
{ {
frequency: Frequency.PUNCTUAL, frequency: Frequency.PUNCTUAL,
@@ -46,13 +41,15 @@ const matchQuery = new MatchQuery(
], ],
waypoints: [originWaypoint, destinationWaypoint], waypoints: [originWaypoint, destinationWaypoint],
}, },
mockRouteProvider, bareMockGeorouter,
); );
const mockAdRepository: AdRepositoryPort = { const mockAdRepository: AdRepositoryPort = {
insertExtra: jest.fn(), insertExtra: jest.fn(),
findOneById: jest.fn(), findOneById: jest.fn(),
findOne: jest.fn(), findOne: jest.fn(),
findAll: jest.fn(),
findAllByIds: jest.fn(),
insert: jest.fn(), insert: jest.fn(),
update: jest.fn(), update: jest.fn(),
updateWhere: jest.fn(), updateWhere: jest.fn(),

View File

@@ -1,3 +1,4 @@
import { ArgumentInvalidException } from '@mobicoop/ddd-library';
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 { import {
@@ -6,7 +7,10 @@ import {
} from '@modules/ad/core/domain/candidate.types'; } from '@modules/ad/core/domain/candidate.types';
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 { CarpoolPathItemProps } from '@modules/ad/core/domain/value-objects/carpool-path-item.value-object'; import { CarpoolPathItemProps } from '@modules/ad/core/domain/value-objects/carpool-path-item.value-object';
import { Journey } from '@modules/ad/core/domain/value-objects/journey.value-object'; import {
Journey,
JourneyProps,
} from '@modules/ad/core/domain/value-objects/journey.value-object';
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 { ScheduleItemProps } from '@modules/ad/core/domain/value-objects/schedule-item.value-object'; import { ScheduleItemProps } from '@modules/ad/core/domain/value-objects/schedule-item.value-object';
import { StepProps } from '@modules/ad/core/domain/value-objects/step.value-object'; import { StepProps } from '@modules/ad/core/domain/value-objects/step.value-object';
@@ -33,7 +37,7 @@ const waypointsSet2: PointProps[] = [
}, },
]; ];
const schedule1: ScheduleItemProps[] = [ const mondayAt7: ScheduleItemProps[] = [
{ {
day: 1, day: 1,
time: '07:00', time: '07:00',
@@ -41,7 +45,7 @@ const schedule1: ScheduleItemProps[] = [
}, },
]; ];
const schedule2: ScheduleItemProps[] = [ const mondayAt710: ScheduleItemProps[] = [
{ {
day: 1, day: 1,
time: '07:10', time: '07:10',
@@ -49,7 +53,7 @@ const schedule2: ScheduleItemProps[] = [
}, },
]; ];
const schedule3: ScheduleItemProps[] = [ const weekdayMornings: ScheduleItemProps[] = [
{ {
day: 1, day: 1,
time: '06:30', time: '06:30',
@@ -77,7 +81,7 @@ const schedule3: ScheduleItemProps[] = [
}, },
]; ];
const schedule4: ScheduleItemProps[] = [ const schooldayMornings: ScheduleItemProps[] = [
{ {
day: 1, day: 1,
time: '06:50', time: '06:50',
@@ -100,7 +104,7 @@ const schedule4: ScheduleItemProps[] = [
}, },
]; ];
const schedule5: ScheduleItemProps[] = [ const saturdayNightAndMondayMorning: ScheduleItemProps[] = [
{ {
day: 0, day: 0,
time: '00:02', time: '00:02',
@@ -113,7 +117,7 @@ const schedule5: ScheduleItemProps[] = [
}, },
]; ];
const schedule6: ScheduleItemProps[] = [ const mondayAndSaturdayNights: ScheduleItemProps[] = [
{ {
day: 1, day: 1,
time: '23:10', time: '23:10',
@@ -126,7 +130,7 @@ const schedule6: ScheduleItemProps[] = [
}, },
]; ];
const schedule7: ScheduleItemProps[] = [ const thursdayEvening: ScheduleItemProps[] = [
{ {
day: 4, day: 4,
time: '19:00', time: '19:00',
@@ -262,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);
@@ -282,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);
@@ -302,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);
@@ -324,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();
@@ -343,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();
@@ -365,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)
@@ -374,6 +378,95 @@ describe('Candidate entity', () => {
.createJourneys(); .createJourneys();
expect(candidateEntity.getProps().journeys).toHaveLength(1); expect(candidateEntity.getProps().journeys).toHaveLength(1);
}); });
it('should create journeys for a single date without driver schedule', () => {
const candidateEntity: CandidateEntity = CandidateEntity.create({
id: '5600ccfb-ab69-4d03-aa30-0fbe84fcedc0',
role: Role.PASSENGER,
frequency: Frequency.PUNCTUAL,
dateInterval: {
lowerDate: '2023-08-28',
higherDate: '2023-08-28',
},
driverWaypoints: waypointsSet1,
passengerWaypoints: waypointsSet2,
driverDistance: 350145,
driverDuration: 13548,
driverSchedule: undefined,
passengerSchedule: mondayAt710,
spacetimeDetourRatio,
})
.setCarpoolPath(carpoolPath2)
.setSteps(steps)
.createJourneys();
expect(candidateEntity.getProps().journeys).toHaveLength(1);
// computed driver start time should be 06:49
expect(
(
candidateEntity.getProps().journeys as JourneyProps[]
)[0].journeyItems[0].actorTimes[0].firstDatetime.getUTCMinutes(),
).toBe(49);
expect(
(
candidateEntity.getProps().journeys as JourneyProps[]
)[0].journeyItems[0].actorTimes[0].firstDatetime.getUTCHours(),
).toBe(6);
});
it('should create journeys for a single date without passenger schedule', () => {
const candidateEntity: CandidateEntity = CandidateEntity.create({
id: '5600ccfb-ab69-4d03-aa30-0fbe84fcedc0',
role: Role.PASSENGER,
frequency: Frequency.PUNCTUAL,
dateInterval: {
lowerDate: '2023-08-28',
higherDate: '2023-08-28',
},
driverWaypoints: waypointsSet1,
passengerWaypoints: waypointsSet2,
driverDistance: 350145,
driverDuration: 13548,
driverSchedule: mondayAt7,
passengerSchedule: undefined,
spacetimeDetourRatio,
})
.setCarpoolPath(carpoolPath2)
.setSteps(steps)
.createJourneys();
expect(candidateEntity.getProps().journeys).toHaveLength(1);
// computed passenger start time should be 07:20
expect(
(
candidateEntity.getProps().journeys as JourneyProps[]
)[0].journeyItems[1].actorTimes[0].firstDatetime.getUTCMinutes(),
).toBe(20);
expect(
(
candidateEntity.getProps().journeys as JourneyProps[]
)[0].journeyItems[1].actorTimes[0].firstDatetime.getUTCHours(),
).toBe(7);
});
it('should throw without driver and passenger schedule', () => {
expect(() =>
CandidateEntity.create({
id: '5600ccfb-ab69-4d03-aa30-0fbe84fcedc0',
role: Role.PASSENGER,
frequency: Frequency.PUNCTUAL,
dateInterval: {
lowerDate: '2023-08-28',
higherDate: '2023-08-28',
},
driverWaypoints: waypointsSet1,
passengerWaypoints: waypointsSet2,
driverDistance: 350145,
driverDuration: 13548,
driverSchedule: undefined,
passengerSchedule: undefined,
spacetimeDetourRatio,
})
.setCarpoolPath(carpoolPath2)
.setSteps(steps)
.createJourneys(),
).toThrow(ArgumentInvalidException);
});
it('should create journeys for multiple dates', () => { it('should create journeys for multiple dates', () => {
const candidateEntity: CandidateEntity = CandidateEntity.create({ const candidateEntity: CandidateEntity = CandidateEntity.create({
id: '5600ccfb-ab69-4d03-aa30-0fbe84fcedc0', id: '5600ccfb-ab69-4d03-aa30-0fbe84fcedc0',
@@ -387,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)
@@ -424,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)
@@ -457,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({
@@ -471,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)
@@ -495,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,5 +1,9 @@
import { Test, TestingModule } from '@nestjs/testing'; import { Test, TestingModule } from '@nestjs/testing';
import { AD_REPOSITORY, AD_ROUTE_PROVIDER } from '@modules/ad/ad.di-tokens'; import {
AD_MESSAGE_PUBLISHER,
AD_REPOSITORY,
AD_ROUTE_PROVIDER,
} from '@modules/ad/ad.di-tokens';
import { AggregateID } from '@mobicoop/ddd-library'; import { AggregateID } from '@mobicoop/ddd-library';
import { AdEntity } from '@modules/ad/core/domain/ad.entity'; import { AdEntity } from '@modules/ad/core/domain/ad.entity';
import { ConflictException } from '@mobicoop/ddd-library'; import { ConflictException } from '@mobicoop/ddd-library';
@@ -7,8 +11,8 @@ import { CreateAdProps, Frequency } from '@modules/ad/core/domain/ad.types';
import { CreateAdService } from '@modules/ad/core/application/commands/create-ad/create-ad.service'; 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 { AdAlreadyExistsException } from '@modules/ad/core/domain/ad.errors'; import { AdAlreadyExistsException } from '@modules/ad/core/domain/ad.errors';
import { RouteProviderPort } from '@modules/ad/core/application/ports/route-provider.port';
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';
const originWaypoint: PointProps = { const originWaypoint: PointProps = {
lat: 48.689445, lat: 48.689445,
@@ -58,8 +62,8 @@ const mockAdRepository = {
}), }),
}; };
const mockRouteProvider: RouteProviderPort = { const mockRouteProvider: GeorouterPort = {
getBasic: jest getRoute: jest
.fn() .fn()
.mockImplementationOnce(() => { .mockImplementationOnce(() => {
throw new Error(); throw new Error();
@@ -93,7 +97,10 @@ const mockRouteProvider: RouteProviderPort = {
}, },
], ],
})), })),
getDetailed: jest.fn(), };
const mockMessagePublisher = {
publish: jest.fn().mockImplementation(),
}; };
describe('create-ad.service', () => { describe('create-ad.service', () => {
@@ -110,6 +117,10 @@ describe('create-ad.service', () => {
provide: AD_ROUTE_PROVIDER, provide: AD_ROUTE_PROVIDER,
useValue: mockRouteProvider, useValue: mockRouteProvider,
}, },
{
provide: AD_MESSAGE_PUBLISHER,
useValue: mockMessagePublisher,
},
CreateAdService, CreateAdService,
], ],
}).compile(); }).compile();

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

@@ -6,6 +6,7 @@ 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 { Actor } from '@modules/ad/core/domain/value-objects/actor.value-object'; import { Actor } from '@modules/ad/core/domain/value-objects/actor.value-object';
import { simpleMockGeorouter } from '../georouter.mock';
const originWaypoint: Waypoint = { const originWaypoint: Waypoint = {
position: 0, position: 0,
@@ -42,24 +43,7 @@ const matchQuery = new MatchQuery(
strict: false, strict: false,
waypoints: [originWaypoint, destinationWaypoint], waypoints: [originWaypoint, destinationWaypoint],
}, },
{ simpleMockGeorouter,
getBasic: jest.fn().mockImplementation(() => ({
distance: 350101,
duration: 14422,
fwdAzimuth: 273,
backAzimuth: 93,
distanceAzimuth: 336544,
points: [],
})),
getDetailed: jest.fn().mockImplementation(() => ({
distance: 350102,
duration: 14423,
fwdAzimuth: 273,
backAzimuth: 93,
distanceAzimuth: 336544,
points: [],
})),
},
); );
const candidate: CandidateEntity = CandidateEntity.create({ const candidate: CandidateEntity = CandidateEntity.create({

View File

@@ -4,6 +4,7 @@ import { AlgorithmType } from '@modules/ad/core/application/types/algorithm.type
import { Waypoint } from '@modules/ad/core/application/types/waypoint.type'; 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 { bareMockGeorouter } from '../georouter.mock';
const originWaypoint: Waypoint = { const originWaypoint: Waypoint = {
position: 0, position: 0,
@@ -40,10 +41,7 @@ const matchQuery = new MatchQuery(
strict: false, strict: false,
waypoints: [originWaypoint, destinationWaypoint], waypoints: [originWaypoint, destinationWaypoint],
}, },
{ bareMockGeorouter,
getBasic: jest.fn(),
getDetailed: jest.fn(),
},
); );
const candidate: CandidateEntity = CandidateEntity.create({ const candidate: CandidateEntity = CandidateEntity.create({

View File

@@ -44,7 +44,7 @@ describe('Match Query value object', () => {
expect(matchQueryVO.frequency).toBe(Frequency.PUNCTUAL); expect(matchQueryVO.frequency).toBe(Frequency.PUNCTUAL);
expect(matchQueryVO.fromDate).toBe('2023-09-01'); expect(matchQueryVO.fromDate).toBe('2023-09-01');
expect(matchQueryVO.toDate).toBe('2023-09-01'); expect(matchQueryVO.toDate).toBe('2023-09-01');
expect(matchQueryVO.schedule.length).toBe(1); expect(matchQueryVO.schedule?.length).toBe(1);
expect(matchQueryVO.seatsProposed).toBe(3); expect(matchQueryVO.seatsProposed).toBe(3);
expect(matchQueryVO.seatsRequested).toBe(1); expect(matchQueryVO.seatsRequested).toBe(1);
expect(matchQueryVO.strict).toBe(false); expect(matchQueryVO.strict).toBe(false);

View File

@@ -1,6 +1,6 @@
import { import {
ConfigurationDomain, Domain,
ConfigurationDomainGet, KeyType,
Configurator, Configurator,
GetConfigurationRepositoryPort, GetConfigurationRepositoryPort,
} from '@mobicoop/configuration-module'; } from '@mobicoop/configuration-module';
@@ -19,7 +19,6 @@ import {
} from '@modules/ad/ad.di-tokens'; } from '@modules/ad/ad.di-tokens';
import { DateTimeTransformerPort } from '@modules/ad/core/application/ports/datetime-transformer.port'; 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 { RouteProviderPort } from '@modules/ad/core/application/ports/route-provider.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 {
MatchQueryHandler, MatchQueryHandler,
@@ -43,6 +42,7 @@ import {
PAGINATION_CONFIG_PER_PAGE, PAGINATION_CONFIG_PER_PAGE,
} from '@modules/ad/match.constants'; } from '@modules/ad/match.constants';
import { Test, TestingModule } from '@nestjs/testing'; import { Test, TestingModule } from '@nestjs/testing';
import { simpleMockGeorouter } from '../georouter.mock';
const originWaypoint: Waypoint = { const originWaypoint: Waypoint = {
position: 0, position: 0,
@@ -258,83 +258,83 @@ const mockConfigurationRepository: GetConfigurationRepositoryPort = {
get: jest.fn(), get: jest.fn(),
mget: jest.fn().mockImplementation( mget: jest.fn().mockImplementation(
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
(domain: ConfigurationDomain, configs: ConfigurationDomainGet[]) => { (domain: Domain, keyTypes: KeyType[]) => {
switch (domain) { switch (domain) {
case ConfigurationDomain.CARPOOL: case Domain.CARPOOL:
return new Configurator(ConfigurationDomain.CARPOOL, [ return new Configurator(Domain.CARPOOL, [
{ {
domain: ConfigurationDomain.CARPOOL, domain: Domain.CARPOOL,
key: CARPOOL_CONFIG_DEPARTURE_TIME_MARGIN, key: CARPOOL_CONFIG_DEPARTURE_TIME_MARGIN,
value: 900, value: 900,
}, },
{ {
domain: ConfigurationDomain.CARPOOL, domain: Domain.CARPOOL,
key: CARPOOL_CONFIG_ROLE, key: CARPOOL_CONFIG_ROLE,
value: 'passenger', value: 'passenger',
}, },
{ {
domain: ConfigurationDomain.CARPOOL, domain: Domain.CARPOOL,
key: CARPOOL_CONFIG_SEATS_PROPOSED, key: CARPOOL_CONFIG_SEATS_PROPOSED,
value: 3, value: 3,
}, },
{ {
domain: ConfigurationDomain.CARPOOL, domain: Domain.CARPOOL,
key: CARPOOL_CONFIG_SEATS_REQUESTED, key: CARPOOL_CONFIG_SEATS_REQUESTED,
value: 1, value: 1,
}, },
{ {
domain: ConfigurationDomain.CARPOOL, domain: Domain.CARPOOL,
key: CARPOOL_CONFIG_STRICT_FREQUENCY, key: CARPOOL_CONFIG_STRICT_FREQUENCY,
value: false, value: false,
}, },
]); ]);
case ConfigurationDomain.MATCH: case Domain.MATCH:
return new Configurator(ConfigurationDomain.MATCH, [ return new Configurator(Domain.MATCH, [
{ {
domain: ConfigurationDomain.MATCH, domain: Domain.MATCH,
key: MATCH_CONFIG_ALGORITHM, key: MATCH_CONFIG_ALGORITHM,
value: 'PASSENGER_ORIENTED', value: 'PASSENGER_ORIENTED',
}, },
{ {
domain: ConfigurationDomain.MATCH, domain: Domain.MATCH,
key: MATCH_CONFIG_REMOTENESS, key: MATCH_CONFIG_REMOTENESS,
value: 15000, value: 15000,
}, },
{ {
domain: ConfigurationDomain.MATCH, domain: Domain.MATCH,
key: MATCH_CONFIG_USE_PROPORTION, key: MATCH_CONFIG_USE_PROPORTION,
value: true, value: true,
}, },
{ {
domain: ConfigurationDomain.MATCH, domain: Domain.MATCH,
key: MATCH_CONFIG_PROPORTION, key: MATCH_CONFIG_PROPORTION,
value: 0.3, value: 0.3,
}, },
{ {
domain: ConfigurationDomain.MATCH, domain: Domain.MATCH,
key: MATCH_CONFIG_USE_AZIMUTH, key: MATCH_CONFIG_USE_AZIMUTH,
value: true, value: true,
}, },
{ {
domain: ConfigurationDomain.MATCH, domain: Domain.MATCH,
key: MATCH_CONFIG_AZIMUTH_MARGIN, key: MATCH_CONFIG_AZIMUTH_MARGIN,
value: 10, value: 10,
}, },
{ {
domain: ConfigurationDomain.MATCH, domain: Domain.MATCH,
key: MATCH_CONFIG_MAX_DETOUR_DISTANCE_RATIO, key: MATCH_CONFIG_MAX_DETOUR_DISTANCE_RATIO,
value: 0.3, value: 0.3,
}, },
{ {
domain: ConfigurationDomain.MATCH, domain: Domain.MATCH,
key: MATCH_CONFIG_MAX_DETOUR_DURATION_RATIO, key: MATCH_CONFIG_MAX_DETOUR_DURATION_RATIO,
value: 0.3, value: 0.3,
}, },
]); ]);
case ConfigurationDomain.PAGINATION: case Domain.PAGINATION:
return new Configurator(ConfigurationDomain.PAGINATION, [ return new Configurator(Domain.PAGINATION, [
{ {
domain: ConfigurationDomain.PAGINATION, domain: Domain.PAGINATION,
key: PAGINATION_CONFIG_PER_PAGE, key: PAGINATION_CONFIG_PER_PAGE,
value: 10, value: 10,
}, },
@@ -351,17 +351,7 @@ const mockInputDateTimeTransformer: DateTimeTransformerPort = {
time: jest.fn(), time: jest.fn(),
}; };
const mockRouteProvider: RouteProviderPort = { const mockRouteProvider = simpleMockGeorouter;
getBasic: jest.fn().mockImplementation(() => ({
distance: 350101,
duration: 14422,
fwdAzimuth: 273,
backAzimuth: 93,
distanceAzimuth: 336544,
points: [],
})),
getDetailed: jest.fn(),
};
describe('Match Query Handler', () => { describe('Match Query Handler', () => {
let matchQueryHandler: MatchQueryHandler; let matchQueryHandler: MatchQueryHandler;

View File

@@ -1,9 +1,13 @@
import { DateTimeTransformerPort } from '@modules/ad/core/application/ports/datetime-transformer.port'; import { DateTimeTransformerPort } from '@modules/ad/core/application/ports/datetime-transformer.port';
import { RouteProviderPort } from '@modules/ad/core/application/ports/route-provider.port'; import { GeorouterPort } from '@modules/ad/core/application/ports/georouter.port';
import { MatchQuery } from '@modules/ad/core/application/queries/match/match.query'; import {
MatchQuery,
ScheduleItem,
} from '@modules/ad/core/application/queries/match/match.query';
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 { simpleMockGeorouter } from '../georouter.mock';
const originWaypoint: Waypoint = { const originWaypoint: Waypoint = {
position: 0, position: 0,
@@ -57,17 +61,10 @@ const mockInputDateTimeTransformer: DateTimeTransformerPort = {
time: jest.fn().mockImplementation(() => '23:05'), time: jest.fn().mockImplementation(() => '23:05'),
}; };
const mockRouteProvider: RouteProviderPort = { const mockRouteProvider: GeorouterPort = {
getBasic: jest getRoute: jest
.fn() .fn()
.mockImplementationOnce(() => ({ .mockImplementationOnce(simpleMockGeorouter.getRoute)
distance: 350101,
duration: 14422,
fwdAzimuth: 273,
backAzimuth: 93,
distanceAzimuth: 336544,
points: [],
}))
.mockImplementationOnce(() => ({ .mockImplementationOnce(() => ({
distance: 340102, distance: 340102,
duration: 13423, duration: 13423,
@@ -76,22 +73,8 @@ const mockRouteProvider: RouteProviderPort = {
distanceAzimuth: 336544, distanceAzimuth: 336544,
points: [], points: [],
})) }))
.mockImplementationOnce(() => ({ .mockImplementationOnce(simpleMockGeorouter.getRoute)
distance: 350101, .mockImplementationOnce(simpleMockGeorouter.getRoute)
duration: 14422,
fwdAzimuth: 273,
backAzimuth: 93,
distanceAzimuth: 336544,
points: [],
}))
.mockImplementationOnce(() => ({
distance: 350101,
duration: 14422,
fwdAzimuth: 273,
backAzimuth: 93,
distanceAzimuth: 336544,
points: [],
}))
.mockImplementationOnce(() => ({ .mockImplementationOnce(() => ({
distance: 340102, distance: 340102,
duration: 13423, duration: 13423,
@@ -103,7 +86,6 @@ const mockRouteProvider: RouteProviderPort = {
.mockImplementationOnce(() => { .mockImplementationOnce(() => {
throw new Error(); throw new Error();
}), }),
getDetailed: jest.fn(),
}; };
describe('Match Query', () => { describe('Match Query', () => {
@@ -156,9 +138,9 @@ describe('Match Query', () => {
expect(matchQuery.maxDetourDurationRatio).toBe(0.3); expect(matchQuery.maxDetourDurationRatio).toBe(0.3);
expect(matchQuery.fromDate).toBe('2023-08-27'); expect(matchQuery.fromDate).toBe('2023-08-27');
expect(matchQuery.toDate).toBe('2023-08-27'); expect(matchQuery.toDate).toBe('2023-08-27');
expect(matchQuery.schedule[0].day).toBe(0); expect((matchQuery.schedule as ScheduleItem[])[0].day).toBe(0);
expect(matchQuery.schedule[0].time).toBe('23:05'); expect((matchQuery.schedule as ScheduleItem[])[0].time).toBe('23:05');
expect(matchQuery.schedule[0].margin).toBe(900); expect((matchQuery.schedule as ScheduleItem[])[0].margin).toBe(900);
}); });
it('should set good values for seats', async () => { it('should set good values for seats', async () => {

View File

@@ -42,11 +42,10 @@ const matchQuery = new MatchQuery(
waypoints: [originWaypoint, destinationWaypoint], waypoints: [originWaypoint, destinationWaypoint],
}, },
{ {
getBasic: jest.fn().mockImplementation(() => ({ getRoute: jest.fn().mockImplementation(() => ({
duration: 6500, duration: 6500,
distance: 89745, distance: 89745,
})), })),
getDetailed: jest.fn(),
}, },
); );
@@ -54,6 +53,8 @@ const mockMatcherRepository: AdRepositoryPort = {
insertExtra: jest.fn(), insertExtra: jest.fn(),
findOneById: jest.fn(), findOneById: jest.fn(),
findOne: jest.fn(), findOne: jest.fn(),
findAll: jest.fn(),
findAllByIds: jest.fn(),
insert: jest.fn(), insert: jest.fn(),
update: jest.fn(), update: jest.fn(),
updateWhere: jest.fn(), updateWhere: jest.fn(),

View File

@@ -4,6 +4,7 @@ import { AlgorithmType } from '@modules/ad/core/application/types/algorithm.type
import { Waypoint } from '@modules/ad/core/application/types/waypoint.type'; 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 { bareMockGeorouter } from '../georouter.mock';
const originWaypoint: Waypoint = { const originWaypoint: Waypoint = {
position: 0, position: 0,
@@ -40,10 +41,7 @@ const matchQuery = new MatchQuery(
strict: false, strict: false,
waypoints: [originWaypoint, destinationWaypoint], waypoints: [originWaypoint, destinationWaypoint],
}, },
{ bareMockGeorouter,
getBasic: jest.fn(),
getDetailed: jest.fn(),
},
); );
const candidates: CandidateEntity[] = [ const candidates: CandidateEntity[] = [

View File

@@ -4,6 +4,7 @@ import { AlgorithmType } from '@modules/ad/core/application/types/algorithm.type
import { Waypoint } from '@modules/ad/core/application/types/waypoint.type'; 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 { bareMockGeorouter } from '../georouter.mock';
const originWaypoint: Waypoint = { const originWaypoint: Waypoint = {
position: 0, position: 0,
@@ -40,10 +41,7 @@ const matchQuery = new MatchQuery(
strict: false, strict: false,
waypoints: [originWaypoint, destinationWaypoint], waypoints: [originWaypoint, destinationWaypoint],
}, },
{ bareMockGeorouter,
getBasic: jest.fn(),
getDetailed: jest.fn(),
},
); );
const candidate: CandidateEntity = CandidateEntity.create({ const candidate: CandidateEntity = CandidateEntity.create({

View File

@@ -5,6 +5,7 @@ import { AlgorithmType } from '@modules/ad/core/application/types/algorithm.type
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 { CandidateEntity } from '@modules/ad/core/domain/candidate.entity'; import { CandidateEntity } from '@modules/ad/core/domain/candidate.entity';
import { bareMockGeorouter } from '../georouter.mock';
const originWaypoint: Waypoint = { const originWaypoint: Waypoint = {
position: 0, position: 0,
@@ -46,11 +47,9 @@ const matchQuery = new MatchQuery(
], ],
strict: false, strict: false,
waypoints: [originWaypoint, destinationWaypoint], waypoints: [originWaypoint, destinationWaypoint],
excludedAdId: '758618c6-dd82-4199-a548-0205161b04d7',
}, },
{ bareMockGeorouter,
getBasic: jest.fn(),
getDetailed: jest.fn(),
},
); );
matchQuery.driverRoute = { matchQuery.driverRoute = {
distance: 150120, distance: 150120,
@@ -99,6 +98,8 @@ const mockMatcherRepository: AdRepositoryPort = {
insertExtra: jest.fn(), insertExtra: jest.fn(),
findOneById: jest.fn(), findOneById: jest.fn(),
findOne: jest.fn(), findOne: jest.fn(),
findAll: jest.fn(),
findAllByIds: jest.fn(),
insert: jest.fn(), insert: jest.fn(),
update: jest.fn(), update: jest.fn(),
updateWhere: jest.fn(), updateWhere: jest.fn(),

View File

@@ -0,0 +1,57 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AD_MESSAGE_PUBLISHER } from '@modules/ad/ad.di-tokens';
import { MATCHER_AD_CREATED_ROUTING_KEY } from '@src/app.constants';
import { PublishMessageWhenMatcherAdIsCreatedDomainEventHandler } from '@modules/ad/core/application/event-handlers/publish-message-when-matcher-ad-is-created.domain-event-handler';
import { MatcherAdCreatedDomainEvent } from '@modules/ad/core/domain/events/matcher-ad-created.domain-event';
const mockMessagePublisher = {
publish: jest.fn().mockImplementation(),
};
describe('Publish message when matcher ad is created domain event handler', () => {
let publishMessageWhenMatcherAdIsCreatedDomainEventHandler: PublishMessageWhenMatcherAdIsCreatedDomainEventHandler;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
{
provide: AD_MESSAGE_PUBLISHER,
useValue: mockMessagePublisher,
},
PublishMessageWhenMatcherAdIsCreatedDomainEventHandler,
],
}).compile();
publishMessageWhenMatcherAdIsCreatedDomainEventHandler =
module.get<PublishMessageWhenMatcherAdIsCreatedDomainEventHandler>(
PublishMessageWhenMatcherAdIsCreatedDomainEventHandler,
);
});
it('should publish a message', () => {
jest.spyOn(mockMessagePublisher, 'publish');
const matcherAdCreatedDomainEvent: MatcherAdCreatedDomainEvent = {
id: 'some-domain-event-id',
aggregateId: 'some-aggregate-id',
metadata: {
timestamp: new Date('2023-06-28T05:00:00Z').getTime(),
correlationId: 'some-correlation-id',
},
driverDistance: 65845,
driverDuration: 3254,
fwdAzimuth: 90,
backAzimuth: 270,
};
publishMessageWhenMatcherAdIsCreatedDomainEventHandler.handle(
matcherAdCreatedDomainEvent,
);
expect(
publishMessageWhenMatcherAdIsCreatedDomainEventHandler,
).toBeDefined();
expect(mockMessagePublisher.publish).toHaveBeenCalledTimes(1);
expect(mockMessagePublisher.publish).toHaveBeenCalledWith(
MATCHER_AD_CREATED_ROUTING_KEY,
'{"id":"some-aggregate-id","metadata":{"correlationId":"some-correlation-id","timestamp":1687928400000},"driverDuration":3254,"driverDistance":65845,"fwdAzimuth":90,"backAzimuth":270}',
);
});
});

View File

@@ -1,3 +1,7 @@
import {
RouteRequest,
RouteResponse,
} from '@modules/ad/core/application/ports/georouter.port';
import { import {
RouteCompleter, RouteCompleter,
RouteCompleterType, RouteCompleterType,
@@ -9,6 +13,8 @@ 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 { 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 { simpleMockGeorouter } from '../georouter.mock';
const originWaypoint: Waypoint = { const originWaypoint: Waypoint = {
position: 0, position: 0,
@@ -46,23 +52,16 @@ const matchQuery = new MatchQuery(
waypoints: [originWaypoint, destinationWaypoint], waypoints: [originWaypoint, destinationWaypoint],
}, },
{ {
getBasic: jest.fn().mockImplementation(() => ({ getRoute: jest
distance: 350101, .fn()
duration: 14422, .mockImplementation(async (req: RouteRequest): Promise<RouteResponse> => {
fwdAzimuth: 273, const response = await simpleMockGeorouter.getRoute(req);
backAzimuth: 93, if (req.detailsSettings?.steps) {
distanceAzimuth: 336544, const step: Step = { lon: 0, lat: 0, duration: 0 };
points: [], response.steps = [step, step, step, step];
})), }
getDetailed: jest.fn().mockImplementation(() => ({ return response;
distance: 350101, }),
duration: 14422,
fwdAzimuth: 273,
backAzimuth: 93,
distanceAzimuth: 336544,
points: [],
steps: [jest.fn(), jest.fn(), jest.fn(), jest.fn()],
})),
}, },
); );

View File

@@ -0,0 +1,16 @@
import { GeorouterPort } from '@modules/ad/core/application/ports/georouter.port';
export const bareMockGeorouter: GeorouterPort = {
getRoute: jest.fn(),
};
export const simpleMockGeorouter: GeorouterPort = {
getRoute: jest.fn().mockImplementation(() => ({
distance: 350101,
duration: 14422,
fwdAzimuth: 273,
backAzimuth: 93,
distanceAzimuth: 336544,
points: [],
})),
};

View File

@@ -4,7 +4,6 @@ import {
AD_ROUTE_PROVIDER, AD_ROUTE_PROVIDER,
} from '@modules/ad/ad.di-tokens'; } from '@modules/ad/ad.di-tokens';
import { AdMapper } from '@modules/ad/ad.mapper'; import { AdMapper } from '@modules/ad/ad.mapper';
import { RouteProviderPort } from '@modules/ad/core/application/ports/route-provider.port';
import { AdEntity } from '@modules/ad/core/domain/ad.entity'; import { AdEntity } from '@modules/ad/core/domain/ad.entity';
import { 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';
@@ -12,6 +11,7 @@ import { PrismaService } from '@modules/ad/infrastructure/prisma.service';
import { DirectionEncoderPort } from '@modules/geography/core/application/ports/direction-encoder.port'; import { DirectionEncoderPort } from '@modules/geography/core/application/ports/direction-encoder.port';
import { EventEmitterModule } from '@nestjs/event-emitter'; import { EventEmitterModule } from '@nestjs/event-emitter';
import { Test, TestingModule } from '@nestjs/testing'; import { Test, TestingModule } from '@nestjs/testing';
import { bareMockGeorouter } from '../georouter.mock';
const mockMessagePublisher = { const mockMessagePublisher = {
publish: jest.fn().mockImplementation(), publish: jest.fn().mockImplementation(),
@@ -73,11 +73,6 @@ const mockDirectionEncoder: DirectionEncoderPort = {
]), ]),
}; };
const mockRouteProvider: RouteProviderPort = {
getBasic: jest.fn(),
getDetailed: jest.fn(),
};
const mockPrismaService = { const mockPrismaService = {
$queryRawUnsafe: jest $queryRawUnsafe: jest
.fn() .fn()
@@ -239,7 +234,7 @@ describe('Ad repository', () => {
}, },
{ {
provide: AD_ROUTE_PROVIDER, provide: AD_ROUTE_PROVIDER,
useValue: mockRouteProvider, useValue: bareMockGeorouter,
}, },
{ {
provide: AD_MESSAGE_PUBLISHER, provide: AD_MESSAGE_PUBLISHER,

View File

@@ -1,110 +0,0 @@
import {
AD_GET_BASIC_ROUTE_CONTROLLER,
AD_GET_DETAILED_ROUTE_CONTROLLER,
} from '@modules/ad/ad.di-tokens';
import { Point } from '@modules/ad/core/application/types/point.type';
import { Route } from '@modules/ad/core/application/types/route.type';
import { RouteProvider } from '@modules/ad/infrastructure/route-provider';
import { GetRouteControllerPort } from '@modules/geography/core/application/ports/get-route-controller.port';
import { Test, TestingModule } from '@nestjs/testing';
const originPoint: Point = {
lat: 48.689445,
lon: 6.17651,
};
const destinationPoint: Point = {
lat: 48.8566,
lon: 2.3522,
};
const mockGetBasicRouteController: GetRouteControllerPort = {
get: jest.fn().mockImplementationOnce(() => ({
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 mockGetDetailedRouteController: GetRouteControllerPort = {
get: jest.fn().mockImplementationOnce(() => ({
distance: 350102,
duration: 14423,
fwdAzimuth: 273,
backAzimuth: 93,
distanceAzimuth: 336544,
points: [
{
lon: 6.1765102,
lat: 48.689445,
},
{
lon: 4.984578,
lat: 48.725687,
},
{
lon: 2.3522,
lat: 48.8566,
},
],
})),
};
describe('Route provider', () => {
let routeProvider: RouteProvider;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
RouteProvider,
{
provide: AD_GET_BASIC_ROUTE_CONTROLLER,
useValue: mockGetBasicRouteController,
},
{
provide: AD_GET_DETAILED_ROUTE_CONTROLLER,
useValue: mockGetDetailedRouteController,
},
],
}).compile();
routeProvider = module.get<RouteProvider>(RouteProvider);
});
it('should be defined', () => {
expect(routeProvider).toBeDefined();
});
it('should provide a basic route', async () => {
const route: Route = await routeProvider.getBasic([
originPoint,
destinationPoint,
]);
expect(route.distance).toBe(350101);
expect(route.duration).toBe(14422);
});
it('should provide a detailed route', async () => {
const route: Route = await routeProvider.getDetailed([
originPoint,
destinationPoint,
]);
expect(route.distance).toBe(350102);
expect(route.duration).toBe(14423);
});
});

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

@@ -1,6 +1,4 @@
import { RpcExceptionCode } 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 { RouteProviderPort } from '@modules/ad/core/application/ports/route-provider.port';
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 { AlgorithmType } from '@modules/ad/core/application/types/algorithm.types'; import { AlgorithmType } from '@modules/ad/core/application/types/algorithm.types';
import { Frequency, Role } from '@modules/ad/core/domain/ad.types'; import { Frequency, Role } from '@modules/ad/core/domain/ad.types';
@@ -15,8 +13,8 @@ import { MatchGrpcController } from '@modules/ad/interface/grpc-controllers/matc
import { MatchMapper } from '@modules/ad/match.mapper'; import { MatchMapper } from '@modules/ad/match.mapper';
import { CACHE_MANAGER } from '@nestjs/cache-manager'; import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { QueryBus } from '@nestjs/cqrs'; import { QueryBus } from '@nestjs/cqrs';
import { RpcException } from '@nestjs/microservices';
import { Test, TestingModule } from '@nestjs/testing'; import { Test, TestingModule } from '@nestjs/testing';
import { bareMockGeorouter } from '../georouter.mock';
const originWaypoint: WaypointDto = { const originWaypoint: WaypointDto = {
position: 0, position: 0,
@@ -56,136 +54,126 @@ const recurrentMatchRequestDto: MatchRequestDto = {
}; };
const mockQueryBus = { const mockQueryBus = {
execute: jest execute: jest.fn().mockImplementationOnce(
.fn() () =>
.mockImplementationOnce( <MatchingResult>{
() => id: '43c83ae2-f4b0-4ac6-b8bf-8071801924d4',
<MatchingResult>{ page: 1,
id: '43c83ae2-f4b0-4ac6-b8bf-8071801924d4', perPage: 10,
page: 1, matches: [
perPage: 10, MatchEntity.create({
matches: [ adId: '53a0bf71-4132-4f7b-a4cc-88c796b6bdf1',
MatchEntity.create({ role: Role.DRIVER,
adId: '53a0bf71-4132-4f7b-a4cc-88c796b6bdf1', frequency: Frequency.RECURRENT,
role: Role.DRIVER, distance: 356041,
frequency: Frequency.RECURRENT, duration: 12647,
distance: 356041, initialDistance: 349251,
duration: 12647, initialDuration: 12103,
initialDistance: 349251, journeys: [
initialDuration: 12103, {
journeys: [ firstDate: new Date('2023-09-01'),
{ lastDate: new Date('2024-08-30'),
firstDate: new Date('2023-09-01'), journeyItems: [
lastDate: new Date('2024-08-30'), new JourneyItem({
journeyItems: [ lat: 48.689445,
new JourneyItem({ lon: 6.17651,
lat: 48.689445, duration: 0,
lon: 6.17651, distance: 0,
duration: 0, actorTimes: [
distance: 0, new ActorTime({
actorTimes: [ role: Role.DRIVER,
new ActorTime({ target: Target.START,
role: Role.DRIVER, firstDatetime: new Date('2023-09-01 07:00'),
target: Target.START, firstMinDatetime: new Date('2023-09-01 06:45'),
firstDatetime: new Date('2023-09-01 07:00'), firstMaxDatetime: new Date('2023-09-01 07:15'),
firstMinDatetime: new Date('2023-09-01 06:45'), lastDatetime: new Date('2024-08-30 07:00'),
firstMaxDatetime: new Date('2023-09-01 07:15'), lastMinDatetime: new Date('2024-08-30 06:45'),
lastDatetime: new Date('2024-08-30 07:00'), lastMaxDatetime: new Date('2024-08-30 07:15'),
lastMinDatetime: new Date('2024-08-30 06:45'), }),
lastMaxDatetime: new Date('2024-08-30 07:15'), ],
}), }),
], new JourneyItem({
}), lat: 48.369445,
new JourneyItem({ lon: 6.67487,
lat: 48.369445, duration: 2100,
lon: 6.67487, distance: 56878,
duration: 2100, actorTimes: [
distance: 56878, new ActorTime({
actorTimes: [ role: Role.DRIVER,
new ActorTime({ target: Target.NEUTRAL,
role: Role.DRIVER, firstDatetime: new Date('2023-09-01 07:35'),
target: Target.NEUTRAL, firstMinDatetime: new Date('2023-09-01 07:20'),
firstDatetime: new Date('2023-09-01 07:35'), firstMaxDatetime: new Date('2023-09-01 07:50'),
firstMinDatetime: new Date('2023-09-01 07:20'), lastDatetime: new Date('2024-08-30 07:35'),
firstMaxDatetime: new Date('2023-09-01 07:50'), lastMinDatetime: new Date('2024-08-30 07:20'),
lastDatetime: new Date('2024-08-30 07:35'), lastMaxDatetime: new Date('2024-08-30 07:50'),
lastMinDatetime: new Date('2024-08-30 07:20'), }),
lastMaxDatetime: new Date('2024-08-30 07:50'), new ActorTime({
}), role: Role.PASSENGER,
new ActorTime({ target: Target.START,
role: Role.PASSENGER, firstDatetime: new Date('2023-09-01 07:32'),
target: Target.START, firstMinDatetime: new Date('2023-09-01 07:17'),
firstDatetime: new Date('2023-09-01 07:32'), firstMaxDatetime: new Date('2023-09-01 07:47'),
firstMinDatetime: new Date('2023-09-01 07:17'), lastDatetime: new Date('2024-08-30 07:32'),
firstMaxDatetime: new Date('2023-09-01 07:47'), lastMinDatetime: new Date('2024-08-30 07:17'),
lastDatetime: new Date('2024-08-30 07:32'), lastMaxDatetime: new Date('2024-08-30 07:47'),
lastMinDatetime: new Date('2024-08-30 07:17'), }),
lastMaxDatetime: new Date('2024-08-30 07:47'), ],
}), }),
], new JourneyItem({
}), lat: 47.98487,
new JourneyItem({ lon: 6.9427,
lat: 47.98487, duration: 3840,
lon: 6.9427, distance: 76491,
duration: 3840, actorTimes: [
distance: 76491, new ActorTime({
actorTimes: [ role: Role.DRIVER,
new ActorTime({ target: Target.NEUTRAL,
role: Role.DRIVER, firstDatetime: new Date('2023-09-01 08:04'),
target: Target.NEUTRAL, firstMinDatetime: new Date('2023-09-01 07:51'),
firstDatetime: new Date('2023-09-01 08:04'), firstMaxDatetime: new Date('2023-09-01 08:19'),
firstMinDatetime: new Date('2023-09-01 07:51'), lastDatetime: new Date('2024-08-30 08:04'),
firstMaxDatetime: new Date('2023-09-01 08:19'), lastMinDatetime: new Date('2024-08-30 07:51'),
lastDatetime: new Date('2024-08-30 08:04'), lastMaxDatetime: new Date('2024-08-30 08:19'),
lastMinDatetime: new Date('2024-08-30 07:51'), }),
lastMaxDatetime: new Date('2024-08-30 08:19'), new ActorTime({
}), role: Role.PASSENGER,
new ActorTime({ target: Target.FINISH,
role: Role.PASSENGER, firstDatetime: new Date('2023-09-01 08:01'),
target: Target.FINISH, firstMinDatetime: new Date('2023-09-01 07:46'),
firstDatetime: new Date('2023-09-01 08:01'), firstMaxDatetime: new Date('2023-09-01 08:16'),
firstMinDatetime: new Date('2023-09-01 07:46'), lastDatetime: new Date('2024-08-30 08:01'),
firstMaxDatetime: new Date('2023-09-01 08:16'), lastMinDatetime: new Date('2024-08-30 07:46'),
lastDatetime: new Date('2024-08-30 08:01'), lastMaxDatetime: new Date('2024-08-30 08:16'),
lastMinDatetime: new Date('2024-08-30 07:46'), }),
lastMaxDatetime: new Date('2024-08-30 08:16'), ],
}), }),
], new JourneyItem({
}), lat: 47.365987,
new JourneyItem({ lon: 7.02154,
lat: 47.365987, duration: 4980,
lon: 7.02154, distance: 96475,
duration: 4980, actorTimes: [
distance: 96475, new ActorTime({
actorTimes: [ role: Role.DRIVER,
new ActorTime({ target: Target.FINISH,
role: Role.DRIVER, firstDatetime: new Date('2023-09-01 08:23'),
target: Target.FINISH, firstMinDatetime: new Date('2023-09-01 08:08'),
firstDatetime: new Date('2023-09-01 08:23'), firstMaxDatetime: new Date('2023-09-01 08:38'),
firstMinDatetime: new Date('2023-09-01 08:08'), lastDatetime: new Date('2024-08-30 08:23'),
firstMaxDatetime: new Date('2023-09-01 08:38'), lastMinDatetime: new Date('2024-08-30 08:08'),
lastDatetime: new Date('2024-08-30 08:23'), lastMaxDatetime: new Date('2024-08-30 08:38'),
lastMinDatetime: new Date('2024-08-30 08:08'), }),
lastMaxDatetime: new Date('2024-08-30 08:38'), ],
}), }),
], ],
}), },
], ],
}, }),
], ],
}), total: 1,
], },
total: 1, ),
},
)
.mockImplementationOnce(() => {
throw new Error();
}),
};
const mockRouteProvider: RouteProviderPort = {
getBasic: jest.fn(),
getDetailed: jest.fn(),
}; };
const mockMatchMapper = { const mockMatchMapper = {
@@ -195,8 +183,15 @@ const mockMatchMapper = {
frequency: 'RECURRENT', frequency: 'RECURRENT',
distance: 356041, distance: 356041,
duration: 12647, duration: 12647,
initialDistance: 349251,
initialDuration: 12103,
distanceDetour: 6790,
durationDetour: 544,
distanceDetourPercentage: 4.1,
durationDetourPercentage: 3.8,
journeys: [ journeys: [
{ {
day: 5,
firstDate: '2023-09-01', firstDate: '2023-09-01',
lastDate: '2024-08-30', lastDate: '2024-08-30',
journeyItems: [ journeyItems: [
@@ -205,16 +200,11 @@ const mockMatchMapper = {
lon: 6.17651, lon: 6.17651,
duration: 0, duration: 0,
distance: 0, distance: 0,
time: '07:00',
actorTimes: [ actorTimes: [
{ {
role: 'DRIVER', role: 'DRIVER',
target: 'START', target: 'START',
firstDatetime: '2023-09-01 07:00',
firstMinDatetime: '2023-09-01 06:45',
firstMaxDatetime: '2023-09-01 07:15',
lastDatetime: '2024-08-30 07:00',
lastMinDatetime: '2024-08-30 06:45',
lastMaxDatetime: '2024-08-30 07:15',
}, },
], ],
}, },
@@ -223,26 +213,15 @@ const mockMatchMapper = {
lon: 6.67487, lon: 6.67487,
duration: 2100, duration: 2100,
distance: 56878, distance: 56878,
time: '07:35',
actorTimes: [ actorTimes: [
{ {
role: 'DRIVER', role: 'DRIVER',
target: 'NEUTRAL', target: 'NEUTRAL',
firstDatetime: '2023-09-01 07:35',
firstMinDatetime: '2023-09-01 07:20',
firstMaxDatetime: '2023-09-01 07:50',
lastDatetime: '2024-08-30 07:35',
lastMinDatetime: '2024-08-30 07:20',
lastMaxDatetime: '2024-08-30 07:50',
}, },
{ {
role: 'PASSENGER', role: 'PASSENGER',
target: 'START', target: 'START',
firstDatetime: '2023-09-01 07:32',
firstMinDatetime: '2023-09-01 07:17',
firstMaxDatetime: '2023-09-01 07:47',
lastDatetime: '2024-08-30 07:32',
lastMinDatetime: '2024-08-30 07:17',
lastMaxDatetime: '2024-08-30 07:47',
}, },
], ],
}, },
@@ -251,26 +230,15 @@ const mockMatchMapper = {
lon: 6.9427, lon: 6.9427,
duration: 3840, duration: 3840,
distance: 76491, distance: 76491,
time: '08:04',
actorTimes: [ actorTimes: [
{ {
role: 'DRIVER', role: 'DRIVER',
target: 'NEUTRAL', target: 'NEUTRAL',
firstDatetime: '2023-09-01 08:04',
firstMinDatetime: '2023-09-01 07:51',
firstMaxDatetime: '2023-09-01 08:19',
lastDatetime: '2024-08-30 08:04',
lastMinDatetime: '2024-08-30 07:51',
lastMaxDatetime: '2024-08-30 08:19',
}, },
{ {
role: 'PASSENGER', role: 'PASSENGER',
target: 'FINISH', target: 'FINISH',
firstDatetime: '2023-09-01 08:01',
firstMinDatetime: '2023-09-01 07:46',
firstMaxDatetime: '2023-09-01 08:16',
lastDatetime: '2024-08-30 08:01',
lastMinDatetime: '2024-08-30 07:46',
lastMaxDatetime: '2024-08-30 08:16',
}, },
], ],
}, },
@@ -279,16 +247,11 @@ const mockMatchMapper = {
lon: 7.02154, lon: 7.02154,
duration: 4980, duration: 4980,
distance: 96475, distance: 96475,
time: '08:23',
actorTimes: [ actorTimes: [
{ {
role: 'DRIVER', role: 'DRIVER',
target: 'FINISH', target: 'FINISH',
firstDatetime: '2023-09-01 08:23',
firstMinDatetime: '2023-09-01 08:08',
firstMaxDatetime: '2023-09-01 08:38',
lastDatetime: '2024-08-30 08:23',
lastMinDatetime: '2024-08-30 08:08',
lastMaxDatetime: '2024-08-30 08:38',
}, },
], ],
}, },
@@ -311,7 +274,7 @@ describe('Match Grpc Controller', () => {
}, },
{ {
provide: AD_ROUTE_PROVIDER, provide: AD_ROUTE_PROVIDER,
useValue: mockRouteProvider, useValue: bareMockGeorouter,
}, },
{ {
provide: MatchMapper, provide: MatchMapper,
@@ -347,16 +310,4 @@ describe('Match Grpc Controller', () => {
expect(matchingPaginatedResponseDto.perPage).toBe(10); expect(matchingPaginatedResponseDto.perPage).toBe(10);
expect(mockQueryBus.execute).toHaveBeenCalledTimes(1); expect(mockQueryBus.execute).toHaveBeenCalledTimes(1);
}); });
it('should throw a generic RpcException', async () => {
jest.spyOn(mockQueryBus, 'execute');
expect.assertions(3);
try {
await matchGrpcController.match(recurrentMatchRequestDto);
} catch (e: any) {
expect(e).toBeInstanceOf(RpcException);
expect(e.error.code).toBe(RpcExceptionCode.UNKNOWN);
}
expect(mockQueryBus.execute).toHaveBeenCalledTimes(1);
});
}); });

View File

@@ -1,13 +0,0 @@
export interface GeodesicPort {
inverse(
lon1: number,
lat1: number,
lon2: number,
lat2: number,
): {
azimuth: number;
distance: number;
};
distance(lon1: number, lat1: number, lon2: number, lat2: number): number;
azimuth(lon1: number, lat1: number, lon2: number, lat2: number): number;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,33 +0,0 @@
import { AggregateRoot, AggregateID } from '@mobicoop/ddd-library';
import { CreateRouteProps, RouteProps, Route } from './route.types';
import { v4 } from 'uuid';
import { RouteNotFoundException } from './route.errors';
export class RouteEntity extends AggregateRoot<RouteProps> {
protected readonly _id: AggregateID;
static create = async (create: CreateRouteProps): Promise<RouteEntity> => {
const route: Route = await create.georouter.route(
create.waypoints,
create.georouterSettings,
);
if (!route) throw new RouteNotFoundException();
const routeProps: RouteProps = {
distance: route.distance,
duration: route.duration,
fwdAzimuth: route.fwdAzimuth,
backAzimuth: route.backAzimuth,
distanceAzimuth: route.distanceAzimuth,
points: route.points,
steps: route.steps,
};
return new RouteEntity({
id: v4(),
props: routeProps,
});
};
validate(): void {
// entity business rules validation to protect it's invariant before saving entity to a database
}
}

View File

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

View File

@@ -1,26 +1,3 @@
import { GeorouterPort } from '../application/ports/georouter.port';
import { GeorouterSettings } from '../application/types/georouter-settings.type';
import { PointProps } from './value-objects/point.value-object';
import { StepProps } from './value-objects/step.value-object';
// All properties that a Route has
export interface RouteProps {
distance: number;
duration: number;
fwdAzimuth: number;
backAzimuth: number;
distanceAzimuth: number;
points: PointProps[];
steps?: StepProps[];
}
// Properties that are needed for a Route creation
export interface CreateRouteProps {
waypoints: PointProps[];
georouter: GeorouterPort;
georouterSettings: GeorouterSettings;
}
// Types used outside the domain // Types used outside the domain
export type Route = { export type Route = {
distance: number; distance: number;

View File

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

View File

@@ -1,46 +0,0 @@
import { ArgumentInvalidException, ValueObject } from '@mobicoop/ddd-library';
import { Point, PointProps } from './point.value-object';
/** Note:
* Value Objects with multiple properties can contain
* other Value Objects inside if needed.
* */
export interface StepProps extends PointProps {
duration: number;
distance?: number;
}
export class Step extends ValueObject<StepProps> {
get duration(): number {
return this.props.duration;
}
get distance(): number | undefined {
return this.props.distance;
}
get lon(): number {
return this.props.lon;
}
get lat(): number {
return this.props.lat;
}
protected validate(props: StepProps): void {
// validate point props
new Point({
lon: props.lon,
lat: props.lat,
});
if (props.duration < 0)
throw new ArgumentInvalidException(
'duration must be greater than or equal to 0',
);
if (props.distance !== undefined && props.distance < 0)
throw new ArgumentInvalidException(
'distance must be greater than or equal to 0',
);
}
}

View File

@@ -1,18 +0,0 @@
import {
ConfigurationDomainGet,
ConfigurationType,
} from '@mobicoop/configuration-module';
export const GEOGRAPHY_CONFIG_GEOROUTER_TYPE = 'georouterType';
export const GEOGRAPHY_CONFIG_GEOROUTER_URL = 'georouterUrl';
export const GeographyConfig: ConfigurationDomainGet[] = [
{
key: GEOGRAPHY_CONFIG_GEOROUTER_TYPE,
type: ConfigurationType.STRING,
},
{
key: GEOGRAPHY_CONFIG_GEOROUTER_URL,
type: ConfigurationType.STRING,
},
];

View File

@@ -1,6 +1,4 @@
export const DIRECTION_ENCODER = Symbol('DIRECTION_ENCODER'); export const DIRECTION_ENCODER = Symbol('DIRECTION_ENCODER');
export const GEOROUTER = Symbol('GEOROUTER');
export const GEODESIC = Symbol('GEODESIC');
export const GEOGRAPHY_CONFIGURATION_REPOSITORY = Symbol( export const GEOGRAPHY_CONFIGURATION_REPOSITORY = Symbol(
'GEOGRAPHY_CONFIGURATION_REPOSITORY', 'GEOGRAPHY_CONFIGURATION_REPOSITORY',
); );

View File

@@ -2,24 +2,12 @@ import { Module, Provider } from '@nestjs/common';
import { CqrsModule } from '@nestjs/cqrs'; import { CqrsModule } from '@nestjs/cqrs';
import { import {
DIRECTION_ENCODER, DIRECTION_ENCODER,
GEODESIC,
GEOGRAPHY_CONFIGURATION_REPOSITORY, GEOGRAPHY_CONFIGURATION_REPOSITORY,
GEOROUTER,
} from './geography.di-tokens'; } from './geography.di-tokens';
import { PostgresDirectionEncoder } from './infrastructure/postgres-direction-encoder'; import { PostgresDirectionEncoder } from './infrastructure/postgres-direction-encoder';
import { GetBasicRouteController } from './interface/controllers/get-basic-route.controller';
import { RouteMapper } from './route.mapper';
import { Geodesic } from './infrastructure/geodesic';
import { GraphhopperGeorouter } from './infrastructure/graphhopper-georouter';
import { HttpModule } from '@nestjs/axios'; import { HttpModule } from '@nestjs/axios';
import { GetRouteQueryHandler } from './core/application/queries/get-route/get-route.query-handler';
import { GetDetailedRouteController } from './interface/controllers/get-detailed-route.controller';
import { ConfigurationRepository } from '@mobicoop/configuration-module'; import { ConfigurationRepository } from '@mobicoop/configuration-module';
const queryHandlers: Provider[] = [GetRouteQueryHandler];
const mappers: Provider[] = [RouteMapper];
const adapters: Provider[] = [ const adapters: Provider[] = [
{ {
provide: GEOGRAPHY_CONFIGURATION_REPOSITORY, provide: GEOGRAPHY_CONFIGURATION_REPOSITORY,
@@ -29,26 +17,11 @@ const adapters: Provider[] = [
provide: DIRECTION_ENCODER, provide: DIRECTION_ENCODER,
useClass: PostgresDirectionEncoder, useClass: PostgresDirectionEncoder,
}, },
{
provide: GEOROUTER,
useClass: GraphhopperGeorouter,
},
{
provide: GEODESIC,
useClass: Geodesic,
},
GetBasicRouteController,
GetDetailedRouteController,
]; ];
@Module({ @Module({
imports: [CqrsModule, HttpModule], imports: [CqrsModule, HttpModule],
providers: [...queryHandlers, ...mappers, ...adapters], providers: [...adapters],
exports: [ exports: [DIRECTION_ENCODER],
RouteMapper,
DIRECTION_ENCODER,
GetBasicRouteController,
GetDetailedRouteController,
],
}) })
export class GeographyModule {} export class GeographyModule {}

View File

@@ -1,59 +0,0 @@
import { Injectable } from '@nestjs/common';
import { Geodesic as Geolib, GeodesicClass } from 'geographiclib-geodesic';
import { GeodesicPort } from '../core/application/ports/geodesic.port';
@Injectable()
export class Geodesic implements GeodesicPort {
private geod: GeodesicClass;
constructor() {
this.geod = Geolib.WGS84;
}
inverse = (
lon1: number,
lat1: number,
lon2: number,
lat2: number,
): { azimuth: number; distance: number } => {
const { azi2: azimuth, s12: distance } = this.geod.Inverse(
lat1,
lon1,
lat2,
lon2,
);
if (!azimuth || !distance)
throw new Error(
`Inverse not found for coordinates ${lon1} ${lat1} / ${lon2} ${lat2}`,
);
return { azimuth, distance };
};
azimuth = (
lon1: number,
lat1: number,
lon2: number,
lat2: number,
): number => {
const { azi2: azimuth } = this.geod.Inverse(lat1, lon1, lat2, lon2);
if (!azimuth)
throw new Error(
`Azimuth not found for coordinates ${lon1} ${lat1} / ${lon2} ${lat2}`,
);
return azimuth;
};
distance = (
lon1: number,
lat1: number,
lon2: number,
lat2: number,
): number => {
const { s12: distance } = this.geod.Inverse(lat1, lon1, lat2, lon2);
if (!distance)
throw new Error(
`Distance not found for coordinates ${lon1} ${lat1} / ${lon2} ${lat2}`,
);
return distance;
};
}

View File

@@ -1,342 +0,0 @@
import { Inject, Injectable } from '@nestjs/common';
import { HttpService } from '@nestjs/axios';
import { GeorouterPort } from '../core/application/ports/georouter.port';
import { GeorouterSettings } from '../core/application/types/georouter-settings.type';
import { Route, Step, Point } from '../core/domain/route.types';
import {
GEODESIC,
GEOGRAPHY_CONFIGURATION_REPOSITORY,
} from '../geography.di-tokens';
import { catchError, lastValueFrom, map } from 'rxjs';
import { AxiosError, AxiosResponse } from 'axios';
import {
GeorouterUnavailableException,
RouteNotFoundException,
} from '../core/domain/route.errors';
import { GeodesicPort } from '../core/application/ports/geodesic.port';
import {
ConfigurationDomain,
Configurator,
GetConfigurationRepositoryPort,
} from '@mobicoop/configuration-module';
import {
GEOGRAPHY_CONFIG_GEOROUTER_URL,
GeographyConfig,
} from '../geography.constants';
@Injectable()
export class GraphhopperGeorouter implements GeorouterPort {
private url: string;
private urlArgs: string[];
constructor(
private readonly httpService: HttpService,
@Inject(GEOGRAPHY_CONFIGURATION_REPOSITORY)
private readonly configurationRepository: GetConfigurationRepositoryPort,
@Inject(GEODESIC) private readonly geodesic: GeodesicPort,
) {}
route = async (
waypoints: Point[],
settings: GeorouterSettings,
): Promise<Route> => {
const geographyConfigurator: Configurator =
await this.configurationRepository.mget(
ConfigurationDomain.GEOGRAPHY,
GeographyConfig,
);
this.url = [
geographyConfigurator.get<string>(GEOGRAPHY_CONFIG_GEOROUTER_URL),
'/route?',
].join('');
this._setDefaultUrlArgs();
this._setSettings(settings);
return this._getRoute(waypoints);
};
private _setDefaultUrlArgs = (): void => {
this.urlArgs = ['vehicle=car', 'weighting=fastest', 'points_encoded=false'];
};
private _setSettings = (settings: GeorouterSettings): void => {
if (settings.detailedDuration) {
this.urlArgs.push('details=time');
}
if (settings.detailedDistance) {
this.urlArgs.push('instructions=true');
} else {
this.urlArgs.push('instructions=false');
}
if (!settings.points) {
this.urlArgs.push('calc_points=false');
}
};
private _getRoute = async (waypoints: Point[]): Promise<Route> => {
const url: string = [
this.getUrl(),
'&point=',
waypoints
.map((point: Point) => [point.lat, point.lon].join('%2C'))
.join('&point='),
].join('');
return await lastValueFrom(
this.httpService.get(url).pipe(
map((response) => {
if (response.data) return this.createRoute(response);
throw new Error();
}),
catchError((error: AxiosError) => {
if (error.code == AxiosError.ERR_BAD_REQUEST) {
throw new RouteNotFoundException(
error,
'No route found for given coordinates',
);
}
throw new GeorouterUnavailableException(error);
}),
),
);
};
private getUrl = (): string => [this.url, this.urlArgs.join('&')].join('');
private createRoute = (
response: AxiosResponse<GraphhopperResponse>,
): Route => {
const route = {} as Route;
if (response.data.paths && response.data.paths[0]) {
const shortestPath = response.data.paths[0];
route.distance = shortestPath.distance ?? 0;
route.duration = shortestPath.time ? shortestPath.time / 1000 : 0;
if (shortestPath.points && shortestPath.points.coordinates) {
route.points = shortestPath.points.coordinates.map((coordinate) => ({
lon: coordinate[0],
lat: coordinate[1],
}));
const inverse = this.geodesic.inverse(
route.points[0].lon,
route.points[0].lat,
route.points[route.points.length - 1].lon,
route.points[route.points.length - 1].lat,
);
route.fwdAzimuth =
inverse.azimuth >= 0
? inverse.azimuth
: 360 - Math.abs(inverse.azimuth);
route.backAzimuth =
route.fwdAzimuth > 180
? route.fwdAzimuth - 180
: route.fwdAzimuth + 180;
route.distanceAzimuth = inverse.distance;
if (
shortestPath.details &&
shortestPath.details.time &&
shortestPath.snapped_waypoints &&
shortestPath.snapped_waypoints.coordinates
) {
let instructions: GraphhopperInstruction[] = [];
if (shortestPath.instructions)
instructions = shortestPath.instructions;
route.steps = this.generateSteps(
shortestPath.points.coordinates,
shortestPath.snapped_waypoints.coordinates,
shortestPath.details.time,
instructions,
);
}
}
}
return route;
};
private generateSteps = (
points: [[number, number]],
snappedWaypoints: [[number, number]],
durations: [[number, number, number]],
instructions: GraphhopperInstruction[],
): Step[] => {
const indices = this.getIndices(points, snappedWaypoints);
const times = this.getTimes(durations, indices);
const distances = this.getDistances(instructions, indices);
return indices.map((index) => {
const duration = times.find((time) => time.index == index);
if (!duration)
throw new Error(`Duration not found for waypoint #${index}`);
const distance = distances.find((distance) => distance.index == index);
if (!distance && instructions.length > 0)
throw new Error(`Distance not found for waypoint #${index}`);
return {
lon: points[index][1],
lat: points[index][0],
distance: distance?.distance,
duration: duration.duration,
};
});
};
private getIndices = (
points: [[number, number]],
snappedWaypoints: [[number, number]],
): number[] => {
const indices: number[] = snappedWaypoints.map(
(waypoint: [number, number]) =>
points.findIndex(
(point) => point[0] == waypoint[0] && point[1] == waypoint[1],
),
);
if (indices.find((index: number) => index == -1) === undefined)
return indices;
const missedWaypoints = indices
.map(
(value, index) =>
<
{
index: number;
originIndex: number;
waypoint: number[];
nearest?: number;
distance: number;
}
>{
index: value,
originIndex: index,
waypoint: snappedWaypoints[index],
nearest: undefined,
distance: 999999999,
},
)
.filter((element) => element.index == -1);
for (const index in points) {
for (const missedWaypoint of missedWaypoints) {
const distance = this.geodesic.distance(
missedWaypoint.waypoint[0],
missedWaypoint.waypoint[1],
points[index][0],
points[index][1],
);
if (distance < missedWaypoint.distance) {
missedWaypoint.distance = distance;
missedWaypoint.nearest = parseInt(index);
}
}
}
for (const missedWaypoint of missedWaypoints) {
indices[missedWaypoint.originIndex] = missedWaypoint.nearest as number;
}
return indices;
};
private getTimes = (
durations: [[number, number, number]],
indices: number[],
): Array<{ index: number; duration: number }> => {
const times: Array<{ index: number; duration: number }> = [];
let duration = 0;
for (const [origin, destination, stepDuration] of durations) {
let indexFound = false;
const indexAsOrigin = indices.find((index) => index == origin);
if (
indexAsOrigin !== undefined &&
times.find((time) => origin == time.index) == undefined
) {
times.push({
index: indexAsOrigin,
duration: Math.round(stepDuration / 1000),
});
indexFound = true;
}
if (!indexFound) {
const indexAsDestination = indices.find(
(index) => index == destination,
);
if (
indexAsDestination !== undefined &&
times.find((time) => destination == time.index) == undefined
) {
times.push({
index: indexAsDestination,
duration: Math.round((duration + stepDuration) / 1000),
});
indexFound = true;
}
}
if (!indexFound) {
const indexInBetween = indices.find(
(index) => origin < index && index < destination,
);
if (indexInBetween !== undefined) {
times.push({
index: indexInBetween,
duration: Math.round((duration + stepDuration / 2) / 1000),
});
}
}
duration += stepDuration;
}
return times;
};
private getDistances = (
instructions: GraphhopperInstruction[],
indices: number[],
): Array<{ index: number; distance: number }> => {
let distance = 0;
const distances: Array<{ index: number; distance: number }> = [
{
index: 0,
distance,
},
];
for (const instruction of instructions) {
distance += instruction.distance;
if (
(instruction.sign == GraphhopperSign.SIGN_WAYPOINT ||
instruction.sign == GraphhopperSign.SIGN_FINISH) &&
indices.find((index) => index == instruction.interval[0]) !== undefined
) {
distances.push({
index: instruction.interval[0],
distance: Math.round(distance),
});
}
}
return distances;
};
}
type GraphhopperResponse = {
paths: [
{
distance: number;
weight: number;
time: number;
points_encoded: boolean;
bbox: number[];
points: GraphhopperCoordinates;
snapped_waypoints: GraphhopperCoordinates;
details: {
time: [[number, number, number]];
};
instructions: GraphhopperInstruction[];
},
];
};
type GraphhopperCoordinates = {
coordinates: [[number, number]];
};
type GraphhopperInstruction = {
distance: number;
heading: number;
sign: GraphhopperSign;
interval: [number, number];
text: string;
};
enum GraphhopperSign {
SIGN_START = 0,
SIGN_FINISH = 4,
SIGN_WAYPOINT = 5,
}

View File

@@ -1,5 +0,0 @@
import { Point } from '@modules/geography/core/domain/route.types';
export type GetRouteRequestDto = {
waypoints: Point[];
};

View File

@@ -1,23 +0,0 @@
import { QueryBus } from '@nestjs/cqrs';
import { RouteResponseDto } from '../dtos/route.response.dto';
import { GetRouteRequestDto } from './dtos/get-route.request.dto';
import { RouteEntity } from '@modules/geography/core/domain/route.entity';
import { GetRouteQuery } from '@modules/geography/core/application/queries/get-route/get-route.query';
import { RouteMapper } from '@modules/geography/route.mapper';
import { Controller } from '@nestjs/common';
import { GetRouteControllerPort } from '@modules/geography/core/application/ports/get-route-controller.port';
@Controller()
export class GetBasicRouteController implements GetRouteControllerPort {
constructor(
private readonly queryBus: QueryBus,
private readonly mapper: RouteMapper,
) {}
async get(data: GetRouteRequestDto): Promise<RouteResponseDto> {
const route: RouteEntity = await this.queryBus.execute(
new GetRouteQuery(data.waypoints),
);
return this.mapper.toResponse(route);
}
}

View File

@@ -1,27 +0,0 @@
import { QueryBus } from '@nestjs/cqrs';
import { RouteResponseDto } from '../dtos/route.response.dto';
import { GetRouteRequestDto } from './dtos/get-route.request.dto';
import { RouteEntity } from '@modules/geography/core/domain/route.entity';
import { GetRouteQuery } from '@modules/geography/core/application/queries/get-route/get-route.query';
import { RouteMapper } from '@modules/geography/route.mapper';
import { Controller } from '@nestjs/common';
import { GetRouteControllerPort } from '@modules/geography/core/application/ports/get-route-controller.port';
@Controller()
export class GetDetailedRouteController implements GetRouteControllerPort {
constructor(
private readonly queryBus: QueryBus,
private readonly mapper: RouteMapper,
) {}
async get(data: GetRouteRequestDto): Promise<RouteResponseDto> {
const route: RouteEntity = await this.queryBus.execute(
new GetRouteQuery(data.waypoints, {
detailedDistance: true,
detailedDuration: true,
points: true,
}),
);
return this.mapper.toResponse(route);
}
}

View File

@@ -1,11 +0,0 @@
import { Point, Step } from '@modules/geography/core/domain/route.types';
export class RouteResponseDto {
distance: number;
duration: number;
fwdAzimuth: number;
backAzimuth: number;
distanceAzimuth: number;
points: Point[];
steps?: Step[];
}

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