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:
Ho Ngoc Hai
2026-04-16 15:27:57 +07:00
parent ca41f7e604
commit a7bcc807ad
13 changed files with 175 additions and 3 deletions

View File

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

View File

@@ -0,0 +1,6 @@
export class DeleteTransferListingCommand {
constructor(
public readonly id: string,
public readonly sellerId: string,
) {}
}

View File

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

View File

@@ -0,0 +1,2 @@
export { DeleteTransferListingCommand } from './delete-transfer-listing.command';
export { DeleteTransferListingHandler } from './delete-transfer-listing.handler';

View File

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

View File

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

View File

@@ -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,
) {}
}

View File

@@ -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,
) {}
}

View File

@@ -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,
) {}
}

View File

@@ -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);
}
}

View File

@@ -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');
}
}
}

View File

@@ -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),
);
}
}

View File

@@ -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],
})