diff --git a/apps/api/src/modules/admin/presentation/controllers/admin-moderation.controller.ts b/apps/api/src/modules/admin/presentation/controllers/admin-moderation.controller.ts index 0573761..0175d3f 100644 --- a/apps/api/src/modules/admin/presentation/controllers/admin-moderation.controller.ts +++ b/apps/api/src/modules/admin/presentation/controllers/admin-moderation.controller.ts @@ -2,13 +2,19 @@ import { Body, Controller, Get, + Ip, + Param, Post, Query, UseGuards, } from '@nestjs/common'; import { type CommandBus, type QueryBus } from '@nestjs/cqrs'; -import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiQuery } from '@nestjs/swagger'; +import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiParam, ApiQuery } from '@nestjs/swagger'; import { type JwtPayload, CurrentUser, Roles, JwtAuthGuard, RolesGuard } from '@modules/auth'; +import { + AdminFeatureListingCommand, + type AdminFeatureListingResult, +} from '@modules/listings'; import { ApproveKycCommand } from '../../application/commands/approve-kyc/approve-kyc.command'; import { type ApproveKycResult } from '../../application/commands/approve-kyc/approve-kyc.handler'; import { ApproveListingCommand } from '../../application/commands/approve-listing/approve-listing.command'; @@ -25,6 +31,7 @@ import { type ModerationQueueResult, type KycQueueResult, } from '../../domain/repositories/admin-query.repository'; +import { type AdminFeatureListingDto } from '../dto/admin-feature-listing.dto'; import { type ApproveKycDto } from '../dto/approve-kyc.dto'; import { type ApproveListingDto } from '../dto/approve-listing.dto'; import { type BulkModerateDto } from '../dto/bulk-moderate.dto'; @@ -105,6 +112,33 @@ export class AdminModerationController { ); } + @Post('listings/:id/feature') + @ApiOperation({ + summary: 'Admin: feature or unfeature a listing manually (audited, no payment)', + }) + @ApiParam({ name: 'id', description: 'Listing UUID' }) + @ApiResponse({ status: 201, description: 'Listing featured state updated successfully' }) + @ApiResponse({ status: 400, description: 'Validation error' }) + @ApiResponse({ status: 401, description: 'Unauthorized – missing or invalid JWT' }) + @ApiResponse({ status: 403, description: 'Forbidden – requires ADMIN role' }) + async adminFeatureListing( + @Param('id') id: string, + @Body() dto: AdminFeatureListingDto, + @CurrentUser() user: JwtPayload, + @Ip() ip: string, + ): Promise { + return this.commandBus.execute( + new AdminFeatureListingCommand( + id, + user.sub, + dto.action, + dto.durationDays ?? null, + dto.reason, + ip ?? null, + ), + ); + } + // ── KYC ── @Get('kyc') diff --git a/apps/api/src/modules/admin/presentation/dto/admin-feature-listing.dto.ts b/apps/api/src/modules/admin/presentation/dto/admin-feature-listing.dto.ts new file mode 100644 index 0000000..d776115 --- /dev/null +++ b/apps/api/src/modules/admin/presentation/dto/admin-feature-listing.dto.ts @@ -0,0 +1,36 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { IsIn, IsInt, IsOptional, IsString, MinLength, ValidateIf } from 'class-validator'; + +const ALLOWED_DURATIONS = [3, 7, 14, 30, 60, 90] as const; +export type AdminFeatureDuration = (typeof ALLOWED_DURATIONS)[number]; + +export class AdminFeatureListingDto { + @ApiProperty({ + enum: ['feature', 'unfeature'], + example: 'feature', + description: 'Bật hoặc gỡ tin nổi bật thủ công', + }) + @IsIn(['feature', 'unfeature']) + action!: 'feature' | 'unfeature'; + + @ApiPropertyOptional({ + enum: ALLOWED_DURATIONS, + example: 7, + description: 'Số ngày featured (bắt buộc khi action=feature)', + }) + @ValidateIf((o: AdminFeatureListingDto) => o.action === 'feature') + @Type(() => Number) + @IsInt() + @IsIn([...ALLOWED_DURATIONS]) + @IsOptional() + durationDays?: AdminFeatureDuration; + + @ApiProperty({ + example: 'Đền bù lỗi hiển thị featured trong 3 ngày qua', + description: 'Lý do cho audit log (tối thiểu 5 ký tự)', + }) + @IsString() + @MinLength(5) + reason!: string; +} diff --git a/apps/api/src/modules/listings/application/__tests__/admin-feature-listing.handler.spec.ts b/apps/api/src/modules/listings/application/__tests__/admin-feature-listing.handler.spec.ts new file mode 100644 index 0000000..88390dc --- /dev/null +++ b/apps/api/src/modules/listings/application/__tests__/admin-feature-listing.handler.spec.ts @@ -0,0 +1,131 @@ +import { ListingEntity } from '@modules/listings/domain/entities/listing.entity'; +import { Price } from '@modules/listings/domain/value-objects/price.vo'; +import { AdminFeatureListingCommand } from '../commands/admin-feature-listing/admin-feature-listing.command'; +import { AdminFeatureListingHandler } from '../commands/admin-feature-listing/admin-feature-listing.handler'; + +function createListing( + id = 'listing-1', + status: 'DRAFT' | 'PENDING_REVIEW' | 'ACTIVE' = 'ACTIVE', +): ListingEntity { + const price = Price.create(1_500_000_000n).unwrap(); + const listing = ListingEntity.createNew(id, 'prop-1', 'seller-1', 'SALE', price, 60); + if (status === 'PENDING_REVIEW' || status === 'ACTIVE') listing.submitForReview(); + if (status === 'ACTIVE') listing.approve(); + listing.clearDomainEvents(); + return listing; +} + +describe('AdminFeatureListingHandler', () => { + let handler: AdminFeatureListingHandler; + let mockListingRepo: { findById: ReturnType }; + let mockPrisma: { + $transaction: ReturnType; + listing: { update: ReturnType }; + adminAuditLog: { create: ReturnType }; + }; + let mockLogger: { log: ReturnType; error: ReturnType }; + let transactionOps: unknown[]; + + beforeEach(() => { + transactionOps = []; + mockListingRepo = { findById: vi.fn() }; + const listingUpdate = vi.fn().mockImplementation((args: unknown) => { + transactionOps.push({ kind: 'listing.update', args }); + return { kind: 'listing.update', args }; + }); + const auditLogCreate = vi.fn().mockImplementation((args: unknown) => { + transactionOps.push({ kind: 'audit.create', args }); + return { kind: 'audit.create', args }; + }); + const $transaction = vi.fn().mockImplementation(async (ops: unknown[]) => ops); + mockPrisma = { + $transaction, + listing: { update: listingUpdate }, + adminAuditLog: { create: auditLogCreate }, + }; + mockLogger = { log: vi.fn(), error: vi.fn() }; + + handler = new AdminFeatureListingHandler(mockListingRepo as any, mockPrisma as any, mockLogger as any); + }); + + it('features a listing with durationDays and writes audit log', async () => { + mockListingRepo.findById.mockResolvedValue(createListing('listing-1', 'ACTIVE')); + + const before = Date.now(); + const result = await handler.execute( + new AdminFeatureListingCommand('listing-1', 'admin-1', 'feature', 14, 'Đền bù lỗi hiển thị', '10.0.0.1'), + ); + const after = Date.now(); + + expect(result.action).toBe('feature'); + expect(result.listingId).toBe('listing-1'); + expect(result.featuredUntil).not.toBeNull(); + const parsed = Date.parse(result.featuredUntil!); + expect(parsed).toBeGreaterThanOrEqual(before + 14 * 24 * 60 * 60 * 1000); + expect(parsed).toBeLessThanOrEqual(after + 14 * 24 * 60 * 60 * 1000 + 1000); + + expect(mockPrisma.$transaction).toHaveBeenCalledTimes(1); + const auditOp = transactionOps.find((op: any) => op.kind === 'audit.create') as any; + expect(auditOp.args.data.action).toBe('LISTING_FEATURED'); + expect(auditOp.args.data.actorId).toBe('admin-1'); + expect(auditOp.args.data.targetId).toBe('listing-1'); + expect(auditOp.args.data.targetType).toBe('LISTING'); + expect(auditOp.args.data.metadata.reason).toBe('Đền bù lỗi hiển thị'); + expect(auditOp.args.data.metadata.durationDays).toBe(14); + expect(auditOp.args.data.ipAddress).toBe('10.0.0.1'); + }); + + it('unfeatures a listing and logs LISTING_UNFEATURED', async () => { + mockListingRepo.findById.mockResolvedValue(createListing('listing-1', 'ACTIVE')); + + const result = await handler.execute( + new AdminFeatureListingCommand('listing-1', 'admin-1', 'unfeature', null, 'Vi phạm chính sách nội dung', null), + ); + + expect(result.action).toBe('unfeature'); + expect(result.featuredUntil).toBeNull(); + + const updateOp = transactionOps.find((op: any) => op.kind === 'listing.update') as any; + expect(updateOp.args.data.featuredUntil).toBeNull(); + + const auditOp = transactionOps.find((op: any) => op.kind === 'audit.create') as any; + expect(auditOp.args.data.action).toBe('LISTING_UNFEATURED'); + expect(auditOp.args.data.metadata.featuredUntil).toBeNull(); + }); + + it('rejects short reason', async () => { + mockListingRepo.findById.mockResolvedValue(createListing('listing-1', 'ACTIVE')); + + await expect( + handler.execute(new AdminFeatureListingCommand('listing-1', 'admin-1', 'feature', 7, 'bad', null)), + ).rejects.toThrow(/Lý do/); + + expect(mockPrisma.$transaction).not.toHaveBeenCalled(); + }); + + it('rejects feature action with invalid durationDays', async () => { + mockListingRepo.findById.mockResolvedValue(createListing('listing-1', 'ACTIVE')); + + await expect( + handler.execute(new AdminFeatureListingCommand('listing-1', 'admin-1', 'feature', 5, 'reason long enough', null)), + ).rejects.toThrow(/Thời lượng/); + }); + + it('rejects feature action with null durationDays', async () => { + mockListingRepo.findById.mockResolvedValue(createListing('listing-1', 'ACTIVE')); + + await expect( + handler.execute( + new AdminFeatureListingCommand('listing-1', 'admin-1', 'feature', null, 'reason long enough', null), + ), + ).rejects.toThrow(/Thời lượng/); + }); + + it('throws NotFoundException for non-existent listing', async () => { + mockListingRepo.findById.mockResolvedValue(null); + + await expect( + handler.execute(new AdminFeatureListingCommand('missing', 'admin-1', 'feature', 7, 'reason long enough', null)), + ).rejects.toThrow('Listing'); + }); +}); diff --git a/apps/api/src/modules/listings/application/__tests__/promote-featured-listing.handler.spec.ts b/apps/api/src/modules/listings/application/__tests__/promote-featured-listing.handler.spec.ts new file mode 100644 index 0000000..57303c1 --- /dev/null +++ b/apps/api/src/modules/listings/application/__tests__/promote-featured-listing.handler.spec.ts @@ -0,0 +1,157 @@ +import { ListingEntity } from '@modules/listings/domain/entities/listing.entity'; +import { Price } from '@modules/listings/domain/value-objects/price.vo'; +import { CheckQuotaQuery, MeterUsageCommand } from '@modules/subscriptions'; +import { PromoteFeaturedListingCommand } from '../commands/promote-featured-listing/promote-featured-listing.command'; +import { + FEATURED_LISTINGS_PROMOTED_METRIC, + PromoteFeaturedListingHandler, +} from '../commands/promote-featured-listing/promote-featured-listing.handler'; + +function createListing( + id = 'listing-1', + sellerId = 'seller-1', + agentId: string | null = null, + status: 'DRAFT' | 'PENDING_REVIEW' | 'ACTIVE' = 'ACTIVE', +): ListingEntity { + const price = Price.create(2_000_000_000n).unwrap(); + const listing = ListingEntity.createNew(id, 'prop-1', sellerId, 'SALE', price, 80, agentId ?? undefined); + if (status === 'PENDING_REVIEW' || status === 'ACTIVE') listing.submitForReview(); + if (status === 'ACTIVE') listing.approve(); + listing.clearDomainEvents(); + return listing; +} + +describe('PromoteFeaturedListingHandler', () => { + let handler: PromoteFeaturedListingHandler; + let mockListingRepo: { findById: ReturnType }; + let mockPrisma: { listing: { update: ReturnType } }; + let mockCommandBus: { execute: ReturnType }; + let mockQueryBus: { execute: ReturnType }; + let mockLogger: { log: ReturnType; error: ReturnType }; + + beforeEach(() => { + mockListingRepo = { findById: vi.fn() }; + mockPrisma = { listing: { update: vi.fn().mockResolvedValue(undefined) } }; + mockCommandBus = { execute: vi.fn().mockResolvedValue({ usageRecordId: 'u-1' }) }; + mockQueryBus = { + execute: vi.fn().mockResolvedValue({ + metric: FEATURED_LISTINGS_PROMOTED_METRIC, + limit: 5, + used: 0, + remaining: 5, + allowed: true, + }), + }; + mockLogger = { log: vi.fn(), error: vi.fn() }; + + handler = new PromoteFeaturedListingHandler( + mockListingRepo as any, + mockPrisma as any, + mockCommandBus as any, + mockQueryBus as any, + mockLogger as any, + ); + }); + + it('promotes an active listing when owner has quota', async () => { + mockListingRepo.findById.mockResolvedValue(createListing('listing-1', 'seller-1', null, 'ACTIVE')); + + const before = Date.now(); + const result = await handler.execute( + new PromoteFeaturedListingCommand('listing-1', 'seller-1', 7), + ); + const after = Date.now(); + + expect(result.listingId).toBe('listing-1'); + expect(result.durationDays).toBe(7); + expect(result.quotaRemaining).toBe(4); + + const parsed = Date.parse(result.featuredUntil); + expect(parsed).toBeGreaterThanOrEqual(before + 7 * 24 * 60 * 60 * 1000); + expect(parsed).toBeLessThanOrEqual(after + 7 * 24 * 60 * 60 * 1000 + 1000); + + expect(mockPrisma.listing.update).toHaveBeenCalledTimes(1); + expect(mockCommandBus.execute).toHaveBeenCalledTimes(1); + const meterCall = mockCommandBus.execute.mock.calls[0][0]; + expect(meterCall).toBeInstanceOf(MeterUsageCommand); + expect(meterCall.metric).toBe(FEATURED_LISTINGS_PROMOTED_METRIC); + expect(meterCall.count).toBe(1); + }); + + it('allows the assigned agent to promote', async () => { + mockListingRepo.findById.mockResolvedValue(createListing('listing-1', 'seller-1', 'agent-1', 'ACTIVE')); + + const result = await handler.execute( + new PromoteFeaturedListingCommand('listing-1', 'agent-1', 3), + ); + expect(result.durationDays).toBe(3); + expect(mockPrisma.listing.update).toHaveBeenCalled(); + }); + + it('extends featuredUntil from the existing expiry when still active', async () => { + const listing = createListing('listing-1', 'seller-1', null, 'ACTIVE'); + const future = new Date(Date.now() + 2 * 24 * 60 * 60 * 1000); + (listing as unknown as { _featuredUntil: Date })._featuredUntil = future; + mockListingRepo.findById.mockResolvedValue(listing); + + const result = await handler.execute( + new PromoteFeaturedListingCommand('listing-1', 'seller-1', 7), + ); + + const expected = future.getTime() + 7 * 24 * 60 * 60 * 1000; + expect(Math.abs(Date.parse(result.featuredUntil) - expected)).toBeLessThan(1000); + }); + + it('rejects promote when quota exhausted', async () => { + mockListingRepo.findById.mockResolvedValue(createListing('listing-1', 'seller-1', null, 'ACTIVE')); + mockQueryBus.execute.mockResolvedValue({ + metric: FEATURED_LISTINGS_PROMOTED_METRIC, + limit: 5, + used: 5, + remaining: 0, + allowed: false, + }); + + await expect( + handler.execute(new PromoteFeaturedListingCommand('listing-1', 'seller-1', 7)), + ).rejects.toThrow(/Đã dùng hết|nâng cấp/); + + expect(mockPrisma.listing.update).not.toHaveBeenCalled(); + expect(mockCommandBus.execute).not.toHaveBeenCalled(); + }); + + it('rejects non-owner / non-agent', async () => { + mockListingRepo.findById.mockResolvedValue(createListing('listing-1', 'seller-1', null, 'ACTIVE')); + + await expect( + handler.execute(new PromoteFeaturedListingCommand('listing-1', 'stranger', 7)), + ).rejects.toThrow(/người bán|môi giới/); + }); + + it('rejects non-ACTIVE listing', async () => { + mockListingRepo.findById.mockResolvedValue(createListing('listing-1', 'seller-1', null, 'DRAFT')); + + await expect( + handler.execute(new PromoteFeaturedListingCommand('listing-1', 'seller-1', 7)), + ).rejects.toThrow(/hoạt động/); + }); + + it('rejects invalid durationDays', async () => { + mockListingRepo.findById.mockResolvedValue(createListing('listing-1', 'seller-1', null, 'ACTIVE')); + + await expect( + handler.execute(new PromoteFeaturedListingCommand('listing-1', 'seller-1', 5 as unknown as 3)), + ).rejects.toThrow(/Thời lượng/); + }); + + it('passes CheckQuotaQuery with the featured_listings_promoted metric', async () => { + mockListingRepo.findById.mockResolvedValue(createListing('listing-1', 'seller-1', null, 'ACTIVE')); + + await handler.execute(new PromoteFeaturedListingCommand('listing-1', 'seller-1', 7)); + + const queryArg = mockQueryBus.execute.mock.calls[0][0]; + expect(queryArg).toBeInstanceOf(CheckQuotaQuery); + expect(queryArg.metric).toBe(FEATURED_LISTINGS_PROMOTED_METRIC); + expect(queryArg.userId).toBe('seller-1'); + }); +}); diff --git a/apps/api/src/modules/listings/application/commands/admin-feature-listing/admin-feature-listing.command.ts b/apps/api/src/modules/listings/application/commands/admin-feature-listing/admin-feature-listing.command.ts new file mode 100644 index 0000000..75167b6 --- /dev/null +++ b/apps/api/src/modules/listings/application/commands/admin-feature-listing/admin-feature-listing.command.ts @@ -0,0 +1,12 @@ +export type AdminFeatureAction = 'feature' | 'unfeature'; + +export class AdminFeatureListingCommand { + constructor( + public readonly listingId: string, + public readonly adminId: string, + public readonly action: AdminFeatureAction, + public readonly durationDays: number | null, + public readonly reason: string, + public readonly ipAddress: string | null, + ) {} +} diff --git a/apps/api/src/modules/listings/application/commands/admin-feature-listing/admin-feature-listing.handler.ts b/apps/api/src/modules/listings/application/commands/admin-feature-listing/admin-feature-listing.handler.ts new file mode 100644 index 0000000..db664c7 --- /dev/null +++ b/apps/api/src/modules/listings/application/commands/admin-feature-listing/admin-feature-listing.handler.ts @@ -0,0 +1,99 @@ +import { Inject, InternalServerErrorException } from '@nestjs/common'; +import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs'; +import { + DomainException, + NotFoundException, + ValidationException, + type LoggerService, + type PrismaService, +} from '@modules/shared'; +import { LISTING_REPOSITORY, type IListingRepository } from '../../../domain/repositories/listing.repository'; +import { AdminFeatureListingCommand } from './admin-feature-listing.command'; + +const ALLOWED_DURATIONS = new Set([3, 7, 14, 30, 60, 90]); + +export interface AdminFeatureListingResult { + listingId: string; + featuredUntil: string | null; + action: 'feature' | 'unfeature'; +} + +@CommandHandler(AdminFeatureListingCommand) +export class AdminFeatureListingHandler + implements ICommandHandler +{ + constructor( + @Inject(LISTING_REPOSITORY) private readonly listingRepo: IListingRepository, + private readonly prisma: PrismaService, + private readonly logger: LoggerService, + ) {} + + async execute(command: AdminFeatureListingCommand): Promise { + try { + if (!command.reason || command.reason.trim().length < 5) { + throw new ValidationException('Lý do phải tối thiểu 5 ký tự', { reason: command.reason }); + } + + const listing = await this.listingRepo.findById(command.listingId); + if (!listing) { + throw new NotFoundException('Listing', command.listingId); + } + + let featuredUntil: Date | null; + if (command.action === 'feature') { + if (command.durationDays === null || !ALLOWED_DURATIONS.has(command.durationDays)) { + throw new ValidationException('Thời lượng không hợp lệ', { + durationDays: command.durationDays, + allowed: Array.from(ALLOWED_DURATIONS), + }); + } + const now = new Date(); + const baseDate = + listing.featuredUntil && listing.featuredUntil > now ? listing.featuredUntil : now; + featuredUntil = new Date(baseDate.getTime() + command.durationDays * 24 * 60 * 60 * 1000); + } else { + featuredUntil = null; + } + + await this.prisma.$transaction([ + this.prisma.listing.update({ + where: { id: command.listingId }, + data: { featuredUntil }, + }), + this.prisma.adminAuditLog.create({ + data: { + action: command.action === 'feature' ? 'LISTING_FEATURED' : 'LISTING_UNFEATURED', + actorId: command.adminId, + targetId: command.listingId, + targetType: 'LISTING', + metadata: { + reason: command.reason, + durationDays: command.durationDays, + featuredUntil: featuredUntil?.toISOString() ?? null, + }, + ipAddress: command.ipAddress, + }, + }), + ]); + + this.logger.log( + `Admin ${command.action}: listing=${command.listingId}, admin=${command.adminId}, featuredUntil=${featuredUntil?.toISOString() ?? 'null'}`, + 'AdminFeatureListingHandler', + ); + + return { + listingId: command.listingId, + featuredUntil: featuredUntil ? featuredUntil.toISOString() : null, + action: command.action, + }; + } catch (error) { + if (error instanceof DomainException) throw error; + this.logger.error( + `Failed to admin-feature listing: ${error instanceof Error ? error.message : error}`, + error instanceof Error ? error.stack : undefined, + this.constructor.name, + ); + throw new InternalServerErrorException('Không thể cập nhật trạng thái nổi bật'); + } + } +} diff --git a/apps/api/src/modules/listings/application/commands/promote-featured-listing/promote-featured-listing.command.ts b/apps/api/src/modules/listings/application/commands/promote-featured-listing/promote-featured-listing.command.ts new file mode 100644 index 0000000..5b04945 --- /dev/null +++ b/apps/api/src/modules/listings/application/commands/promote-featured-listing/promote-featured-listing.command.ts @@ -0,0 +1,11 @@ +export type PromoteFeaturedDuration = 3 | 7 | 14 | 30; + +export const PROMOTE_FEATURED_DURATION_VALUES: readonly PromoteFeaturedDuration[] = [3, 7, 14, 30]; + +export class PromoteFeaturedListingCommand { + constructor( + public readonly listingId: string, + public readonly userId: string, + public readonly durationDays: PromoteFeaturedDuration, + ) {} +} diff --git a/apps/api/src/modules/listings/application/commands/promote-featured-listing/promote-featured-listing.handler.ts b/apps/api/src/modules/listings/application/commands/promote-featured-listing/promote-featured-listing.handler.ts new file mode 100644 index 0000000..077c93b --- /dev/null +++ b/apps/api/src/modules/listings/application/commands/promote-featured-listing/promote-featured-listing.handler.ts @@ -0,0 +1,117 @@ +import { Inject, InternalServerErrorException } from '@nestjs/common'; +import { CommandHandler, type CommandBus, type ICommandHandler, type QueryBus } from '@nestjs/cqrs'; +import { + DomainException, + ForbiddenException, + NotFoundException, + ValidationException, + type LoggerService, + type PrismaService, +} from '@modules/shared'; +import { + CheckQuotaQuery, + MeterUsageCommand, + type QuotaCheckResult, +} from '@modules/subscriptions'; +import { LISTING_REPOSITORY, type IListingRepository } from '../../../domain/repositories/listing.repository'; +import { + type PromoteFeaturedDuration, + PROMOTE_FEATURED_DURATION_VALUES, + PromoteFeaturedListingCommand, +} from './promote-featured-listing.command'; + +export const FEATURED_LISTINGS_PROMOTED_METRIC = 'featured_listings_promoted'; + +export interface PromoteFeaturedListingResult { + listingId: string; + featuredUntil: string; + durationDays: PromoteFeaturedDuration; + quotaRemaining: number | null; +} + +@CommandHandler(PromoteFeaturedListingCommand) +export class PromoteFeaturedListingHandler + implements ICommandHandler +{ + constructor( + @Inject(LISTING_REPOSITORY) private readonly listingRepo: IListingRepository, + private readonly prisma: PrismaService, + private readonly commandBus: CommandBus, + private readonly queryBus: QueryBus, + private readonly logger: LoggerService, + ) {} + + async execute(command: PromoteFeaturedListingCommand): Promise { + try { + if (!PROMOTE_FEATURED_DURATION_VALUES.includes(command.durationDays)) { + throw new ValidationException('Thời lượng không hợp lệ', { + durationDays: command.durationDays, + allowed: PROMOTE_FEATURED_DURATION_VALUES, + }); + } + + const listing = await this.listingRepo.findById(command.listingId); + if (!listing) { + throw new NotFoundException('Listing', command.listingId); + } + + if (listing.sellerId !== command.userId && listing.agentId !== command.userId) { + throw new ForbiddenException('Chỉ người bán hoặc môi giới mới có thể đẩy tin nổi bật'); + } + + if (listing.status !== 'ACTIVE') { + throw new ValidationException('Chỉ tin đăng đang hoạt động mới có thể đẩy nổi bật', { + status: listing.status, + }); + } + + const quota: QuotaCheckResult = await this.queryBus.execute( + new CheckQuotaQuery(command.userId, FEATURED_LISTINGS_PROMOTED_METRIC), + ); + + if (!quota.allowed) { + throw new ForbiddenException( + `Đã dùng hết lượt đẩy tin nổi bật trong gói (${quota.used}/${quota.limit}). Vui lòng nâng cấp gói để tiếp tục.`, + ); + } + + const now = new Date(); + const baseDate = + listing.featuredUntil && listing.featuredUntil > now ? listing.featuredUntil : now; + const featuredUntil = new Date( + baseDate.getTime() + command.durationDays * 24 * 60 * 60 * 1000, + ); + + await this.prisma.listing.update({ + where: { id: command.listingId }, + data: { featuredUntil }, + }); + + await this.commandBus.execute( + new MeterUsageCommand(command.userId, FEATURED_LISTINGS_PROMOTED_METRIC, 1), + ); + + const newRemaining = quota.remaining === null ? null : Math.max(0, quota.remaining - 1); + + this.logger.log( + `Featured listing promoted via entitlement: listing=${command.listingId}, user=${command.userId}, until=${featuredUntil.toISOString()}, days=${command.durationDays}`, + 'PromoteFeaturedListingHandler', + ); + + return { + listingId: command.listingId, + featuredUntil: featuredUntil.toISOString(), + durationDays: command.durationDays, + quotaRemaining: newRemaining, + }; + } catch (error) { + if (error instanceof DomainException) throw error; + this.logger.error( + `Failed to promote featured listing: ${error instanceof Error ? error.message : error}`, + error instanceof Error ? error.stack : undefined, + this.constructor.name, + ); + throw new InternalServerErrorException('Không thể đẩy tin nổi bật'); + } + } +} diff --git a/apps/api/src/modules/listings/index.ts b/apps/api/src/modules/listings/index.ts index ab2792f..aadcd46 100644 --- a/apps/api/src/modules/listings/index.ts +++ b/apps/api/src/modules/listings/index.ts @@ -2,6 +2,19 @@ export { ListingsModule } from './listings.module'; export { ListingEntity, type ListingProps } from './domain/entities/listing.entity'; export { ListingCreatedEvent } from './domain/events/listing-created.event'; export { ModerateListingCommand } from './application/commands/moderate-listing/moderate-listing.command'; +export { + AdminFeatureListingCommand, + type AdminFeatureAction, +} from './application/commands/admin-feature-listing/admin-feature-listing.command'; +export { type AdminFeatureListingResult } from './application/commands/admin-feature-listing/admin-feature-listing.handler'; +export { + PromoteFeaturedListingCommand, + type PromoteFeaturedDuration, +} from './application/commands/promote-featured-listing/promote-featured-listing.command'; +export { + type PromoteFeaturedListingResult, + FEATURED_LISTINGS_PROMOTED_METRIC, +} from './application/commands/promote-featured-listing/promote-featured-listing.handler'; export { LISTING_REPOSITORY, IListingRepository, type ListingSearchParams, type PaginatedResult } from './domain/repositories/listing.repository'; export { ListingPriceChangedEvent } from './domain/events/listing-price-changed.event'; export { ListingSoldEvent } from './domain/events/listing-sold.event'; diff --git a/apps/api/src/modules/listings/listings.module.ts b/apps/api/src/modules/listings/listings.module.ts index 78d1957..1b10f8f 100644 --- a/apps/api/src/modules/listings/listings.module.ts +++ b/apps/api/src/modules/listings/listings.module.ts @@ -1,9 +1,11 @@ import { Module } from '@nestjs/common'; import { CqrsModule } from '@nestjs/cqrs'; import { MulterModule } from '@nestjs/platform-express'; +import { AdminFeatureListingHandler } from './application/commands/admin-feature-listing/admin-feature-listing.handler'; import { CreateListingHandler } from './application/commands/create-listing/create-listing.handler'; import { FeatureListingHandler } from './application/commands/feature-listing/feature-listing.handler'; import { ModerateListingHandler } from './application/commands/moderate-listing/moderate-listing.handler'; +import { PromoteFeaturedListingHandler } from './application/commands/promote-featured-listing/promote-featured-listing.handler'; import { UpdateListingHandler } from './application/commands/update-listing/update-listing.handler'; import { UpdateListingStatusHandler } from './application/commands/update-listing-status/update-listing-status.handler'; import { UploadMediaHandler } from './application/commands/upload-media/upload-media.handler'; @@ -28,6 +30,8 @@ import { ListingsController } from './presentation/controllers/listings.controll const CommandHandlers = [ CreateListingHandler, FeatureListingHandler, + PromoteFeaturedListingHandler, + AdminFeatureListingHandler, UpdateListingHandler, UpdateListingStatusHandler, UploadMediaHandler, diff --git a/apps/api/src/modules/listings/presentation/controllers/listings.controller.ts b/apps/api/src/modules/listings/presentation/controllers/listings.controller.ts index 1fcb278..a083a13 100644 --- a/apps/api/src/modules/listings/presentation/controllers/listings.controller.ts +++ b/apps/api/src/modules/listings/presentation/controllers/listings.controller.ts @@ -33,6 +33,8 @@ import type { CreateListingResult } from '../../application/commands/create-list import { FeatureListingCommand } from '../../application/commands/feature-listing/feature-listing.command'; import type { FeatureListingResult } from '../../application/commands/feature-listing/feature-listing.handler'; import { ModerateListingCommand } from '../../application/commands/moderate-listing/moderate-listing.command'; +import { PromoteFeaturedListingCommand } from '../../application/commands/promote-featured-listing/promote-featured-listing.command'; +import type { PromoteFeaturedListingResult } from '../../application/commands/promote-featured-listing/promote-featured-listing.handler'; import { UpdateListingCommand } from '../../application/commands/update-listing/update-listing.command'; import type { UpdateListingResult } from '../../application/commands/update-listing/update-listing.handler'; import { UpdateListingStatusCommand } from '../../application/commands/update-listing-status/update-listing-status.command'; @@ -47,6 +49,7 @@ import type { PaginatedResult } from '../../domain/repositories/listing.reposito import type { CreateListingDto } from '../dto/create-listing.dto'; import type { FeatureListingDto } from '../dto/feature-listing.dto'; import type { ModerateListingDto } from '../dto/moderate-listing.dto'; +import type { PromoteFeaturedListingDto } from '../dto/promote-featured-listing.dto'; import { type SearchListingsDto } from '../dto/search-listings.dto'; import type { UpdateListingStatusDto } from '../dto/update-listing-status.dto'; import type { UpdateListingDto } from '../dto/update-listing.dto'; @@ -319,4 +322,28 @@ export class ListingsController { new FeatureListingCommand(id, user.sub, dto.package, dto.provider, dto.returnUrl, ip), ); } + + @ApiBearerAuth('JWT') + @ApiOperation({ + summary: 'Promote a listing via subscription entitlement (no payment)', + description: + 'Sử dụng quota `featured_listings_promoted` của subscription để bật featured không qua thanh toán.', + }) + @ApiParam({ name: 'id', description: 'Listing UUID' }) + @ApiResponse({ status: 201, description: 'Listing promoted successfully' }) + @ApiResponse({ status: 400, description: 'Invalid duration or listing not ACTIVE' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ status: 403, description: 'Not owner/agent or quota exhausted' }) + @UseGuards(JwtAuthGuard, QuotaGuard) + @RequireQuota('featured_listings_promoted') + @Post(':id/promote') + async promoteListing( + @Param('id') id: string, + @Body() dto: PromoteFeaturedListingDto, + @CurrentUser() user: JwtPayload, + ): Promise { + return this.commandBus.execute( + new PromoteFeaturedListingCommand(id, user.sub, dto.durationDays), + ); + } } diff --git a/apps/api/src/modules/listings/presentation/dto/promote-featured-listing.dto.ts b/apps/api/src/modules/listings/presentation/dto/promote-featured-listing.dto.ts new file mode 100644 index 0000000..413786d --- /dev/null +++ b/apps/api/src/modules/listings/presentation/dto/promote-featured-listing.dto.ts @@ -0,0 +1,18 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { IsIn, IsInt } from 'class-validator'; +import { type PromoteFeaturedDuration } from '../../application/commands/promote-featured-listing/promote-featured-listing.command'; + +const ALLOWED_DURATIONS: readonly number[] = [3, 7, 14, 30]; + +export class PromoteFeaturedListingDto { + @ApiProperty({ + enum: ALLOWED_DURATIONS, + example: 7, + description: 'Số ngày đẩy nổi bật (dùng quota subscription, không phát sinh thanh toán)', + }) + @Type(() => Number) + @IsInt() + @IsIn([...ALLOWED_DURATIONS]) + durationDays!: PromoteFeaturedDuration; +} diff --git a/apps/api/src/modules/search/application/__tests__/search-properties.handler.spec.ts b/apps/api/src/modules/search/application/__tests__/search-properties.handler.spec.ts index e453e7f..e26681c 100644 --- a/apps/api/src/modules/search/application/__tests__/search-properties.handler.spec.ts +++ b/apps/api/src/modules/search/application/__tests__/search-properties.handler.spec.ts @@ -107,4 +107,41 @@ describe('SearchPropertiesHandler', () => { const searchCall = mockSearchRepo.search.mock.calls[0]![0]; expect(searchCall.filterBy).toContain('areaM2:<=200'); }); + + it('applies featured=true filter as isFeatured:=1', async () => { + mockSearchRepo.search.mockResolvedValue(createMockSearchResult()); + + const query = new SearchPropertiesQuery( + undefined, undefined, undefined, undefined, undefined, + undefined, undefined, undefined, undefined, undefined, + undefined, undefined, undefined, true, + ); + await handler.execute(query); + + const searchCall = mockSearchRepo.search.mock.calls[0]![0]; + expect(searchCall.filterBy).toContain('isFeatured:=1'); + }); + + it('applies featured=false filter as isFeatured:=0', async () => { + mockSearchRepo.search.mockResolvedValue(createMockSearchResult()); + + const query = new SearchPropertiesQuery( + undefined, undefined, undefined, undefined, undefined, + undefined, undefined, undefined, undefined, undefined, + undefined, undefined, undefined, false, + ); + await handler.execute(query); + + const searchCall = mockSearchRepo.search.mock.calls[0]![0]; + expect(searchCall.filterBy).toContain('isFeatured:=0'); + }); + + it('omits isFeatured filter when featured is undefined', async () => { + mockSearchRepo.search.mockResolvedValue(createMockSearchResult()); + + await handler.execute(new SearchPropertiesQuery('anything')); + + const searchCall = mockSearchRepo.search.mock.calls[0]![0]; + expect(searchCall.filterBy).not.toContain('isFeatured'); + }); }); diff --git a/apps/api/src/modules/search/application/queries/search-properties/search-properties.handler.ts b/apps/api/src/modules/search/application/queries/search-properties/search-properties.handler.ts index fde6ab8..4bcd515 100644 --- a/apps/api/src/modules/search/application/queries/search-properties/search-properties.handler.ts +++ b/apps/api/src/modules/search/application/queries/search-properties/search-properties.handler.ts @@ -49,6 +49,11 @@ export class SearchPropertiesHandler implements IQueryHandler { + if (value === undefined || value === null || value === '') return undefined; + if (typeof value === 'boolean') return value; + const normalized = String(value).toLowerCase(); + if (normalized === 'true' || normalized === '1') return true; + if (normalized === 'false' || normalized === '0') return false; + return value; + }) + @IsBoolean() + featured?: boolean; + @ApiPropertyOptional({ description: 'Sort order', enum: SortByOption, example: SortByOption.PRICE_ASC }) @IsOptional() @IsEnum(SortByOption) diff --git a/apps/api/src/modules/subscriptions/application/queries/check-quota/check-quota.handler.ts b/apps/api/src/modules/subscriptions/application/queries/check-quota/check-quota.handler.ts index 66176b9..115906f 100644 --- a/apps/api/src/modules/subscriptions/application/queries/check-quota/check-quota.handler.ts +++ b/apps/api/src/modules/subscriptions/application/queries/check-quota/check-quota.handler.ts @@ -21,6 +21,7 @@ const METRIC_TO_PLAN_FIELD: Record = { searches_saved: 'maxSavedSearches', analytics_queries: 'maxAnalyticsQueries', media_uploads: 'maxMediaUploads', + featured_listings_promoted: 'featuredListingsQuota', }; @QueryHandler(CheckQuotaQuery) diff --git a/prisma/migrations/20260418000000_add_featured_listings_quota/migration.sql b/prisma/migrations/20260418000000_add_featured_listings_quota/migration.sql new file mode 100644 index 0000000..6705696 --- /dev/null +++ b/prisma/migrations/20260418000000_add_featured_listings_quota/migration.sql @@ -0,0 +1,12 @@ +-- AlterTable +ALTER TABLE "Plan" ADD COLUMN "featuredListingsQuota" INTEGER; + +-- Seed defaults per tier (keep in sync with prisma/seed.ts) +UPDATE "Plan" SET "featuredListingsQuota" = 0 WHERE "tier" = 'FREE' AND "featuredListingsQuota" IS NULL; +UPDATE "Plan" SET "featuredListingsQuota" = 5 WHERE "tier" = 'AGENT_PRO' AND "featuredListingsQuota" IS NULL; +UPDATE "Plan" SET "featuredListingsQuota" = 10 WHERE "tier" = 'INVESTOR' AND "featuredListingsQuota" IS NULL; +-- ENTERPRISE intentionally left NULL (treated as unlimited by CheckQuotaHandler) + +-- AlterEnum: admin audit actions for featured listings +ALTER TYPE "AdminAction" ADD VALUE IF NOT EXISTS 'LISTING_FEATURED'; +ALTER TYPE "AdminAction" ADD VALUE IF NOT EXISTS 'LISTING_UNFEATURED'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 8c7c03a..6b32dde 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -618,18 +618,19 @@ enum SubscriptionStatus { } model Plan { - id String @id @default(cuid()) - tier PlanTier @unique - name String - priceMonthlyVND BigInt - priceYearlyVND BigInt - maxListings Int? - maxSavedSearches Int? - maxAnalyticsQueries Int? - maxReports Int? - maxMediaUploads Int? - features Json - isActive Boolean @default(true) + id String @id @default(cuid()) + tier PlanTier @unique + name String + priceMonthlyVND BigInt + priceYearlyVND BigInt + maxListings Int? + maxSavedSearches Int? + maxAnalyticsQueries Int? + maxReports Int? + maxMediaUploads Int? + featuredListingsQuota Int? + features Json + isActive Boolean @default(true) subscriptions Subscription[] } @@ -766,6 +767,8 @@ enum AdminAction { LISTING_REJECTED LISTING_BULK_APPROVED LISTING_BULK_REJECTED + LISTING_FEATURED + LISTING_UNFEATURED USER_BANNED USER_UNBANNED USER_STATUS_UPDATED diff --git a/scripts/seed-plans.ts b/scripts/seed-plans.ts index e9e5edb..ead9abe 100644 --- a/scripts/seed-plans.ts +++ b/scripts/seed-plans.ts @@ -23,6 +23,7 @@ export const PLANS = [ maxSavedSearches: 5, maxAnalyticsQueries: 0, maxMediaUploads: 5, + featuredListingsQuota: 0, features: { basicSearch: true, listingPost: true, @@ -42,6 +43,7 @@ export const PLANS = [ maxSavedSearches: 30, maxAnalyticsQueries: 100, maxMediaUploads: 150, + featuredListingsQuota: 5, features: { basicSearch: true, listingPost: true, @@ -63,6 +65,7 @@ export const PLANS = [ maxSavedSearches: 100, maxAnalyticsQueries: 500, maxMediaUploads: 60, + featuredListingsQuota: 10, features: { basicSearch: true, listingPost: true, @@ -85,6 +88,7 @@ export const PLANS = [ maxSavedSearches: null, maxAnalyticsQueries: null, maxMediaUploads: null, + featuredListingsQuota: null, features: { basicSearch: true, listingPost: true, @@ -119,6 +123,7 @@ async function seedPlans() { maxSavedSearches: plan.maxSavedSearches, maxAnalyticsQueries: plan.maxAnalyticsQueries, maxMediaUploads: plan.maxMediaUploads, + featuredListingsQuota: plan.featuredListingsQuota, features: plan.features, }, create: plan,