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 { Inject } from '@nestjs/common';
|
||||||
import { type ICommandHandler, CommandHandler } from '@nestjs/cqrs';
|
import { type ICommandHandler, CommandHandler } from '@nestjs/cqrs';
|
||||||
import { createId } from '@paralleldrive/cuid2';
|
import { createId } from '@paralleldrive/cuid2';
|
||||||
|
import { EventBusService } from '@modules/shared';
|
||||||
import { TransferListingEntity } from '../../../domain/entities/transfer-listing.entity';
|
import { TransferListingEntity } from '../../../domain/entities/transfer-listing.entity';
|
||||||
|
import { TransferListingCreatedEvent } from '../../../domain/events';
|
||||||
import {
|
import {
|
||||||
TRANSFER_LISTING_REPOSITORY,
|
TRANSFER_LISTING_REPOSITORY,
|
||||||
type ITransferListingRepository,
|
type ITransferListingRepository,
|
||||||
@@ -13,6 +15,7 @@ export class CreateTransferListingHandler implements ICommandHandler<CreateTrans
|
|||||||
constructor(
|
constructor(
|
||||||
@Inject(TRANSFER_LISTING_REPOSITORY)
|
@Inject(TRANSFER_LISTING_REPOSITORY)
|
||||||
private readonly repo: ITransferListingRepository,
|
private readonly repo: ITransferListingRepository,
|
||||||
|
private readonly eventBus: EventBusService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async execute(cmd: CreateTransferListingCommand): Promise<{ id: string }> {
|
async execute(cmd: CreateTransferListingCommand): Promise<{ id: string }> {
|
||||||
@@ -55,6 +58,11 @@ export class CreateTransferListingHandler implements ICommandHandler<CreateTrans
|
|||||||
}, now, now);
|
}, now, now);
|
||||||
|
|
||||||
await this.repo.save(entity, cmd.items);
|
await this.repo.save(entity, cmd.items);
|
||||||
|
|
||||||
|
this.eventBus.publish(
|
||||||
|
new TransferListingCreatedEvent(id, cmd.sellerId, cmd.category),
|
||||||
|
);
|
||||||
|
|
||||||
return { id };
|
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 { Inject } from '@nestjs/common';
|
||||||
import { type ICommandHandler, CommandHandler } from '@nestjs/cqrs';
|
import { type ICommandHandler, CommandHandler } from '@nestjs/cqrs';
|
||||||
import { NotFoundException } from '@modules/shared';
|
import { EventBusService, NotFoundException } from '@modules/shared';
|
||||||
|
import { TransferListingUpdatedEvent } from '../../../domain/events';
|
||||||
import {
|
import {
|
||||||
TRANSFER_LISTING_REPOSITORY,
|
TRANSFER_LISTING_REPOSITORY,
|
||||||
type ITransferListingRepository,
|
type ITransferListingRepository,
|
||||||
@@ -12,6 +13,7 @@ export class UpdateTransferListingHandler implements ICommandHandler<UpdateTrans
|
|||||||
constructor(
|
constructor(
|
||||||
@Inject(TRANSFER_LISTING_REPOSITORY)
|
@Inject(TRANSFER_LISTING_REPOSITORY)
|
||||||
private readonly repo: ITransferListingRepository,
|
private readonly repo: ITransferListingRepository,
|
||||||
|
private readonly eventBus: EventBusService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async execute(cmd: UpdateTransferListingCommand): Promise<{ id: string }> {
|
async execute(cmd: UpdateTransferListingCommand): Promise<{ id: string }> {
|
||||||
@@ -38,6 +40,9 @@ export class UpdateTransferListingHandler implements ICommandHandler<UpdateTrans
|
|||||||
});
|
});
|
||||||
|
|
||||||
await this.repo.update(entity);
|
await this.repo.update(entity);
|
||||||
|
|
||||||
|
this.eventBus.publish(new TransferListingUpdatedEvent(cmd.id));
|
||||||
|
|
||||||
return { id: 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);
|
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 { type CommandBus, type QueryBus } from '@nestjs/cqrs';
|
||||||
import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||||
import { JwtAuthGuard, CurrentUser } from '@modules/auth';
|
import { JwtAuthGuard, CurrentUser } from '@modules/auth';
|
||||||
import { EndpointRateLimit, EndpointRateLimitGuard, NotFoundException } from '@modules/shared';
|
import { EndpointRateLimit, EndpointRateLimitGuard, NotFoundException } from '@modules/shared';
|
||||||
import { CreateTransferListingCommand } from '../../application/commands/create-transfer-listing/create-transfer-listing.command';
|
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 { EstimateFromPhotosCommand } from '../../application/commands/estimate-from-photos/estimate-from-photos.command';
|
||||||
import { EstimateTransferPricesCommand } from '../../application/commands/estimate-transfer-prices/estimate-transfer-prices.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';
|
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 { CqrsModule } from '@nestjs/cqrs';
|
||||||
import { SearchModule } from '@modules/search';
|
import { SearchModule } from '@modules/search';
|
||||||
import { CreateTransferListingHandler } from './application/commands/create-transfer-listing/create-transfer-listing.handler';
|
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 { EstimateFromPhotosHandler } from './application/commands/estimate-from-photos/estimate-from-photos.handler';
|
||||||
import { EstimateTransferPricesHandler } from './application/commands/estimate-transfer-prices/estimate-transfer-prices.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';
|
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 { ListTransferListingsHandler } from './application/queries/list-transfer-listings/list-transfer-listings.handler';
|
||||||
import { TransferStatsHandler } from './application/queries/transfer-stats/transfer-stats.handler';
|
import { TransferStatsHandler } from './application/queries/transfer-stats/transfer-stats.handler';
|
||||||
import { TRANSFER_LISTING_REPOSITORY } from './domain/repositories/transfer-listing.repository';
|
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 { PrismaTransferListingRepository } from './infrastructure/repositories/prisma-transfer-listing.repository';
|
||||||
import { CLAUDE_VISION_SERVICE, ClaudeVisionService } from './infrastructure/services/claude-vision.service';
|
import { CLAUDE_VISION_SERVICE, ClaudeVisionService } from './infrastructure/services/claude-vision.service';
|
||||||
import { TypesenseTransferService } from './infrastructure/services/typesense-transfer.service';
|
import { TypesenseTransferService } from './infrastructure/services/typesense-transfer.service';
|
||||||
@@ -16,6 +18,7 @@ import { TransferController } from './presentation/controllers/transfer.controll
|
|||||||
|
|
||||||
const CommandHandlers = [
|
const CommandHandlers = [
|
||||||
CreateTransferListingHandler,
|
CreateTransferListingHandler,
|
||||||
|
DeleteTransferListingHandler,
|
||||||
EstimateFromPhotosHandler,
|
EstimateFromPhotosHandler,
|
||||||
EstimateTransferPricesHandler,
|
EstimateTransferPricesHandler,
|
||||||
UpdateTransferListingHandler,
|
UpdateTransferListingHandler,
|
||||||
@@ -27,6 +30,10 @@ const QueryHandlers = [
|
|||||||
TransferStatsHandler,
|
TransferStatsHandler,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const EventHandlers = [
|
||||||
|
TransferListingTypesenseHandler,
|
||||||
|
];
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [CqrsModule, SearchModule],
|
imports: [CqrsModule, SearchModule],
|
||||||
controllers: [TransferController],
|
controllers: [TransferController],
|
||||||
@@ -36,6 +43,7 @@ const QueryHandlers = [
|
|||||||
TypesenseTransferService,
|
TypesenseTransferService,
|
||||||
...CommandHandlers,
|
...CommandHandlers,
|
||||||
...QueryHandlers,
|
...QueryHandlers,
|
||||||
|
...EventHandlers,
|
||||||
],
|
],
|
||||||
exports: [TRANSFER_LISTING_REPOSITORY, TypesenseTransferService],
|
exports: [TRANSFER_LISTING_REPOSITORY, TypesenseTransferService],
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user