send messages when matcher ad is created, or when matcher ad creation has failed

This commit is contained in:
Sylvain Briat 2023-12-06 15:16:29 +01:00
parent 73f660bf6d
commit 80fac59c43
14 changed files with 309 additions and 96 deletions

View File

@ -14,13 +14,9 @@ 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';
export const MATCHER_AD_CREATED_ROUTING_KEY = 'matcher.ad.created';
// configuration export const MATCHER_AD_CREATION_FAILED_ROUTING_KEY =
export const SERVICE_CONFIGURATION_SET_QUEUE = 'matcher-configuration-set'; 'matcher.ad.creation.failed';
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

@ -43,6 +43,7 @@ import {
RedisModuleOptions, RedisModuleOptions,
} from '@songkeys/nestjs-redis'; } from '@songkeys/nestjs-redis';
import { ConfigurationRepository } from '@mobicoop/configuration-module'; import { ConfigurationRepository } from '@mobicoop/configuration-module';
import { PublishMessageWhenMatcherAdIsCreatedDomainEventHandler } from './core/application/event-handlers/publish-message-when-matcher-ad-is-created.domain-event-handler';
const imports = [ const imports = [
CqrsModule, CqrsModule,
@ -80,6 +81,10 @@ const grpcControllers = [MatchGrpcController];
const messageHandlers = [AdCreatedMessageHandler]; const messageHandlers = [AdCreatedMessageHandler];
const eventHandlers: Provider[] = [
PublishMessageWhenMatcherAdIsCreatedDomainEventHandler,
];
const commandHandlers: Provider[] = [CreateAdService]; const commandHandlers: Provider[] = [CreateAdService];
const queryHandlers: Provider[] = [MatchQueryHandler]; const queryHandlers: Provider[] = [MatchQueryHandler];
@ -150,6 +155,7 @@ const adapters: Provider[] = [
controllers: [...grpcControllers], controllers: [...grpcControllers],
providers: [ providers: [
...messageHandlers, ...messageHandlers,
...eventHandlers,
...commandHandlers, ...commandHandlers,
...queryHandlers, ...queryHandlers,
...mappers, ...mappers,

View File

@ -1,10 +1,18 @@
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 { RouteProviderPort } from '../../ports/route-provider.port';
import { Role } from '@modules/ad/core/domain/ad.types'; import { Role } from '@modules/ad/core/domain/ad.types';
@ -17,10 +25,14 @@ 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';
@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)
@ -44,6 +56,15 @@ export class CreateAdService implements ICommandHandler {
); );
let typedRoutes: TypedRoute[]; let typedRoutes: TypedRoute[];
let driverDistance: number | undefined;
let driverDuration: number | undefined;
let passengerDistance: number | undefined;
let passengerDuration: number | undefined;
let points: PointValueObject[] | undefined;
let fwdAzimuth: number | undefined;
let backAzimuth: number | undefined;
try {
try { try {
typedRoutes = await Promise.all( typedRoutes = await Promise.all(
pathCreator.getBasePaths().map(async (path: Path) => ({ pathCreator.getBasePaths().map(async (path: Path) => ({
@ -55,13 +76,6 @@ export class CreateAdService implements ICommandHandler {
throw new Error('Unable to find a route for given waypoints'); throw new Error('Unable to find a route for given waypoints');
} }
let driverDistance: number | undefined;
let driverDuration: number | undefined;
let passengerDistance: number | undefined;
let passengerDuration: number | undefined;
let points: PointValueObject[] | undefined;
let fwdAzimuth: number | undefined;
let backAzimuth: number | undefined;
try { try {
typedRoutes.forEach((typedRoute: TypedRoute) => { typedRoutes.forEach((typedRoute: TypedRoute) => {
if ([PathType.DRIVER, PathType.GENERIC].includes(typedRoute.type)) { if ([PathType.DRIVER, PathType.GENERIC].includes(typedRoute.type)) {
@ -77,7 +91,9 @@ export class CreateAdService implements ICommandHandler {
fwdAzimuth = typedRoute.route.fwdAzimuth; fwdAzimuth = typedRoute.route.fwdAzimuth;
backAzimuth = typedRoute.route.backAzimuth; backAzimuth = typedRoute.route.backAzimuth;
} }
if ([PathType.PASSENGER, PathType.GENERIC].includes(typedRoute.type)) { if (
[PathType.PASSENGER, PathType.GENERIC].includes(typedRoute.type)
) {
passengerDistance = typedRoute.route.distance; passengerDistance = typedRoute.route.distance;
passengerDuration = typedRoute.route.duration; passengerDuration = typedRoute.route.duration;
if (!points) if (!points)
@ -95,6 +111,7 @@ export class CreateAdService implements ICommandHandler {
} catch (error: any) { } catch (error: any) {
throw new Error('Invalid route'); throw new Error('Invalid route');
} }
const ad = AdEntity.create({ const ad = AdEntity.create({
id: command.id, id: command.id,
driver: command.driver, driver: command.driver,
@ -115,6 +132,7 @@ export class CreateAdService implements ICommandHandler {
fwdAzimuth: fwdAzimuth as number, fwdAzimuth: fwdAzimuth as number,
backAzimuth: backAzimuth as number, backAzimuth: backAzimuth as number,
}); });
try { try {
await this.repository.insertExtra(ad, 'ad'); await this.repository.insertExtra(ad, 'ad');
return ad.id; return ad.id;
@ -124,5 +142,21 @@ export class CreateAdService implements ICommandHandler {
} }
throw 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;
}
} }
} }

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

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

@ -1,4 +1,4 @@
import { Frequency, Role } from '@modules/ad/core/domain/ad.types'; import { AdStatus, Frequency, Role } from '@modules/ad/core/domain/ad.types';
import { Selector } from '../algorithm.abstract'; import { Selector } from '../algorithm.abstract';
import { Waypoint } from '../../../types/waypoint.type'; import { Waypoint } from '../../../types/waypoint.type';
import { Point } from '../../../types/point.type'; import { Point } from '../../../types/point.type';
@ -133,6 +133,7 @@ export class PassengerOrientedSelector extends Selector {
private _createWhere = (role: Role): string => private _createWhere = (role: Role): string =>
[ [
this._whereStatus(),
this._whereRole(role), this._whereRole(role),
this._whereStrict(), this._whereStrict(),
this._whereDate(), this._whereDate(),
@ -144,6 +145,8 @@ export class PassengerOrientedSelector extends Selector {
.filter((where: string) => where != '') .filter((where: string) => where != '')
.join(' AND '); .join(' AND ');
private _whereStatus = (): string => `status='${AdStatus.VALID}'`;
private _whereRole = (role: Role): string => private _whereRole = (role: Role): string =>
role == Role.PASSENGER ? 'driver=True' : 'passenger=True'; role == Role.PASSENGER ? 'driver=True' : 'passenger=True';
@ -174,7 +177,7 @@ export class PassengerOrientedSelector extends Selector {
private _whereSchedule = (role: Role): string => { private _whereSchedule = (role: Role): string => {
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,

View File

@ -1,12 +1,29 @@
import { AggregateRoot, AggregateID } from '@mobicoop/ddd-library'; import { AggregateRoot, AggregateID } from '@mobicoop/ddd-library';
import { AdProps, CreateAdProps } from './ad.types'; import { AdProps, CreateAdProps } from './ad.types';
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;
}; };
validate(): void { validate(): void {

View File

@ -53,3 +53,10 @@ export enum Role {
DRIVER = 'DRIVER', DRIVER = 'DRIVER',
PASSENGER = 'PASSENGER', PASSENGER = 'PASSENGER',
} }
export enum AdStatus {
PENDING = 'PENDING',
VALID = 'VALID',
INVALID = 'INVALID',
SUSPENDED = 'SUSPENDED',
}

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

@ -13,7 +13,6 @@ export class AdCreatedMessageHandler {
name: AD_CREATED_MESSAGE_HANDLER, name: AD_CREATED_MESSAGE_HANDLER,
}) })
public async adCreated(message: string) { public async adCreated(message: string) {
try {
const createdAd: Ad = JSON.parse(message); const createdAd: Ad = JSON.parse(message);
await this.commandBus.execute( await this.commandBus.execute(
new CreateAdCommand({ new CreateAdCommand({
@ -30,8 +29,5 @@ export class AdCreatedMessageHandler {
waypoints: createdAd.waypoints, waypoints: createdAd.waypoints,
}), }),
); );
} catch (e: any) {
console.log(e);
}
} }
} }

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';
@ -96,6 +100,10 @@ const mockRouteProvider: RouteProviderPort = {
getDetailed: jest.fn(), getDetailed: jest.fn(),
}; };
const mockMessagePublisher = {
publish: jest.fn().mockImplementation(),
};
describe('create-ad.service', () => { describe('create-ad.service', () => {
let createAdService: CreateAdService; let createAdService: CreateAdService;
@ -110,6 +118,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,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}',
);
});
});