diff --git a/apps/api/src/modules/listings/application/__tests__/create-listing.handler.spec.ts b/apps/api/src/modules/listings/application/__tests__/create-listing.handler.spec.ts index 7b2d31c..a0a754b 100644 --- a/apps/api/src/modules/listings/application/__tests__/create-listing.handler.spec.ts +++ b/apps/api/src/modules/listings/application/__tests__/create-listing.handler.spec.ts @@ -1,6 +1,7 @@ import { type IListingRepository } from '@modules/listings/domain/repositories/listing.repository'; import { type IPropertyRepository } from '@modules/listings/domain/repositories/property.repository'; import { type IDuplicateDetector } from '@modules/listings/domain/services/duplicate-detector'; +import { type IPriceValidator } from '@modules/listings/domain/services/price-validator'; import { CreateListingCommand } from '../commands/create-listing/create-listing.command'; import { CreateListingHandler } from '../commands/create-listing/create-listing.handler'; @@ -9,6 +10,7 @@ describe('CreateListingHandler', () => { let mockPropertyRepo: { [K in keyof IPropertyRepository]: ReturnType }; let mockListingRepo: { [K in keyof IListingRepository]: ReturnType }; let mockDuplicateDetector: { [K in keyof IDuplicateDetector]: ReturnType }; + let mockPriceValidator: { [K in keyof IPriceValidator]: ReturnType }; let mockEventBus: { publish: ReturnType }; let mockCache: { invalidateByPrefix: ReturnType; invalidate: ReturnType; getOrSet: ReturnType }; @@ -37,6 +39,10 @@ describe('CreateListingHandler', () => { findDuplicates: vi.fn().mockResolvedValue([]), }; + mockPriceValidator = { + validate: vi.fn().mockResolvedValue({ isValid: true, isSuspicious: false, pricePerM2: 62_500_000, minPricePerM2: 15_000_000, maxPricePerM2: 200_000_000 }), + }; + mockEventBus = { publish: vi.fn() }; mockCache = { invalidateByPrefix: vi.fn().mockResolvedValue(undefined), @@ -48,6 +54,7 @@ describe('CreateListingHandler', () => { mockPropertyRepo as any, mockListingRepo as any, mockDuplicateDetector as any, + mockPriceValidator as any, mockEventBus as any, mockCache as any, ); diff --git a/apps/api/src/modules/listings/application/__tests__/moderate-listing.handler.spec.ts b/apps/api/src/modules/listings/application/__tests__/moderate-listing.handler.spec.ts index 7de03c4..9fb6780 100644 --- a/apps/api/src/modules/listings/application/__tests__/moderate-listing.handler.spec.ts +++ b/apps/api/src/modules/listings/application/__tests__/moderate-listing.handler.spec.ts @@ -1,5 +1,6 @@ import { ListingEntity } from '@modules/listings/domain/entities/listing.entity'; import { type IListingRepository } from '@modules/listings/domain/repositories/listing.repository'; +import { ModerationService } from '@modules/listings/domain/services/moderation.service'; import { Price } from '@modules/listings/domain/value-objects/price.vo'; import { ModerateListingCommand } from '../commands/moderate-listing/moderate-listing.command'; import { ModerateListingHandler } from '../commands/moderate-listing/moderate-listing.handler'; @@ -39,6 +40,7 @@ describe('ModerateListingHandler', () => { mockListingRepo as any, mockEventBus as any, mockCache as any, + new ModerationService(), ); }); diff --git a/apps/api/src/modules/listings/application/__tests__/price-validator.spec.ts b/apps/api/src/modules/listings/application/__tests__/price-validator.spec.ts new file mode 100644 index 0000000..0eb385b --- /dev/null +++ b/apps/api/src/modules/listings/application/__tests__/price-validator.spec.ts @@ -0,0 +1,105 @@ +import { PrismaPriceValidator } from '../../infrastructure/services/prisma-price-validator'; + +describe('PrismaPriceValidator', () => { + let validator: PrismaPriceValidator; + let mockPrisma: { $queryRaw: ReturnType }; + + beforeEach(() => { + mockPrisma = { $queryRaw: vi.fn() }; + validator = new PrismaPriceValidator(mockPrisma as any); + }); + + it('returns valid + not suspicious for normal price within market range', async () => { + mockPrisma.$queryRaw.mockResolvedValue([{ min_price: 30_000_000, max_price: 100_000_000 }]); + + const result = await validator.validate({ + priceVND: 5_000_000_000n, + areaM2: 80, + propertyType: 'APARTMENT', + district: 'Quận 1', + }); + + expect(result.isValid).toBe(true); + expect(result.isSuspicious).toBe(false); + expect(result.pricePerM2).toBe(62_500_000); + }); + + it('flags suspicious low price', async () => { + mockPrisma.$queryRaw.mockResolvedValue([{ min_price: 50_000_000, max_price: 100_000_000 }]); + + const result = await validator.validate({ + priceVND: 1_000_000_000n, + areaM2: 80, + propertyType: 'APARTMENT', + district: 'Quận 1', + }); + + // 12.5M per m2, min threshold is 50M * 0.5 = 25M → 12.5M < 25M → suspicious + expect(result.isValid).toBe(true); + expect(result.isSuspicious).toBe(true); + expect(result.reason).toContain('thấp hơn'); + }); + + it('flags suspicious high price', async () => { + mockPrisma.$queryRaw.mockResolvedValue([{ min_price: 30_000_000, max_price: 80_000_000 }]); + + const result = await validator.validate({ + priceVND: 20_000_000_000n, + areaM2: 80, + propertyType: 'APARTMENT', + district: 'Quận 1', + }); + + // 250M per m2, max threshold is 80M * 1.5 = 120M → 250M > 120M → suspicious + expect(result.isValid).toBe(true); + expect(result.isSuspicious).toBe(true); + expect(result.reason).toContain('cao hơn'); + }); + + it('returns invalid for zero area', async () => { + mockPrisma.$queryRaw.mockResolvedValue([]); + + const result = await validator.validate({ + priceVND: 5_000_000_000n, + areaM2: 0, + propertyType: 'APARTMENT', + district: 'Quận 1', + }); + + expect(result.isValid).toBe(false); + expect(result.isSuspicious).toBe(true); + expect(result.reason).toContain('không hợp lệ'); + }); + + it('uses default ranges when no market data available', async () => { + mockPrisma.$queryRaw.mockResolvedValue([]); + + const result = await validator.validate({ + priceVND: 5_000_000_000n, + areaM2: 80, + propertyType: 'APARTMENT', + district: 'Quận 9', + }); + + expect(result.isValid).toBe(true); + // 62.5M per m2, default APARTMENT range is 15M-200M → within range + expect(result.isSuspicious).toBe(false); + expect(result.minPricePerM2).toBe(15_000_000); + expect(result.maxPricePerM2).toBe(200_000_000); + }); + + it('handles database error gracefully using defaults', async () => { + mockPrisma.$queryRaw.mockRejectedValue(new Error('DB connection failed')); + + const result = await validator.validate({ + priceVND: 5_000_000_000n, + areaM2: 80, + propertyType: 'HOUSE', + district: 'Quận 1', + }); + + expect(result.isValid).toBe(true); + expect(result.minPricePerM2).toBe(20_000_000); + expect(result.maxPricePerM2).toBe(500_000_000); + }); +}); diff --git a/apps/api/src/modules/listings/application/__tests__/update-listing-status.handler.spec.ts b/apps/api/src/modules/listings/application/__tests__/update-listing-status.handler.spec.ts index 443c2e7..dcca32e 100644 --- a/apps/api/src/modules/listings/application/__tests__/update-listing-status.handler.spec.ts +++ b/apps/api/src/modules/listings/application/__tests__/update-listing-status.handler.spec.ts @@ -1,5 +1,6 @@ import { ListingEntity } from '@modules/listings/domain/entities/listing.entity'; import { type IListingRepository } from '@modules/listings/domain/repositories/listing.repository'; +import { ModerationService } from '@modules/listings/domain/services/moderation.service'; import { Price } from '@modules/listings/domain/value-objects/price.vo'; import { UpdateListingStatusCommand } from '../commands/update-listing-status/update-listing-status.command'; import { UpdateListingStatusHandler } from '../commands/update-listing-status/update-listing-status.handler'; @@ -43,6 +44,7 @@ describe('UpdateListingStatusHandler', () => { mockListingRepo as any, mockEventBus as any, mockCache as any, + new ModerationService(), ); }); diff --git a/apps/api/src/modules/listings/application/commands/create-listing/create-listing.handler.ts b/apps/api/src/modules/listings/application/commands/create-listing/create-listing.handler.ts index 651682e..fabb056 100644 --- a/apps/api/src/modules/listings/application/commands/create-listing/create-listing.handler.ts +++ b/apps/api/src/modules/listings/application/commands/create-listing/create-listing.handler.ts @@ -1,13 +1,13 @@ import { Inject, Logger } from '@nestjs/common'; import { CommandHandler, type EventBus, type ICommandHandler } from '@nestjs/cqrs'; import { createId } from '@paralleldrive/cuid2'; -import { ValidationException } from '@modules/shared/domain/domain-exception'; -import { type CacheService, CachePrefix } from '@modules/shared/infrastructure/cache.service'; +import { ValidationException, type CacheService, CachePrefix } from '@modules/shared'; import { ListingEntity } from '../../../domain/entities/listing.entity'; import { PropertyEntity } from '../../../domain/entities/property.entity'; import { LISTING_REPOSITORY, type IListingRepository } from '../../../domain/repositories/listing.repository'; import { PROPERTY_REPOSITORY, type IPropertyRepository } from '../../../domain/repositories/property.repository'; import { DUPLICATE_DETECTOR, type IDuplicateDetector } from '../../../domain/services/duplicate-detector'; +import { PRICE_VALIDATOR, type IPriceValidator } from '../../../domain/services/price-validator'; import { Address } from '../../../domain/value-objects/address.vo'; import { GeoPoint } from '../../../domain/value-objects/geo-point.vo'; import { Price } from '../../../domain/value-objects/price.vo'; @@ -23,11 +23,19 @@ export interface DuplicateWarning { titleSimilarity: number; } +export interface PriceWarning { + pricePerM2: number; + minPricePerM2: number; + maxPricePerM2: number; + reason: string; +} + export interface CreateListingResult { listingId: string; propertyId: string; status: string; duplicateWarnings: DuplicateWarning[]; + priceWarning?: PriceWarning; } @CommandHandler(CreateListingCommand) @@ -38,6 +46,7 @@ export class CreateListingHandler implements ICommandHandler { @@ -19,15 +21,11 @@ export class ModerateListingHandler implements ICommandHandler { @@ -19,13 +21,11 @@ export class UpdateListingStatusHandler implements ICommandHandler; +} diff --git a/apps/api/src/modules/listings/domain/value-objects/address.vo.ts b/apps/api/src/modules/listings/domain/value-objects/address.vo.ts index fd34f77..187e85a 100644 --- a/apps/api/src/modules/listings/domain/value-objects/address.vo.ts +++ b/apps/api/src/modules/listings/domain/value-objects/address.vo.ts @@ -1,5 +1,4 @@ -import { Result } from '@modules/shared/domain/result'; -import { ValueObject } from '@modules/shared/domain/value-object'; +import { Result, ValueObject } from '@modules/shared'; interface AddressProps { address: string; diff --git a/apps/api/src/modules/listings/domain/value-objects/geo-point.vo.ts b/apps/api/src/modules/listings/domain/value-objects/geo-point.vo.ts index cecc5dc..37d058b 100644 --- a/apps/api/src/modules/listings/domain/value-objects/geo-point.vo.ts +++ b/apps/api/src/modules/listings/domain/value-objects/geo-point.vo.ts @@ -1,5 +1,4 @@ -import { Result } from '@modules/shared/domain/result'; -import { ValueObject } from '@modules/shared/domain/value-object'; +import { Result, ValueObject } from '@modules/shared'; interface GeoPointProps { latitude: number; diff --git a/apps/api/src/modules/listings/domain/value-objects/price.vo.ts b/apps/api/src/modules/listings/domain/value-objects/price.vo.ts index 0567f35..bec98d6 100644 --- a/apps/api/src/modules/listings/domain/value-objects/price.vo.ts +++ b/apps/api/src/modules/listings/domain/value-objects/price.vo.ts @@ -1,5 +1,4 @@ -import { Result } from '@modules/shared/domain/result'; -import { ValueObject } from '@modules/shared/domain/value-object'; +import { Result, ValueObject } from '@modules/shared'; interface PriceProps { amountVND: bigint; diff --git a/apps/api/src/modules/listings/index.ts b/apps/api/src/modules/listings/index.ts index d3bd191..dee3168 100644 --- a/apps/api/src/modules/listings/index.ts +++ b/apps/api/src/modules/listings/index.ts @@ -1 +1,6 @@ export { ListingsModule } from './listings.module'; +export { ListingEntity, type ListingProps } from './domain/entities/listing.entity'; +export { ListingCreatedEvent } from './domain/events/listing-created.event'; +export { LISTING_REPOSITORY, type IListingRepository, type ListingSearchParams, type PaginatedResult } from './domain/repositories/listing.repository'; +export { ListingSoldEvent } from './domain/events/listing-sold.event'; +export { Price } from './domain/value-objects/price.vo'; diff --git a/apps/api/src/modules/listings/infrastructure/repositories/prisma-listing.repository.ts b/apps/api/src/modules/listings/infrastructure/repositories/prisma-listing.repository.ts index e0df8bd..a3f538f 100644 --- a/apps/api/src/modules/listings/infrastructure/repositories/prisma-listing.repository.ts +++ b/apps/api/src/modules/listings/infrastructure/repositories/prisma-listing.repository.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; import { type Listing as PrismaListing, type Prisma, type ListingStatus } from '@prisma/client'; -import { type PrismaService } from '@modules/shared/infrastructure/prisma.service'; +import { type PrismaService } from '@modules/shared'; import { ListingEntity, type ListingProps } from '../../domain/entities/listing.entity'; import { type ListingDetailData, type ListingSearchItem, type ListingSellerItem } from '../../domain/repositories/listing-read.dto'; import { type IListingRepository, type ListingSearchParams, type PaginatedResult } from '../../domain/repositories/listing.repository'; diff --git a/apps/api/src/modules/listings/infrastructure/repositories/prisma-property.repository.ts b/apps/api/src/modules/listings/infrastructure/repositories/prisma-property.repository.ts index 10981bd..b646197 100644 --- a/apps/api/src/modules/listings/infrastructure/repositories/prisma-property.repository.ts +++ b/apps/api/src/modules/listings/infrastructure/repositories/prisma-property.repository.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; import { type Prisma, type Property as PrismaProperty, type PropertyMedia as PrismaMedia } from '@prisma/client'; -import { type PrismaService } from '@modules/shared/infrastructure/prisma.service'; +import { type PrismaService } from '@modules/shared'; import { PropertyMediaEntity, type PropertyMediaProps } from '../../domain/entities/property-media.entity'; import { PropertyEntity, type PropertyProps } from '../../domain/entities/property.entity'; import { type IPropertyRepository } from '../../domain/repositories/property.repository'; diff --git a/apps/api/src/modules/listings/infrastructure/services/index.ts b/apps/api/src/modules/listings/infrastructure/services/index.ts index 8233203..018a6ba 100644 --- a/apps/api/src/modules/listings/infrastructure/services/index.ts +++ b/apps/api/src/modules/listings/infrastructure/services/index.ts @@ -1 +1,2 @@ export { MEDIA_STORAGE_SERVICE, type IMediaStorageService, MinioMediaStorageService } from './media-storage.service'; +export { PrismaPriceValidator } from './prisma-price-validator'; diff --git a/apps/api/src/modules/listings/infrastructure/services/media-storage.service.ts b/apps/api/src/modules/listings/infrastructure/services/media-storage.service.ts index 5e02677..8c9515e 100644 --- a/apps/api/src/modules/listings/infrastructure/services/media-storage.service.ts +++ b/apps/api/src/modules/listings/infrastructure/services/media-storage.service.ts @@ -9,7 +9,7 @@ import { } from '@aws-sdk/client-s3'; import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; import { Injectable, type OnModuleInit } from '@nestjs/common'; -import { type LoggerService } from '@modules/shared/infrastructure/logger.service'; +import { type LoggerService } from '@modules/shared'; export const MEDIA_STORAGE_SERVICE = Symbol('MEDIA_STORAGE_SERVICE'); diff --git a/apps/api/src/modules/listings/infrastructure/services/prisma-duplicate-detector.ts b/apps/api/src/modules/listings/infrastructure/services/prisma-duplicate-detector.ts index 64a2f09..cc16ab6 100644 --- a/apps/api/src/modules/listings/infrastructure/services/prisma-duplicate-detector.ts +++ b/apps/api/src/modules/listings/infrastructure/services/prisma-duplicate-detector.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; import { type PropertyType } from '@prisma/client'; -import { type PrismaService } from '@modules/shared/infrastructure/prisma.service'; +import { type PrismaService } from '@modules/shared'; import { type DuplicateCandidate, type DuplicateCheckParams, diff --git a/apps/api/src/modules/listings/infrastructure/services/prisma-price-validator.ts b/apps/api/src/modules/listings/infrastructure/services/prisma-price-validator.ts new file mode 100644 index 0000000..6dd436b --- /dev/null +++ b/apps/api/src/modules/listings/infrastructure/services/prisma-price-validator.ts @@ -0,0 +1,120 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { type PropertyType } from '@prisma/client'; +import { type PrismaService } from '@modules/shared'; +import { + type IPriceValidator, + type PriceValidationParams, + type PriceValidationResult, +} from '../../domain/services/price-validator'; + +/** + * Default price ranges per m2 (VND) by property type when no market data exists. + * These are conservative ranges for the Vietnamese real estate market. + */ +const DEFAULT_RANGES: Record = { + APARTMENT: { min: 15_000_000, max: 200_000_000 }, + HOUSE: { min: 20_000_000, max: 500_000_000 }, + VILLA: { min: 50_000_000, max: 1_000_000_000 }, + LAND: { min: 5_000_000, max: 800_000_000 }, + OFFICE: { min: 10_000_000, max: 300_000_000 }, + SHOPHOUSE: { min: 30_000_000, max: 600_000_000 }, +}; + +/** Multiplier to widen default ranges for suspicious-but-not-invalid detection */ +const SUSPICIOUS_MULTIPLIER = 0.5; + +@Injectable() +export class PrismaPriceValidator implements IPriceValidator { + private readonly logger = new Logger(PrismaPriceValidator.name); + + constructor(private readonly prisma: PrismaService) {} + + async validate(params: PriceValidationParams): Promise { + const { priceVND, areaM2, propertyType, district } = params; + const pricePerM2 = areaM2 > 0 ? Number(priceVND) / areaM2 : 0; + + // Try to get market data from recent listings in the same district + property type + const marketRange = await this.getMarketRange(propertyType, district); + + const minPricePerM2 = marketRange?.min ?? DEFAULT_RANGES[propertyType].min; + const maxPricePerM2 = marketRange?.max ?? DEFAULT_RANGES[propertyType].max; + + // Price is invalid if it's zero or negative area + if (pricePerM2 <= 0) { + return { + isValid: false, + isSuspicious: true, + reason: 'Giá hoặc diện tích không hợp lệ', + pricePerM2, + minPricePerM2, + maxPricePerM2, + }; + } + + // Too low — likely a data entry error + const suspiciousLow = minPricePerM2 * SUSPICIOUS_MULTIPLIER; + if (pricePerM2 < suspiciousLow) { + return { + isValid: true, + isSuspicious: true, + reason: `Giá ${Math.round(pricePerM2).toLocaleString()} VND/m² thấp hơn nhiều so với thị trường (${Math.round(minPricePerM2).toLocaleString()} - ${Math.round(maxPricePerM2).toLocaleString()} VND/m²)`, + pricePerM2, + minPricePerM2, + maxPricePerM2, + }; + } + + // Too high — likely a data entry error + const suspiciousHigh = maxPricePerM2 * (1 + (1 - SUSPICIOUS_MULTIPLIER)); + if (pricePerM2 > suspiciousHigh) { + return { + isValid: true, + isSuspicious: true, + reason: `Giá ${Math.round(pricePerM2).toLocaleString()} VND/m² cao hơn nhiều so với thị trường (${Math.round(minPricePerM2).toLocaleString()} - ${Math.round(maxPricePerM2).toLocaleString()} VND/m²)`, + pricePerM2, + minPricePerM2, + maxPricePerM2, + }; + } + + return { + isValid: true, + isSuspicious: false, + pricePerM2, + minPricePerM2, + maxPricePerM2, + }; + } + + /** + * Get min/max price per m2 from active listings in the same district + property type. + * Uses P10/P90 percentiles to exclude outliers. + */ + private async getMarketRange( + propertyType: PropertyType, + district: string, + ): Promise<{ min: number; max: number } | null> { + try { + const rows = await this.prisma.$queryRaw<{ min_price: number; max_price: number }[]>` + SELECT + PERCENTILE_CONT(0.10) WITHIN GROUP (ORDER BY l."priceVND"::float8 / p."areaM2") AS min_price, + PERCENTILE_CONT(0.90) WITHIN GROUP (ORDER BY l."priceVND"::float8 / p."areaM2") AS max_price + FROM "Listing" l + JOIN "Property" p ON p.id = l."propertyId" + WHERE p."propertyType" = ${propertyType}::"PropertyType" + AND p.district = ${district} + AND l.status = 'ACTIVE' + AND p."areaM2" > 0 + AND l."createdAt" > NOW() - INTERVAL '6 months' + `; + + if (rows.length > 0 && rows[0].min_price && rows[0].max_price) { + return { min: rows[0].min_price, max: rows[0].max_price }; + } + return null; + } catch (err) { + this.logger.warn('Failed to fetch market range, using defaults', err); + return null; + } + } +} diff --git a/apps/api/src/modules/listings/listings.module.ts b/apps/api/src/modules/listings/listings.module.ts index a87fc63..88ff33c 100644 --- a/apps/api/src/modules/listings/listings.module.ts +++ b/apps/api/src/modules/listings/listings.module.ts @@ -11,10 +11,13 @@ import { SearchListingsHandler } from './application/queries/search-listings/sea import { LISTING_REPOSITORY } from './domain/repositories/listing.repository'; import { PROPERTY_REPOSITORY } from './domain/repositories/property.repository'; import { DUPLICATE_DETECTOR } from './domain/services/duplicate-detector'; +import { ModerationService } from './domain/services/moderation.service'; +import { PRICE_VALIDATOR } from './domain/services/price-validator'; import { PrismaListingRepository } from './infrastructure/repositories/prisma-listing.repository'; import { PrismaPropertyRepository } from './infrastructure/repositories/prisma-property.repository'; import { MEDIA_STORAGE_SERVICE, MinioMediaStorageService } from './infrastructure/services/media-storage.service'; import { PrismaDuplicateDetector } from './infrastructure/services/prisma-duplicate-detector'; +import { PrismaPriceValidator } from './infrastructure/services/prisma-price-validator'; import { ListingsController } from './presentation/controllers/listings.controller'; const CommandHandlers = [ @@ -45,6 +48,8 @@ const QueryHandlers = [ // Services { provide: DUPLICATE_DETECTOR, useClass: PrismaDuplicateDetector }, + { provide: PRICE_VALIDATOR, useClass: PrismaPriceValidator }, + ModerationService, { provide: MEDIA_STORAGE_SERVICE, useClass: MinioMediaStorageService }, // CQRS diff --git a/apps/api/src/modules/listings/presentation/controllers/listings.controller.ts b/apps/api/src/modules/listings/presentation/controllers/listings.controller.ts index ce4b2cc..974b846 100644 --- a/apps/api/src/modules/listings/presentation/controllers/listings.controller.ts +++ b/apps/api/src/modules/listings/presentation/controllers/listings.controller.ts @@ -21,14 +21,9 @@ import { ApiQuery, ApiParam, } from '@nestjs/swagger'; -import { type JwtPayload } from '@modules/auth/infrastructure/services/token.service'; -import { CurrentUser } from '@modules/auth/presentation/decorators/current-user.decorator'; -import { Roles } from '@modules/auth/presentation/decorators/roles.decorator'; -import { JwtAuthGuard } from '@modules/auth/presentation/guards/jwt-auth.guard'; -import { RolesGuard } from '@modules/auth/presentation/guards/roles.guard'; -import { FileValidationPipe, type UploadedFile as ValidatedFile } from '@modules/shared/infrastructure/pipes/file-validation.pipe'; -import { RequireQuota } from '@modules/subscriptions/presentation/decorators/require-quota.decorator'; -import { QuotaGuard } from '@modules/subscriptions/presentation/guards/quota.guard'; +import { type JwtPayload, CurrentUser, Roles, JwtAuthGuard, RolesGuard } from '@modules/auth'; +import { FileValidationPipe, type UploadedFile as ValidatedFile } from '@modules/shared'; +import { RequireQuota, QuotaGuard } from '@modules/subscriptions'; import { CreateListingCommand } from '../../application/commands/create-listing/create-listing.command'; import { type CreateListingResult } from '../../application/commands/create-listing/create-listing.handler'; import { ModerateListingCommand } from '../../application/commands/moderate-listing/moderate-listing.command';