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:
@@ -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')
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -13,5 +13,6 @@ export class SearchPropertiesQuery {
|
||||
public readonly sortBy?: string,
|
||||
public readonly page?: number,
|
||||
public readonly perPage?: number,
|
||||
public readonly featured?: boolean,
|
||||
) {}
|
||||
}
|
||||
|
||||
@@ -51,6 +51,7 @@ export class SearchController {
|
||||
dto.sortBy,
|
||||
dto.page,
|
||||
dto.perPage,
|
||||
dto.featured,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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';
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user