feat(transfer): add DELETE endpoint, domain events, and event-driven Typesense sync
- DeleteTransferListingCommand/Handler with seller authorization and soft delete (→ CANCELLED) - Domain events: TransferListingCreated/Updated/DeletedEvent with EventEmitter2 - Event handler: TransferListingTypesenseHandler syncs Typesense on all CUD operations - Create/Update handlers now emit domain events after persistence - DELETE /transfer/listings/:id controller endpoint with JWT auth Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -1,7 +1,9 @@
|
||||
import { Inject } from '@nestjs/common';
|
||||
import { type ICommandHandler, CommandHandler } from '@nestjs/cqrs';
|
||||
import { createId } from '@paralleldrive/cuid2';
|
||||
import { EventBusService } from '@modules/shared';
|
||||
import { TransferListingEntity } from '../../../domain/entities/transfer-listing.entity';
|
||||
import { TransferListingCreatedEvent } from '../../../domain/events';
|
||||
import {
|
||||
TRANSFER_LISTING_REPOSITORY,
|
||||
type ITransferListingRepository,
|
||||
@@ -13,6 +15,7 @@ export class CreateTransferListingHandler implements ICommandHandler<CreateTrans
|
||||
constructor(
|
||||
@Inject(TRANSFER_LISTING_REPOSITORY)
|
||||
private readonly repo: ITransferListingRepository,
|
||||
private readonly eventBus: EventBusService,
|
||||
) {}
|
||||
|
||||
async execute(cmd: CreateTransferListingCommand): Promise<{ id: string }> {
|
||||
@@ -55,6 +58,11 @@ export class CreateTransferListingHandler implements ICommandHandler<CreateTrans
|
||||
}, now, now);
|
||||
|
||||
await this.repo.save(entity, cmd.items);
|
||||
|
||||
this.eventBus.publish(
|
||||
new TransferListingCreatedEvent(id, cmd.sellerId, cmd.category),
|
||||
);
|
||||
|
||||
return { id };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
export class DeleteTransferListingCommand {
|
||||
constructor(
|
||||
public readonly id: string,
|
||||
public readonly sellerId: string,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Inject } from '@nestjs/common';
|
||||
import { type ICommandHandler, CommandHandler } from '@nestjs/cqrs';
|
||||
import { EventBusService, ForbiddenException, NotFoundException } from '@modules/shared';
|
||||
import {
|
||||
TRANSFER_LISTING_REPOSITORY,
|
||||
type ITransferListingRepository,
|
||||
} from '../../../domain/repositories/transfer-listing.repository';
|
||||
import { TransferListingDeletedEvent } from '../../../domain/events';
|
||||
import { DeleteTransferListingCommand } from './delete-transfer-listing.command';
|
||||
|
||||
@CommandHandler(DeleteTransferListingCommand)
|
||||
export class DeleteTransferListingHandler implements ICommandHandler<DeleteTransferListingCommand> {
|
||||
constructor(
|
||||
@Inject(TRANSFER_LISTING_REPOSITORY)
|
||||
private readonly repo: ITransferListingRepository,
|
||||
private readonly eventBus: EventBusService,
|
||||
) {}
|
||||
|
||||
async execute(cmd: DeleteTransferListingCommand): Promise<{ id: string }> {
|
||||
const entity = await this.repo.findById(cmd.id);
|
||||
if (!entity) {
|
||||
throw new NotFoundException('Transfer listing', cmd.id);
|
||||
}
|
||||
|
||||
if (entity.sellerId !== cmd.sellerId) {
|
||||
throw new ForbiddenException('You can only delete your own listings');
|
||||
}
|
||||
|
||||
entity.updateDetails({ status: 'EXPIRED' });
|
||||
await this.repo.update(entity);
|
||||
|
||||
this.eventBus.publish(
|
||||
new TransferListingDeletedEvent(cmd.id, cmd.sellerId),
|
||||
);
|
||||
|
||||
return { id: cmd.id };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { DeleteTransferListingCommand } from './delete-transfer-listing.command';
|
||||
export { DeleteTransferListingHandler } from './delete-transfer-listing.handler';
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Inject } from '@nestjs/common';
|
||||
import { type ICommandHandler, CommandHandler } from '@nestjs/cqrs';
|
||||
import { NotFoundException } from '@modules/shared';
|
||||
import { EventBusService, NotFoundException } from '@modules/shared';
|
||||
import { TransferListingUpdatedEvent } from '../../../domain/events';
|
||||
import {
|
||||
TRANSFER_LISTING_REPOSITORY,
|
||||
type ITransferListingRepository,
|
||||
@@ -12,6 +13,7 @@ export class UpdateTransferListingHandler implements ICommandHandler<UpdateTrans
|
||||
constructor(
|
||||
@Inject(TRANSFER_LISTING_REPOSITORY)
|
||||
private readonly repo: ITransferListingRepository,
|
||||
private readonly eventBus: EventBusService,
|
||||
) {}
|
||||
|
||||
async execute(cmd: UpdateTransferListingCommand): Promise<{ id: string }> {
|
||||
@@ -38,6 +40,9 @@ export class UpdateTransferListingHandler implements ICommandHandler<UpdateTrans
|
||||
});
|
||||
|
||||
await this.repo.update(entity);
|
||||
|
||||
this.eventBus.publish(new TransferListingUpdatedEvent(cmd.id));
|
||||
|
||||
return { id: cmd.id };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
export {};
|
||||
export { TransferListingCreatedEvent } from './transfer-listing-created.event';
|
||||
export { TransferListingUpdatedEvent } from './transfer-listing-updated.event';
|
||||
export { TransferListingDeletedEvent } from './transfer-listing-deleted.event';
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { type TransferCategory } from '@prisma/client';
|
||||
import { type DomainEvent } from '@modules/shared';
|
||||
|
||||
export class TransferListingCreatedEvent implements DomainEvent {
|
||||
readonly eventName = 'transfer_listing.created';
|
||||
readonly occurredAt = new Date();
|
||||
|
||||
constructor(
|
||||
public readonly aggregateId: string,
|
||||
public readonly sellerId: string,
|
||||
public readonly category: TransferCategory,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { type DomainEvent } from '@modules/shared';
|
||||
|
||||
export class TransferListingDeletedEvent implements DomainEvent {
|
||||
readonly eventName = 'transfer_listing.deleted';
|
||||
readonly occurredAt = new Date();
|
||||
|
||||
constructor(
|
||||
public readonly aggregateId: string,
|
||||
public readonly sellerId: string,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { type DomainEvent } from '@modules/shared';
|
||||
|
||||
export class TransferListingUpdatedEvent implements DomainEvent {
|
||||
readonly eventName = 'transfer_listing.updated';
|
||||
readonly occurredAt = new Date();
|
||||
|
||||
constructor(
|
||||
public readonly aggregateId: string,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { OnEvent } from '@nestjs/event-emitter';
|
||||
import { type LoggerService } from '@modules/shared';
|
||||
import { type TransferListingCreatedEvent } from '../../domain/events/transfer-listing-created.event';
|
||||
import { type TransferListingUpdatedEvent } from '../../domain/events/transfer-listing-updated.event';
|
||||
import { type TransferListingDeletedEvent } from '../../domain/events/transfer-listing-deleted.event';
|
||||
import { TypesenseTransferService } from '../services/typesense-transfer.service';
|
||||
|
||||
@Injectable()
|
||||
export class TransferListingTypesenseHandler {
|
||||
constructor(
|
||||
private readonly typesense: TypesenseTransferService,
|
||||
private readonly logger: LoggerService,
|
||||
) {}
|
||||
|
||||
@OnEvent('transfer_listing.created', { async: true })
|
||||
async handleCreated(event: TransferListingCreatedEvent): Promise<void> {
|
||||
this.logger.log(
|
||||
`Handling transfer_listing.created for ${event.aggregateId}`,
|
||||
'TransferListingTypesenseHandler',
|
||||
);
|
||||
await this.typesense.indexListing(event.aggregateId);
|
||||
}
|
||||
|
||||
@OnEvent('transfer_listing.updated', { async: true })
|
||||
async handleUpdated(event: TransferListingUpdatedEvent): Promise<void> {
|
||||
this.logger.log(
|
||||
`Handling transfer_listing.updated for ${event.aggregateId}`,
|
||||
'TransferListingTypesenseHandler',
|
||||
);
|
||||
await this.typesense.indexListing(event.aggregateId);
|
||||
}
|
||||
|
||||
@OnEvent('transfer_listing.deleted', { async: true })
|
||||
async handleDeleted(event: TransferListingDeletedEvent): Promise<void> {
|
||||
this.logger.log(
|
||||
`Handling transfer_listing.deleted for ${event.aggregateId}`,
|
||||
'TransferListingTypesenseHandler',
|
||||
);
|
||||
await this.typesense.deleteListing(event.aggregateId);
|
||||
}
|
||||
}
|
||||
@@ -180,4 +180,14 @@ export class TypesenseTransferService implements OnModuleInit {
|
||||
|
||||
await this.client.collections(TRANSFER_LISTINGS_COLLECTION).documents().upsert(doc);
|
||||
}
|
||||
|
||||
async deleteListing(listingId: string): Promise<void> {
|
||||
if (!this.client) return;
|
||||
|
||||
try {
|
||||
await this.client.collections(TRANSFER_LISTINGS_COLLECTION).documents(listingId).delete();
|
||||
} catch (err) {
|
||||
this.logger.warn(`Failed to delete transfer listing ${listingId} from Typesense: ${err}`, 'TypesenseTransfer');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Body, Controller, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common';
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common';
|
||||
import { type CommandBus, type QueryBus } from '@nestjs/cqrs';
|
||||
import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
import { JwtAuthGuard, CurrentUser } from '@modules/auth';
|
||||
import { EndpointRateLimit, EndpointRateLimitGuard, NotFoundException } from '@modules/shared';
|
||||
import { CreateTransferListingCommand } from '../../application/commands/create-transfer-listing/create-transfer-listing.command';
|
||||
import { DeleteTransferListingCommand } from '../../application/commands/delete-transfer-listing/delete-transfer-listing.command';
|
||||
import { EstimateFromPhotosCommand } from '../../application/commands/estimate-from-photos/estimate-from-photos.command';
|
||||
import { EstimateTransferPricesCommand } from '../../application/commands/estimate-transfer-prices/estimate-transfer-prices.command';
|
||||
import { UpdateTransferListingCommand } from '../../application/commands/update-transfer-listing/update-transfer-listing.command';
|
||||
@@ -169,4 +170,20 @@ export class TransferController {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@ApiOperation({ summary: 'Xoá tin sang nhượng', description: 'Xoá (soft-delete) tin sang nhượng của bạn' })
|
||||
@ApiResponse({ status: 200, description: 'Tin sang nhượng đã xoá' })
|
||||
@ApiResponse({ status: 403, description: 'Không có quyền xoá tin này' })
|
||||
@ApiResponse({ status: 404, description: 'Không tìm thấy tin sang nhượng' })
|
||||
@ApiBearerAuth('JWT')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Delete('listings/:id')
|
||||
async deleteListing(
|
||||
@Param('id') id: string,
|
||||
@CurrentUser() user: { sub: string },
|
||||
) {
|
||||
return this.commandBus.execute(
|
||||
new DeleteTransferListingCommand(id, user.sub),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
|
||||
import { CqrsModule } from '@nestjs/cqrs';
|
||||
import { SearchModule } from '@modules/search';
|
||||
import { CreateTransferListingHandler } from './application/commands/create-transfer-listing/create-transfer-listing.handler';
|
||||
import { DeleteTransferListingHandler } from './application/commands/delete-transfer-listing/delete-transfer-listing.handler';
|
||||
import { EstimateFromPhotosHandler } from './application/commands/estimate-from-photos/estimate-from-photos.handler';
|
||||
import { EstimateTransferPricesHandler } from './application/commands/estimate-transfer-prices/estimate-transfer-prices.handler';
|
||||
import { UpdateTransferListingHandler } from './application/commands/update-transfer-listing/update-transfer-listing.handler';
|
||||
@@ -9,6 +10,7 @@ import { GetTransferListingHandler } from './application/queries/get-transfer-li
|
||||
import { ListTransferListingsHandler } from './application/queries/list-transfer-listings/list-transfer-listings.handler';
|
||||
import { TransferStatsHandler } from './application/queries/transfer-stats/transfer-stats.handler';
|
||||
import { TRANSFER_LISTING_REPOSITORY } from './domain/repositories/transfer-listing.repository';
|
||||
import { TransferListingTypesenseHandler } from './infrastructure/event-handlers/transfer-listing-typesense.handler';
|
||||
import { PrismaTransferListingRepository } from './infrastructure/repositories/prisma-transfer-listing.repository';
|
||||
import { CLAUDE_VISION_SERVICE, ClaudeVisionService } from './infrastructure/services/claude-vision.service';
|
||||
import { TypesenseTransferService } from './infrastructure/services/typesense-transfer.service';
|
||||
@@ -16,6 +18,7 @@ import { TransferController } from './presentation/controllers/transfer.controll
|
||||
|
||||
const CommandHandlers = [
|
||||
CreateTransferListingHandler,
|
||||
DeleteTransferListingHandler,
|
||||
EstimateFromPhotosHandler,
|
||||
EstimateTransferPricesHandler,
|
||||
UpdateTransferListingHandler,
|
||||
@@ -27,6 +30,10 @@ const QueryHandlers = [
|
||||
TransferStatsHandler,
|
||||
];
|
||||
|
||||
const EventHandlers = [
|
||||
TransferListingTypesenseHandler,
|
||||
];
|
||||
|
||||
@Module({
|
||||
imports: [CqrsModule, SearchModule],
|
||||
controllers: [TransferController],
|
||||
@@ -36,6 +43,7 @@ const QueryHandlers = [
|
||||
TypesenseTransferService,
|
||||
...CommandHandlers,
|
||||
...QueryHandlers,
|
||||
...EventHandlers,
|
||||
],
|
||||
exports: [TRANSFER_LISTING_REPOSITORY, TypesenseTransferService],
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user