diff --git a/src/app.constants.ts b/src/app.constants.ts index 7f2ec77..ec3f2ab 100644 --- a/src/app.constants.ts +++ b/src/app.constants.ts @@ -9,6 +9,8 @@ export const GRPC_SERVICE_NAME = 'AdService'; export const AD_CREATED_ROUTING_KEY = 'ad.created'; export const AD_UPDATED_ROUTING_KEY = 'ad.updated'; export const AD_DELETED_ROUTING_KEY = 'ad.deleted'; +// messaging output +export const AD_PAUSED_ROUTING_KEY = 'ad.paused'; // messaging input export const MATCHER_AD_CREATED_MESSAGE_HANDLER = 'matcherAdCreated'; diff --git a/src/modules/ad/ad.mapper.ts b/src/modules/ad/ad.mapper.ts index 839ced6..acdb6df 100644 --- a/src/modules/ad/ad.mapper.ts +++ b/src/modules/ad/ad.mapper.ts @@ -49,6 +49,7 @@ export class AdMapper seatsRequested: copy.seatsRequested as number, strict: copy.strict as boolean, waypoints: this.toWaypointWriteModel(copy.waypoints, update), + pause: copy.pause, comment: copy.comment, }; return record; @@ -160,6 +161,7 @@ export class AdMapper }, }, })), + pause: record.pause, comment: record.comment, }, }); @@ -227,6 +229,7 @@ export class AdMapper lon: waypoint.address.coordinates.lon, lat: waypoint.address.coordinates.lat, })); + response.pause = props.pause; response.comment = props.comment; return response; }; diff --git a/src/modules/ad/ad.module.ts b/src/modules/ad/ad.module.ts index 286670a..df2694d 100644 --- a/src/modules/ad/ad.module.ts +++ b/src/modules/ad/ad.module.ts @@ -12,6 +12,7 @@ import { 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 { PauseAdService } from './core/application/commands/pause-ad/pause-ad.service'; import { DeleteUserAdsService } from './core/application/commands/delete-user-ads/delete-user-ads.service'; import { InvalidateAdService } from './core/application/commands/invalidate-ad/invalidate-ad.service'; import { UpdateAdService } from './core/application/commands/update-ad/update-ad.service'; @@ -30,6 +31,7 @@ import { TimeConverter } from './infrastructure/time-converter'; import { TimezoneFinder } from './infrastructure/timezone-finder'; import { CreateAdGrpcController } from './interface/grpc-controllers/create-ad.grpc.controller'; import { DeleteAdGrpcController } from './interface/grpc-controllers/delete-ad.grpc.controller'; +import { PauseAdGrpcController } from './interface/grpc-controllers/pause-ad.grpc.controller'; import { FindAdByIdGrpcController } from './interface/grpc-controllers/find-ad-by-id.grpc.controller'; import { FindAdsByIdsGrpcController } from './interface/grpc-controllers/find-ads-by-ids.grpc.controller'; import { FindAdsByUserIdGrpcController } from './interface/grpc-controllers/find-ads-by-user-id.grpc.controller'; @@ -42,6 +44,7 @@ const grpcControllers = [ CreateAdGrpcController, UpdateAdGrpcController, DeleteAdGrpcController, + PauseAdGrpcController, FindAdByIdGrpcController, FindAdsByIdsGrpcController, FindAdsByUserIdGrpcController, @@ -63,6 +66,7 @@ const commandHandlers: Provider[] = [ CreateAdService, UpdateAdService, DeleteAdService, + PauseAdService, DeleteUserAdsService, ValidateAdService, InvalidateAdService, diff --git a/src/modules/ad/core/application/commands/create-ad/create-ad.command.ts b/src/modules/ad/core/application/commands/create-ad/create-ad.command.ts index fefe004..96dc243 100644 --- a/src/modules/ad/core/application/commands/create-ad/create-ad.command.ts +++ b/src/modules/ad/core/application/commands/create-ad/create-ad.command.ts @@ -15,6 +15,7 @@ export class CreateAdCommand extends Command { readonly seatsRequested?: number; readonly strict: boolean; readonly waypoints: Waypoint[]; + readonly pause: boolean; readonly comment?: string; constructor(props: CommandProps) { @@ -30,6 +31,7 @@ export class CreateAdCommand extends Command { this.seatsRequested = props.seatsRequested; this.strict = props.strict; this.waypoints = props.waypoints; + this.pause = props.pause; this.comment = props.comment; } } diff --git a/src/modules/ad/core/application/commands/pause-ad/pause-ad.command.ts b/src/modules/ad/core/application/commands/pause-ad/pause-ad.command.ts new file mode 100644 index 0000000..2c910b1 --- /dev/null +++ b/src/modules/ad/core/application/commands/pause-ad/pause-ad.command.ts @@ -0,0 +1,7 @@ +import { Command, CommandProps } from '@mobicoop/ddd-library'; + +export class PauseAdCommand extends Command { + constructor(props: CommandProps) { + super(props); + } +} diff --git a/src/modules/ad/core/application/commands/pause-ad/pause-ad.service.ts b/src/modules/ad/core/application/commands/pause-ad/pause-ad.service.ts new file mode 100644 index 0000000..fecfe01 --- /dev/null +++ b/src/modules/ad/core/application/commands/pause-ad/pause-ad.service.ts @@ -0,0 +1,19 @@ +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 { PauseAdCommand } from './pause-ad.command'; + +@CommandHandler(PauseAdCommand) +export class PauseAdService implements ICommandHandler { + constructor( + @Inject(AD_REPOSITORY) + private readonly adRepository: AdRepositoryPort, + ) {} + + async execute(command: PauseAdCommand): Promise { + const ad = await this.adRepository.findOneById(command.id); + ad.pause(); + await this.adRepository.update(ad.id, ad); + } +} diff --git a/src/modules/ad/core/application/event-handlers/publish-message-when-ad-is-paused.domain-event-handler.ts b/src/modules/ad/core/application/event-handlers/publish-message-when-ad-is-paused.domain-event-handler.ts new file mode 100644 index 0000000..58193b0 --- /dev/null +++ b/src/modules/ad/core/application/event-handlers/publish-message-when-ad-is-paused.domain-event-handler.ts @@ -0,0 +1,19 @@ +import { MessagePublisherPort } from '@mobicoop/ddd-library'; +import { AD_MESSAGE_PUBLISHER } from '@modules/ad/ad.di-tokens'; +import { Inject, Injectable } from '@nestjs/common'; +import { OnEvent } from '@nestjs/event-emitter'; +import { AD_PAUSED_ROUTING_KEY } from '@src/app.constants'; +import { AdPausedDomainEvent } from '../../domain/events/ad-paused.domain-event'; + +@Injectable() +export class PublishMessageWhenAdIsPausedDomainEventHandler { + constructor( + @Inject(AD_MESSAGE_PUBLISHER) + private readonly messagePublisher: MessagePublisherPort, + ) {} + + @OnEvent(AdPausedDomainEvent.name, { async: true, promisify: true }) + async handle(event: AdPausedDomainEvent): Promise { + this.messagePublisher.publish(AD_PAUSED_ROUTING_KEY, JSON.stringify(event)); + } +} diff --git a/src/modules/ad/core/application/ports/ad.repository.port.ts b/src/modules/ad/core/application/ports/ad.repository.port.ts index 5b9fe49..748c9cf 100644 --- a/src/modules/ad/core/application/ports/ad.repository.port.ts +++ b/src/modules/ad/core/application/ports/ad.repository.port.ts @@ -2,3 +2,8 @@ import { RepositoryPort } from '@mobicoop/ddd-library'; import { AdEntity } from '../../domain/ad.entity'; export type AdRepositoryPort = RepositoryPort; +/* + & { + pause(entity: AdEntity, identifier?: string): Promise; +}; +*/ diff --git a/src/modules/ad/core/domain/ad.entity.ts b/src/modules/ad/core/domain/ad.entity.ts index 00ea1eb..60149f5 100644 --- a/src/modules/ad/core/domain/ad.entity.ts +++ b/src/modules/ad/core/domain/ad.entity.ts @@ -3,6 +3,7 @@ import { v4 } from 'uuid'; import { AdProps, CreateAdProps, Status } from './ad.types'; import { AdCreatedDomainEvent } from './events/ad-created.domain-event'; import { AdDeletedDomainEvent } from './events/ad-delete.domain-event'; +import { AdPausedDomainEvent } from './events/ad-paused.domain-event'; import { AdInvalidatedDomainEvent } from './events/ad-invalidated.domain-event'; import { AdSuspendedDomainEvent } from './events/ad-suspended.domain-event'; import { AdValidatedDomainEvent } from './events/ad-validated.domain-event'; @@ -48,6 +49,7 @@ export class AdEntity extends AggregateRoot { lon: waypoint.address.coordinates.lon, lat: waypoint.address.coordinates.lat, })), + pause: props.pause, comment: props.comment, }), ); @@ -114,6 +116,10 @@ export class AdEntity extends AggregateRoot { return this; }; + validate(): void { + // entity business rules validation to protect it's invariant before saving entity to a database + } + delete(): void { this.addEvent( new AdDeletedDomainEvent({ @@ -122,7 +128,17 @@ export class AdEntity extends AggregateRoot { ); } - validate(): void { - // entity business rules validation to protect it's invariant before saving entity to a database + pause(): AdEntity { + this.props.pause = !this.props.pause; + this.addEvent( + new AdPausedDomainEvent({ + metadata: { + correlationId: this.id, + timestamp: Date.now(), + }, + aggregateId: this.id, + }), + ); + return this; } } diff --git a/src/modules/ad/core/domain/ad.types.ts b/src/modules/ad/core/domain/ad.types.ts index d42115a..83e3a1a 100644 --- a/src/modules/ad/core/domain/ad.types.ts +++ b/src/modules/ad/core/domain/ad.types.ts @@ -14,6 +14,7 @@ export interface CreateAdProps { seatsRequested: number; strict: boolean; waypoints: WaypointProps[]; + pause: boolean; comment?: string; } diff --git a/src/modules/ad/core/domain/events/ad-created.domain-event.ts b/src/modules/ad/core/domain/events/ad-created.domain-event.ts index 12c45cf..2fe16d5 100644 --- a/src/modules/ad/core/domain/events/ad-created.domain-event.ts +++ b/src/modules/ad/core/domain/events/ad-created.domain-event.ts @@ -13,6 +13,7 @@ export class AdCreatedDomainEvent extends DomainEvent { readonly seatsRequested: number; readonly strict: boolean; readonly waypoints: Waypoint[]; + readonly pause?: boolean; readonly comment?: string; constructor(props: DomainEventProps) { @@ -28,6 +29,7 @@ export class AdCreatedDomainEvent extends DomainEvent { this.seatsRequested = props.seatsRequested; this.strict = props.strict; this.waypoints = props.waypoints; + this.pause = props.pause; this.comment = props.comment; } } diff --git a/src/modules/ad/core/domain/events/ad-paused.domain-event.ts b/src/modules/ad/core/domain/events/ad-paused.domain-event.ts new file mode 100644 index 0000000..7737564 --- /dev/null +++ b/src/modules/ad/core/domain/events/ad-paused.domain-event.ts @@ -0,0 +1,7 @@ +import { DomainEvent, DomainEventProps } from '@mobicoop/ddd-library'; + +export class AdPausedDomainEvent extends DomainEvent { + constructor(props: DomainEventProps) { + super(props); + } +} diff --git a/src/modules/ad/infrastructure/ad.repository.ts b/src/modules/ad/infrastructure/ad.repository.ts index fa06d0a..ecc63cb 100644 --- a/src/modules/ad/infrastructure/ad.repository.ts +++ b/src/modules/ad/infrastructure/ad.repository.ts @@ -24,6 +24,7 @@ export type AdBaseModel = { seatsProposed: number; seatsRequested: number; strict: boolean; + pause: boolean; comment?: string; }; diff --git a/src/modules/ad/interface/dtos/ad.response.dto.ts b/src/modules/ad/interface/dtos/ad.response.dto.ts index 60a301f..c6b0a41 100644 --- a/src/modules/ad/interface/dtos/ad.response.dto.ts +++ b/src/modules/ad/interface/dtos/ad.response.dto.ts @@ -28,5 +28,6 @@ export class AdResponseDto extends ResponseBase { lon: number; lat: number; }[]; + pause: boolean; comment?: string; } diff --git a/src/modules/ad/interface/grpc-controllers/dtos/create-ad.request.dto.ts b/src/modules/ad/interface/grpc-controllers/dtos/create-ad.request.dto.ts index 14122d0..aa08e32 100644 --- a/src/modules/ad/interface/grpc-controllers/dtos/create-ad.request.dto.ts +++ b/src/modules/ad/interface/grpc-controllers/dtos/create-ad.request.dto.ts @@ -81,6 +81,10 @@ export class CreateAdRequestDto { @ValidateNested({ each: true }) waypoints: WaypointDto[]; + @IsBoolean() + @IsOptional() + pause: boolean; + @Length(0, 2000) @IsString() @IsOptional() diff --git a/src/modules/ad/interface/grpc-controllers/dtos/pause-ad.request.dto.ts b/src/modules/ad/interface/grpc-controllers/dtos/pause-ad.request.dto.ts new file mode 100644 index 0000000..0e6726d --- /dev/null +++ b/src/modules/ad/interface/grpc-controllers/dtos/pause-ad.request.dto.ts @@ -0,0 +1,7 @@ +import { IsNotEmpty, IsString } from 'class-validator'; + +export class PauseAdRequestDto { + @IsString() + @IsNotEmpty() + id: string; +} diff --git a/src/modules/ad/interface/grpc-controllers/pause-ad.grpc.controller.ts b/src/modules/ad/interface/grpc-controllers/pause-ad.grpc.controller.ts new file mode 100644 index 0000000..78cd230 --- /dev/null +++ b/src/modules/ad/interface/grpc-controllers/pause-ad.grpc.controller.ts @@ -0,0 +1,45 @@ +import { + DatabaseErrorException, + NotFoundException, + RpcExceptionCode, + RpcValidationPipe, +} from '@mobicoop/ddd-library'; +import { PauseAdCommand } from '@modules/ad/core/application/commands/pause-ad/pause-ad.command'; +import { Controller, UsePipes } from '@nestjs/common'; +import { CommandBus } from '@nestjs/cqrs'; +import { GrpcMethod, RpcException } from '@nestjs/microservices'; +import { GRPC_SERVICE_NAME } from '@src/app.constants'; +import { PauseAdRequestDto } from './dtos/pause-ad.request.dto'; + +@UsePipes( + new RpcValidationPipe({ + whitelist: false, + forbidUnknownValues: false, + }), +) +@Controller() +export class PauseAdGrpcController { + constructor(private readonly commandBus: CommandBus) {} + + @GrpcMethod(GRPC_SERVICE_NAME, 'Pause') + async pause(data: PauseAdRequestDto): Promise { + try { + await this.commandBus.execute(new PauseAdCommand(data)); + } catch (error: any) { + if (error instanceof NotFoundException) + throw new RpcException({ + code: RpcExceptionCode.NOT_FOUND, + message: error.message, + }); + if (error instanceof DatabaseErrorException) + throw new RpcException({ + code: RpcExceptionCode.INTERNAL, + message: error.message, + }); + throw new RpcException({ + code: RpcExceptionCode.UNKNOWN, + message: error.message, + }); + } + } +}