feat(listings): R2.3 featured listings entitlement + admin promote + search filter (TEC-2754)

- Add Plan.featuredListingsQuota (Int?) with per-tier seed (FREE=0, AGENT_PRO=5, INVESTOR=10, ENTERPRISE unlimited) and migration 20260418000000_add_featured_listings_quota
- Wire featured_listings_promoted metric into CheckQuotaHandler METRIC_TO_PLAN_FIELD so QuotaGuard honors the new quota
- Add PromoteFeaturedListingCommand + handler (entitlement-based, no payment): verifies ownership/agent, checks quota, extends featuredUntil, meters usage
- Add POST /listings/:id/promote endpoint gated by @RequireQuota('featured_listings_promoted') + QuotaGuard
- Add AdminFeatureListingCommand + handler with LISTING_FEATURED / LISTING_UNFEATURED audit log entries (new AdminAction enum values) and transactional write
- Add POST /admin/moderation/listings/:id/feature endpoint (ADMIN-only) with reason + duration
- Expose featured?: boolean filter on SearchPropertiesDto -> isFeatured:=1|0 Typesense filter in SearchPropertiesHandler
- Unit tests: 8 for PromoteFeaturedListingHandler, 6 for AdminFeatureListingHandler, 3 for search featured filter

Keeps existing pay-per-feature FeatureListingHandler intact for backward compatibility.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-18 15:18:04 +07:00
parent 580eb2a261
commit 5731577fa9
21 changed files with 755 additions and 13 deletions

View File

@@ -2,13 +2,19 @@ import {
Body, Body,
Controller, Controller,
Get, Get,
Ip,
Param,
Post, Post,
Query, Query,
UseGuards, UseGuards,
} from '@nestjs/common'; } from '@nestjs/common';
import { type CommandBus, type QueryBus } from '@nestjs/cqrs'; 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 { 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 { ApproveKycCommand } from '../../application/commands/approve-kyc/approve-kyc.command';
import { type ApproveKycResult } from '../../application/commands/approve-kyc/approve-kyc.handler'; import { type ApproveKycResult } from '../../application/commands/approve-kyc/approve-kyc.handler';
import { ApproveListingCommand } from '../../application/commands/approve-listing/approve-listing.command'; import { ApproveListingCommand } from '../../application/commands/approve-listing/approve-listing.command';
@@ -25,6 +31,7 @@ import {
type ModerationQueueResult, type ModerationQueueResult,
type KycQueueResult, type KycQueueResult,
} from '../../domain/repositories/admin-query.repository'; } 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 ApproveKycDto } from '../dto/approve-kyc.dto';
import { type ApproveListingDto } from '../dto/approve-listing.dto'; import { type ApproveListingDto } from '../dto/approve-listing.dto';
import { type BulkModerateDto } from '../dto/bulk-moderate.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<AdminFeatureListingResult> {
return this.commandBus.execute(
new AdminFeatureListingCommand(
id,
user.sub,
dto.action,
dto.durationDays ?? null,
dto.reason,
ip ?? null,
),
);
}
// ── KYC ── // ── KYC ──
@Get('kyc') @Get('kyc')

View File

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

View File

@@ -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<typeof vi.fn> };
let mockPrisma: {
$transaction: ReturnType<typeof vi.fn>;
listing: { update: ReturnType<typeof vi.fn> };
adminAuditLog: { create: ReturnType<typeof vi.fn> };
};
let mockLogger: { log: ReturnType<typeof vi.fn>; error: ReturnType<typeof vi.fn> };
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');
});
});

View File

@@ -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<typeof vi.fn> };
let mockPrisma: { listing: { update: ReturnType<typeof vi.fn> } };
let mockCommandBus: { execute: ReturnType<typeof vi.fn> };
let mockQueryBus: { execute: ReturnType<typeof vi.fn> };
let mockLogger: { log: ReturnType<typeof vi.fn>; error: ReturnType<typeof vi.fn> };
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');
});
});

View File

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

View File

@@ -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<number>([3, 7, 14, 30, 60, 90]);
export interface AdminFeatureListingResult {
listingId: string;
featuredUntil: string | null;
action: 'feature' | 'unfeature';
}
@CommandHandler(AdminFeatureListingCommand)
export class AdminFeatureListingHandler
implements ICommandHandler<AdminFeatureListingCommand>
{
constructor(
@Inject(LISTING_REPOSITORY) private readonly listingRepo: IListingRepository,
private readonly prisma: PrismaService,
private readonly logger: LoggerService,
) {}
async execute(command: AdminFeatureListingCommand): Promise<AdminFeatureListingResult> {
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');
}
}
}

View File

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

View File

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

View File

@@ -2,6 +2,19 @@ export { ListingsModule } from './listings.module';
export { ListingEntity, type ListingProps } from './domain/entities/listing.entity'; export { ListingEntity, type ListingProps } from './domain/entities/listing.entity';
export { ListingCreatedEvent } from './domain/events/listing-created.event'; export { ListingCreatedEvent } from './domain/events/listing-created.event';
export { ModerateListingCommand } from './application/commands/moderate-listing/moderate-listing.command'; 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 { LISTING_REPOSITORY, IListingRepository, type ListingSearchParams, type PaginatedResult } from './domain/repositories/listing.repository';
export { ListingPriceChangedEvent } from './domain/events/listing-price-changed.event'; export { ListingPriceChangedEvent } from './domain/events/listing-price-changed.event';
export { ListingSoldEvent } from './domain/events/listing-sold.event'; export { ListingSoldEvent } from './domain/events/listing-sold.event';

View File

@@ -1,9 +1,11 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { CqrsModule } from '@nestjs/cqrs'; import { CqrsModule } from '@nestjs/cqrs';
import { MulterModule } from '@nestjs/platform-express'; 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 { CreateListingHandler } from './application/commands/create-listing/create-listing.handler';
import { FeatureListingHandler } from './application/commands/feature-listing/feature-listing.handler'; import { FeatureListingHandler } from './application/commands/feature-listing/feature-listing.handler';
import { ModerateListingHandler } from './application/commands/moderate-listing/moderate-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 { UpdateListingHandler } from './application/commands/update-listing/update-listing.handler';
import { UpdateListingStatusHandler } from './application/commands/update-listing-status/update-listing-status.handler'; import { UpdateListingStatusHandler } from './application/commands/update-listing-status/update-listing-status.handler';
import { UploadMediaHandler } from './application/commands/upload-media/upload-media.handler'; import { UploadMediaHandler } from './application/commands/upload-media/upload-media.handler';
@@ -28,6 +30,8 @@ import { ListingsController } from './presentation/controllers/listings.controll
const CommandHandlers = [ const CommandHandlers = [
CreateListingHandler, CreateListingHandler,
FeatureListingHandler, FeatureListingHandler,
PromoteFeaturedListingHandler,
AdminFeatureListingHandler,
UpdateListingHandler, UpdateListingHandler,
UpdateListingStatusHandler, UpdateListingStatusHandler,
UploadMediaHandler, UploadMediaHandler,

View File

@@ -33,6 +33,8 @@ import type { CreateListingResult } from '../../application/commands/create-list
import { FeatureListingCommand } from '../../application/commands/feature-listing/feature-listing.command'; import { FeatureListingCommand } from '../../application/commands/feature-listing/feature-listing.command';
import type { FeatureListingResult } from '../../application/commands/feature-listing/feature-listing.handler'; import type { FeatureListingResult } from '../../application/commands/feature-listing/feature-listing.handler';
import { ModerateListingCommand } from '../../application/commands/moderate-listing/moderate-listing.command'; 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 { UpdateListingCommand } from '../../application/commands/update-listing/update-listing.command';
import type { UpdateListingResult } from '../../application/commands/update-listing/update-listing.handler'; import type { UpdateListingResult } from '../../application/commands/update-listing/update-listing.handler';
import { UpdateListingStatusCommand } from '../../application/commands/update-listing-status/update-listing-status.command'; 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 { CreateListingDto } from '../dto/create-listing.dto';
import type { FeatureListingDto } from '../dto/feature-listing.dto'; import type { FeatureListingDto } from '../dto/feature-listing.dto';
import type { ModerateListingDto } from '../dto/moderate-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 SearchListingsDto } from '../dto/search-listings.dto';
import type { UpdateListingStatusDto } from '../dto/update-listing-status.dto'; import type { UpdateListingStatusDto } from '../dto/update-listing-status.dto';
import type { UpdateListingDto } from '../dto/update-listing.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), 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<PromoteFeaturedListingResult> {
return this.commandBus.execute(
new PromoteFeaturedListingCommand(id, user.sub, dto.durationDays),
);
}
} }

View File

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

View File

@@ -107,4 +107,41 @@ describe('SearchPropertiesHandler', () => {
const searchCall = mockSearchRepo.search.mock.calls[0]![0]; const searchCall = mockSearchRepo.search.mock.calls[0]![0];
expect(searchCall.filterBy).toContain('areaM2:<=200'); 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');
});
}); });

View File

@@ -49,6 +49,11 @@ export class SearchPropertiesHandler implements IQueryHandler<SearchPropertiesQu
if (query.city) { if (query.city) {
filters.push(`city:=${query.city}`); filters.push(`city:=${query.city}`);
} }
if (query.featured === true) {
filters.push(`isFeatured:=1`);
} else if (query.featured === false) {
filters.push(`isFeatured:=0`);
}
const searchParams = { const searchParams = {
query: query.query, query: query.query,
@@ -73,6 +78,7 @@ export class SearchPropertiesHandler implements IQueryHandler<SearchPropertiesQu
query.areaMax, query.areaMax,
query.bedrooms, query.bedrooms,
query.sortBy, query.sortBy,
query.featured === undefined ? undefined : String(query.featured),
); );
return this.cache.getOrSet( return this.cache.getOrSet(

View File

@@ -13,5 +13,6 @@ export class SearchPropertiesQuery {
public readonly sortBy?: string, public readonly sortBy?: string,
public readonly page?: number, public readonly page?: number,
public readonly perPage?: number, public readonly perPage?: number,
public readonly featured?: boolean,
) {} ) {}
} }

View File

@@ -51,6 +51,7 @@ export class SearchController {
dto.sortBy, dto.sortBy,
dto.page, dto.page,
dto.perPage, dto.perPage,
dto.featured,
), ),
); );
} }

View File

@@ -1,6 +1,7 @@
import { ApiPropertyOptional } from '@nestjs/swagger'; import { ApiPropertyOptional } from '@nestjs/swagger';
import { Transform, Type } from 'class-transformer'; import { Transform, Type } from 'class-transformer';
import { import {
IsBoolean,
IsOptional, IsOptional,
IsString, IsString,
IsNumber, IsNumber,
@@ -78,6 +79,22 @@ export class SearchPropertiesDto {
@IsString() @IsString()
city?: string; city?: string;
@ApiPropertyOptional({
description: 'Chỉ trả về tin đang được đẩy nổi bật (featured)',
example: true,
})
@IsOptional()
@Transform(({ value }) => {
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 }) @ApiPropertyOptional({ description: 'Sort order', enum: SortByOption, example: SortByOption.PRICE_ASC })
@IsOptional() @IsOptional()
@IsEnum(SortByOption) @IsEnum(SortByOption)

View File

@@ -21,6 +21,7 @@ const METRIC_TO_PLAN_FIELD: Record<string, keyof Plan> = {
searches_saved: 'maxSavedSearches', searches_saved: 'maxSavedSearches',
analytics_queries: 'maxAnalyticsQueries', analytics_queries: 'maxAnalyticsQueries',
media_uploads: 'maxMediaUploads', media_uploads: 'maxMediaUploads',
featured_listings_promoted: 'featuredListingsQuota',
}; };
@QueryHandler(CheckQuotaQuery) @QueryHandler(CheckQuotaQuery)

View File

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

View File

@@ -618,18 +618,19 @@ enum SubscriptionStatus {
} }
model Plan { model Plan {
id String @id @default(cuid()) id String @id @default(cuid())
tier PlanTier @unique tier PlanTier @unique
name String name String
priceMonthlyVND BigInt priceMonthlyVND BigInt
priceYearlyVND BigInt priceYearlyVND BigInt
maxListings Int? maxListings Int?
maxSavedSearches Int? maxSavedSearches Int?
maxAnalyticsQueries Int? maxAnalyticsQueries Int?
maxReports Int? maxReports Int?
maxMediaUploads Int? maxMediaUploads Int?
features Json featuredListingsQuota Int?
isActive Boolean @default(true) features Json
isActive Boolean @default(true)
subscriptions Subscription[] subscriptions Subscription[]
} }
@@ -766,6 +767,8 @@ enum AdminAction {
LISTING_REJECTED LISTING_REJECTED
LISTING_BULK_APPROVED LISTING_BULK_APPROVED
LISTING_BULK_REJECTED LISTING_BULK_REJECTED
LISTING_FEATURED
LISTING_UNFEATURED
USER_BANNED USER_BANNED
USER_UNBANNED USER_UNBANNED
USER_STATUS_UPDATED USER_STATUS_UPDATED

View File

@@ -23,6 +23,7 @@ export const PLANS = [
maxSavedSearches: 5, maxSavedSearches: 5,
maxAnalyticsQueries: 0, maxAnalyticsQueries: 0,
maxMediaUploads: 5, maxMediaUploads: 5,
featuredListingsQuota: 0,
features: { features: {
basicSearch: true, basicSearch: true,
listingPost: true, listingPost: true,
@@ -42,6 +43,7 @@ export const PLANS = [
maxSavedSearches: 30, maxSavedSearches: 30,
maxAnalyticsQueries: 100, maxAnalyticsQueries: 100,
maxMediaUploads: 150, maxMediaUploads: 150,
featuredListingsQuota: 5,
features: { features: {
basicSearch: true, basicSearch: true,
listingPost: true, listingPost: true,
@@ -63,6 +65,7 @@ export const PLANS = [
maxSavedSearches: 100, maxSavedSearches: 100,
maxAnalyticsQueries: 500, maxAnalyticsQueries: 500,
maxMediaUploads: 60, maxMediaUploads: 60,
featuredListingsQuota: 10,
features: { features: {
basicSearch: true, basicSearch: true,
listingPost: true, listingPost: true,
@@ -85,6 +88,7 @@ export const PLANS = [
maxSavedSearches: null, maxSavedSearches: null,
maxAnalyticsQueries: null, maxAnalyticsQueries: null,
maxMediaUploads: null, maxMediaUploads: null,
featuredListingsQuota: null,
features: { features: {
basicSearch: true, basicSearch: true,
listingPost: true, listingPost: true,
@@ -119,6 +123,7 @@ async function seedPlans() {
maxSavedSearches: plan.maxSavedSearches, maxSavedSearches: plan.maxSavedSearches,
maxAnalyticsQueries: plan.maxAnalyticsQueries, maxAnalyticsQueries: plan.maxAnalyticsQueries,
maxMediaUploads: plan.maxMediaUploads, maxMediaUploads: plan.maxMediaUploads,
featuredListingsQuota: plan.featuredListingsQuota,
features: plan.features, features: plan.features,
}, },
create: plan, create: plan,