diff --git a/src/modules/ad/ad.mapper.ts b/src/modules/ad/ad.mapper.ts index 05daf4e..839ced6 100644 --- a/src/modules/ad/ad.mapper.ts +++ b/src/modules/ad/ad.mapper.ts @@ -11,7 +11,9 @@ import { AdReadModel, AdWriteModel, ScheduleItemModel, + ScheduleWriteModel, WaypointModel, + WaypointWriteModel, } from './infrastructure/ad.repository'; import { AdResponseDto } from './interface/dtos/ad.response.dto'; @@ -31,9 +33,8 @@ export class AdMapper private readonly outputDatetimeTransformer: DateTimeTransformerPort, ) {} - toPersistence = (entity: AdEntity): AdWriteModel => { + toPersistence = (entity: AdEntity, update?: boolean): AdWriteModel => { const copy = entity.getProps(); - const now = new Date(); const record: AdWriteModel = { uuid: copy.id, userUuid: copy.userId, @@ -43,50 +44,80 @@ export class AdMapper frequency: copy.frequency, fromDate: new Date(copy.fromDate), toDate: new Date(copy.toDate), - schedule: copy.schedule - ? { - create: copy.schedule.map((scheduleItem: ScheduleItemProps) => ({ - uuid: v4(), - day: scheduleItem.day, - time: new Date( - 1970, - 0, - 1, - parseInt(scheduleItem.time.split(':')[0]), - parseInt(scheduleItem.time.split(':')[1]), - ), - margin: scheduleItem.margin, - createdAt: now, - updatedAt: now, - })), - } - : undefined, + schedule: this.toScheduleItemWriteModel(copy.schedule, update), seatsProposed: copy.seatsProposed as number, seatsRequested: copy.seatsRequested as number, strict: copy.strict as boolean, - waypoints: copy.waypoints - ? { - create: copy.waypoints.map((waypoint: WaypointProps) => ({ - uuid: v4(), - position: waypoint.position, - name: waypoint.address.name, - houseNumber: waypoint.address.houseNumber, - street: waypoint.address.street, - locality: waypoint.address.locality, - postalCode: waypoint.address.postalCode, - country: waypoint.address.country, - lon: waypoint.address.coordinates.lon, - lat: waypoint.address.coordinates.lat, - createdAt: now, - updatedAt: now, - })), - } - : undefined, + waypoints: this.toWaypointWriteModel(copy.waypoints, update), comment: copy.comment, }; return record; }; + toScheduleItemWriteModel = ( + schedule: ScheduleItemProps[], + update?: boolean, + ): ScheduleWriteModel | undefined => { + if (!schedule) { + return undefined; + } + const now = new Date(); + const record: ScheduleWriteModel = { + create: schedule.map((scheduleItem: ScheduleItemProps) => ({ + uuid: v4(), + day: scheduleItem.day, + time: new Date( + 1970, + 0, + 1, + parseInt(scheduleItem.time.split(':')[0]), + parseInt(scheduleItem.time.split(':')[1]), + ), + margin: scheduleItem.margin, + createdAt: now, + updatedAt: now, + })), + }; + if (update) { + record.deleteMany = { + createdAt: { lt: now }, + }; + } + return record; + }; + + toWaypointWriteModel = ( + waypoints: WaypointProps[], + update?: boolean, + ): WaypointWriteModel | undefined => { + if (!waypoints) { + return undefined; + } + const now = new Date(); + const record: WaypointWriteModel = { + create: waypoints.map((waypoint: WaypointProps) => ({ + uuid: v4(), + position: waypoint.position, + name: waypoint.address.name, + houseNumber: waypoint.address.houseNumber, + street: waypoint.address.street, + locality: waypoint.address.locality, + postalCode: waypoint.address.postalCode, + country: waypoint.address.country, + lon: waypoint.address.coordinates.lon, + lat: waypoint.address.coordinates.lat, + createdAt: now, + updatedAt: now, + })), + }; + if (update) { + record.deleteMany = { + createdAt: { lt: now }, + }; + } + return record; + }; + toDomain = (record: AdReadModel): AdEntity => { const entity = new AdEntity({ id: record.uuid, diff --git a/src/modules/ad/ad.module.ts b/src/modules/ad/ad.module.ts index 8f0e1d9..c27a272 100644 --- a/src/modules/ad/ad.module.ts +++ b/src/modules/ad/ad.module.ts @@ -14,6 +14,7 @@ import { CreateAdService } from './core/application/commands/create-ad/create-ad import { DeleteAdService } from './core/application/commands/delete-ad/delete-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'; import { ValidateAdService } from './core/application/commands/validate-ad/validate-ad.service'; import { PublishMessageWhenAdIsDeletedDomainEventHandler } from './core/application/event-handlers/publish-message-when-ad-deleted.domain-event-handler'; import { PublishMessageWhenAdIsCreatedDomainEventHandler } from './core/application/event-handlers/publish-message-when-ad-is-created.domain-event-handler'; @@ -58,6 +59,7 @@ const eventHandlers: Provider[] = [ const commandHandlers: Provider[] = [ CreateAdService, + UpdateAdService, DeleteAdService, DeleteUserAdsService, ValidateAdService, diff --git a/src/modules/ad/core/application/commands/create-ad/create-ad.service.ts b/src/modules/ad/core/application/commands/create-ad/create-ad.service.ts index 5b3abc3..98a95dc 100644 --- a/src/modules/ad/core/application/commands/create-ad/create-ad.service.ts +++ b/src/modules/ad/core/application/commands/create-ad/create-ad.service.ts @@ -13,6 +13,87 @@ import { DateTimeTransformerPort } from '../../ports/datetime-transformer.port'; import { Waypoint } from '../../types/waypoint'; import { CreateAdCommand } from './create-ad.command'; +export function createPropsFromCommand( + command: CreateAdCommand, + datetimeTransformer: DateTimeTransformerPort, +) { + return { + userId: command.userId, + driver: command.driver, + passenger: command.passenger, + frequency: command.frequency, + //TODO Shouldn't that kind of logic be in the domain layer? + fromDate: datetimeTransformer.fromDate( + { + date: command.fromDate, + time: command.schedule[0].time, + coordinates: { + lon: command.waypoints[0].lon, + lat: command.waypoints[0].lat, + }, + }, + command.frequency, + ), + toDate: datetimeTransformer.toDate( + command.toDate, + { + date: command.fromDate, + time: command.schedule[0].time, + coordinates: { + lon: command.waypoints[0].lon, + lat: command.waypoints[0].lat, + }, + }, + command.frequency, + ), + schedule: command.schedule.map((scheduleItem: ScheduleItemProps) => ({ + day: datetimeTransformer.day( + scheduleItem.day, + { + date: command.fromDate, + time: scheduleItem.time, + coordinates: { + lon: command.waypoints[0].lon, + lat: command.waypoints[0].lat, + }, + }, + command.frequency, + ), + time: datetimeTransformer.time( + { + date: command.fromDate, + time: scheduleItem.time, + coordinates: { + lon: command.waypoints[0].lon, + lat: command.waypoints[0].lat, + }, + }, + command.frequency, + ), + margin: scheduleItem.margin, + })), + seatsProposed: command.seatsProposed ?? 0, + seatsRequested: command.seatsRequested ?? 0, + strict: command.strict, + waypoints: command.waypoints.map((waypoint: Waypoint) => ({ + position: waypoint.position, + address: { + name: waypoint.name, + houseNumber: waypoint.houseNumber, + street: waypoint.street, + postalCode: waypoint.postalCode, + locality: waypoint.locality, + country: waypoint.country, + coordinates: { + lon: waypoint.lon, + lat: waypoint.lat, + }, + }, + })), + comment: command.comment, + }; +} + @CommandHandler(CreateAdCommand) export class CreateAdService implements ICommandHandler { constructor( @@ -23,80 +104,9 @@ export class CreateAdService implements ICommandHandler { ) {} async execute(command: CreateAdCommand): Promise { - const ad = AdEntity.create({ - userId: command.userId, - driver: command.driver, - passenger: command.passenger, - frequency: command.frequency, - fromDate: this.datetimeTransformer.fromDate( - { - date: command.fromDate, - time: command.schedule[0].time, - coordinates: { - lon: command.waypoints[0].lon, - lat: command.waypoints[0].lat, - }, - }, - command.frequency, - ), - toDate: this.datetimeTransformer.toDate( - command.toDate, - { - date: command.fromDate, - time: command.schedule[0].time, - coordinates: { - lon: command.waypoints[0].lon, - lat: command.waypoints[0].lat, - }, - }, - command.frequency, - ), - schedule: command.schedule.map((scheduleItem: ScheduleItemProps) => ({ - day: this.datetimeTransformer.day( - scheduleItem.day, - { - date: command.fromDate, - time: scheduleItem.time, - coordinates: { - lon: command.waypoints[0].lon, - lat: command.waypoints[0].lat, - }, - }, - command.frequency, - ), - time: this.datetimeTransformer.time( - { - date: command.fromDate, - time: scheduleItem.time, - coordinates: { - lon: command.waypoints[0].lon, - lat: command.waypoints[0].lat, - }, - }, - command.frequency, - ), - margin: scheduleItem.margin, - })), - seatsProposed: command.seatsProposed ?? 0, - seatsRequested: command.seatsRequested ?? 0, - strict: command.strict, - waypoints: command.waypoints.map((waypoint: Waypoint) => ({ - position: waypoint.position, - address: { - name: waypoint.name, - houseNumber: waypoint.houseNumber, - street: waypoint.street, - postalCode: waypoint.postalCode, - locality: waypoint.locality, - country: waypoint.country, - coordinates: { - lon: waypoint.lon, - lat: waypoint.lat, - }, - }, - })), - comment: command.comment, - }); + const ad = AdEntity.create( + createPropsFromCommand(command, this.datetimeTransformer), + ); try { await this.repository.insert(ad); diff --git a/src/modules/ad/core/application/commands/update-ad/update-ad.command.ts b/src/modules/ad/core/application/commands/update-ad/update-ad.command.ts new file mode 100644 index 0000000..db2dbc3 --- /dev/null +++ b/src/modules/ad/core/application/commands/update-ad/update-ad.command.ts @@ -0,0 +1,16 @@ +import { CommandProps } from '@mobicoop/ddd-library'; +import { CreateAdCommand } from '../create-ad/create-ad.command'; + +/** + * Ad updates follow the PUT semantics: they replace the entire object. + * Therefore the update command extends the create command to inherit the same properties + * and re-use the data transformation logic. + */ +export class UpdateAdCommand extends CreateAdCommand { + public adId: string; + + constructor(props: CommandProps) { + super(props); + this.adId = props.adId; + } +} diff --git a/src/modules/ad/core/application/commands/update-ad/update-ad.service.ts b/src/modules/ad/core/application/commands/update-ad/update-ad.service.ts new file mode 100644 index 0000000..a97d093 --- /dev/null +++ b/src/modules/ad/core/application/commands/update-ad/update-ad.service.ts @@ -0,0 +1,29 @@ +import { + AD_REPOSITORY, + INPUT_DATETIME_TRANSFORMER, +} 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 { DateTimeTransformerPort } from '../../ports/datetime-transformer.port'; +import { createPropsFromCommand } from '../create-ad/create-ad.service'; +import { UpdateAdCommand } from './update-ad.command'; + +@CommandHandler(UpdateAdCommand) +export class UpdateAdService implements ICommandHandler { + constructor( + @Inject(AD_REPOSITORY) + private readonly repository: AdRepositoryPort, + @Inject(INPUT_DATETIME_TRANSFORMER) + private readonly datetimeTransformer: DateTimeTransformerPort, + ) {} + + async execute(command: UpdateAdCommand): Promise { + const ad = await this.repository.findOneById(command.adId, { + waypoints: true, + schedule: true, + }); + ad.update(createPropsFromCommand(command, this.datetimeTransformer)); + await this.repository.update(ad.id, ad); + } +} diff --git a/src/modules/ad/core/application/types/waypoint.ts b/src/modules/ad/core/application/types/waypoint.ts index 32c5204..57d792e 100644 --- a/src/modules/ad/core/application/types/waypoint.ts +++ b/src/modules/ad/core/application/types/waypoint.ts @@ -1,5 +1,6 @@ import { Address } from './address'; +//TODO Why not use the Waypoint value-object from the domain? export type Waypoint = { position: number; } & Address; diff --git a/src/modules/ad/infrastructure/ad.repository.ts b/src/modules/ad/infrastructure/ad.repository.ts index b5df96f..fa06d0a 100644 --- a/src/modules/ad/infrastructure/ad.repository.ts +++ b/src/modules/ad/infrastructure/ad.repository.ts @@ -1,16 +1,16 @@ -import { Inject, Injectable, Logger } from '@nestjs/common'; -import { EventEmitter2 } from '@nestjs/event-emitter'; -import { AdEntity } from '../core/domain/ad.entity'; -import { AdMapper } from '../ad.mapper'; -import { AdRepositoryPort } from '../core/application/ports/ad.repository.port'; import { LoggerBase, MessagePublisherPort, PrismaRepositoryBase, } from '@mobicoop/ddd-library'; -import { PrismaService } from './prisma.service'; -import { AD_MESSAGE_PUBLISHER } from '../ad.di-tokens'; +import { Inject, Injectable, Logger } from '@nestjs/common'; +import { EventEmitter2 } from '@nestjs/event-emitter'; import { SERVICE_NAME } from '@src/app.constants'; +import { AD_MESSAGE_PUBLISHER } from '../ad.di-tokens'; +import { AdMapper } from '../ad.mapper'; +import { AdRepositoryPort } from '../core/application/ports/ad.repository.port'; +import { AdEntity } from '../core/domain/ad.entity'; +import { PrismaService } from './prisma.service'; export type AdBaseModel = { uuid: string; @@ -40,13 +40,21 @@ export type AdWriteModel = AdBaseModel & { }; export type ScheduleWriteModel = { + deleteMany?: PastCreatedFilter; create: ScheduleItemModel[]; }; export type WaypointWriteModel = { + deleteMany?: PastCreatedFilter; create: WaypointModel[]; }; +// used to delete records created in the past, +// because the order of `create` and `deleteMany` is not guaranteed +export type PastCreatedFilter = { + createdAt: { lt: Date }; +}; + export type ScheduleItemModel = { uuid: string; day: number;