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:
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
) {}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
) {}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user