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,
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<AdminFeatureListingResult> {
return this.commandBus.execute(
new AdminFeatureListingCommand(
id,
user.sub,
dto.action,
dto.durationDays ?? null,
dto.reason,
ip ?? null,
),
);
}
// ── 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 { 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';

View File

@@ -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,

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

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Transform, Type } from 'class-transformer';
import {
IsBoolean,
IsOptional,
IsString,
IsNumber,
@@ -78,6 +79,22 @@ export class SearchPropertiesDto {
@IsString()
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 })
@IsOptional()
@IsEnum(SortByOption)

View File

@@ -21,6 +21,7 @@ const METRIC_TO_PLAN_FIELD: Record<string, keyof Plan> = {
searches_saved: 'maxSavedSearches',
analytics_queries: 'maxAnalyticsQueries',
media_uploads: 'maxMediaUploads',
featured_listings_promoted: 'featuredListingsQuota',
};
@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 {
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

View File

@@ -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,