diff --git a/apps/api/src/modules/admin/admin.module.ts b/apps/api/src/modules/admin/admin.module.ts index 8f239ed..239113b 100644 --- a/apps/api/src/modules/admin/admin.module.ts +++ b/apps/api/src/modules/admin/admin.module.ts @@ -22,6 +22,7 @@ import { UserDeactivatedListener } from './application/listeners/user-deactivate import { GetAiSettingsHandler } from './application/queries/get-ai-settings/get-ai-settings.handler'; import { GetAuditLogsHandler } from './application/queries/get-audit-logs/get-audit-logs.handler'; import { GetDashboardStatsHandler } from './application/queries/get-dashboard-stats/get-dashboard-stats.handler'; +import { GetFlaggedListingsHandler } from './application/queries/get-flagged-listings/get-flagged-listings.handler'; import { GetKycQueueHandler } from './application/queries/get-kyc-queue/get-kyc-queue.handler'; import { GetModerationAuditLogsHandler } from './application/queries/get-moderation-audit-logs/get-moderation-audit-logs.handler'; import { GetModerationQueueHandler } from './application/queries/get-moderation-queue/get-moderation-queue.handler'; @@ -56,6 +57,7 @@ const CommandHandlers = [ const QueryHandlers = [ GetModerationQueueHandler, + GetFlaggedListingsHandler, GetDashboardStatsHandler, GetRevenueStatsHandler, GetUsersHandler, diff --git a/apps/api/src/modules/admin/application/queries/get-flagged-listings/get-flagged-listings.handler.ts b/apps/api/src/modules/admin/application/queries/get-flagged-listings/get-flagged-listings.handler.ts new file mode 100644 index 0000000..d1a5734 --- /dev/null +++ b/apps/api/src/modules/admin/application/queries/get-flagged-listings/get-flagged-listings.handler.ts @@ -0,0 +1,109 @@ +import { InternalServerErrorException } from '@nestjs/common'; +import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs'; +import { DomainException, LoggerService, PrismaService } from '@modules/shared'; +import { GetFlaggedListingsQuery } from './get-flagged-listings.query'; + +export interface FlaggedListingItem { + listingId: string; + propertyTitle: string; + sellerName: string; + status: string; + totalReports: number; + reasons: string[]; + latestReportAt: string; +} + +export interface FlaggedListingsResult { + items: FlaggedListingItem[]; + total: number; + page: number; + limit: number; +} + +@QueryHandler(GetFlaggedListingsQuery) +export class GetFlaggedListingsHandler implements IQueryHandler { + constructor( + private readonly prisma: PrismaService, + private readonly logger: LoggerService, + ) {} + + async execute(query: GetFlaggedListingsQuery): Promise { + try { + const { page, limit } = query; + const skip = (page - 1) * limit; + + // Get listings that have pending flags, grouped by listing + const flaggedListings = await this.prisma.listingFlag.groupBy({ + by: ['listingId'], + where: { status: 'PENDING' }, + _count: { id: true }, + _max: { createdAt: true }, + orderBy: { _count: { id: 'desc' } }, + skip, + take: limit, + }); + + const totalGroups = await this.prisma.listingFlag.groupBy({ + by: ['listingId'], + where: { status: 'PENDING' }, + }); + const total = totalGroups.length; + + if (flaggedListings.length === 0) { + return { items: [], total: 0, page, limit }; + } + + const listingIds = flaggedListings.map((f) => f.listingId); + + // Fetch listing details + const listings = await this.prisma.listing.findMany({ + where: { id: { in: listingIds } }, + select: { + id: true, + status: true, + property: { select: { title: true } }, + seller: { select: { fullName: true } }, + }, + }); + + const listingMap = new Map(listings.map((l) => [l.id, l])); + + // Fetch distinct reasons per listing + const reasonFlags = await this.prisma.listingFlag.findMany({ + where: { listingId: { in: listingIds }, status: 'PENDING' }, + select: { listingId: true, reason: true }, + distinct: ['listingId', 'reason'], + }); + + const reasonMap = new Map(); + for (const rf of reasonFlags) { + const arr = reasonMap.get(rf.listingId) ?? []; + arr.push(rf.reason); + reasonMap.set(rf.listingId, arr); + } + + const items: FlaggedListingItem[] = flaggedListings.map((group) => { + const listing = listingMap.get(group.listingId); + return { + listingId: group.listingId, + propertyTitle: listing?.property?.title ?? 'Unknown', + sellerName: listing?.seller?.fullName ?? 'Unknown', + status: listing?.status ?? 'UNKNOWN', + totalReports: group._count.id, + reasons: reasonMap.get(group.listingId) ?? [], + latestReportAt: group._max.createdAt?.toISOString() ?? '', + }; + }); + + return { items, total, page, limit }; + } catch (error) { + if (error instanceof DomainException) throw error; + this.logger.error( + `Failed to get flagged listings: ${error instanceof Error ? error.message : String(error)}`, + error instanceof Error ? error.stack : undefined, + 'GetFlaggedListingsHandler', + ); + throw new InternalServerErrorException('Lỗi khi lấy danh sách tin bị báo cáo'); + } + } +} diff --git a/apps/api/src/modules/admin/application/queries/get-flagged-listings/get-flagged-listings.query.ts b/apps/api/src/modules/admin/application/queries/get-flagged-listings/get-flagged-listings.query.ts new file mode 100644 index 0000000..2dc6248 --- /dev/null +++ b/apps/api/src/modules/admin/application/queries/get-flagged-listings/get-flagged-listings.query.ts @@ -0,0 +1,6 @@ +export class GetFlaggedListingsQuery { + constructor( + public readonly page: number = 1, + public readonly limit: number = 20, + ) {} +} diff --git a/apps/api/src/modules/admin/presentation/controllers/admin-moderation.controller.ts b/apps/api/src/modules/admin/presentation/controllers/admin-moderation.controller.ts index 94413dd..d7ee174 100644 --- a/apps/api/src/modules/admin/presentation/controllers/admin-moderation.controller.ts +++ b/apps/api/src/modules/admin/presentation/controllers/admin-moderation.controller.ts @@ -37,6 +37,8 @@ import { ApproveListingDto } from '../dto/approve-listing.dto'; import { BulkModerateDto } from '../dto/bulk-moderate.dto'; import { RejectKycDto } from '../dto/reject-kyc.dto'; import { RejectListingDto } from '../dto/reject-listing.dto'; +import { GetFlaggedListingsQuery } from '../../application/queries/get-flagged-listings/get-flagged-listings.query'; +import type { FlaggedListingsResult } from '../../application/queries/get-flagged-listings/get-flagged-listings.handler'; @ApiTags('admin') @ApiBearerAuth('JWT') @@ -139,6 +141,27 @@ export class AdminModerationController { ); } + // ── Flagged Listings (User Reports) ── + + @Get('flagged-listings') + @ApiOperation({ summary: 'Get listings flagged by users (báo cáo)' }) + @ApiQuery({ name: 'page', required: false, type: Number, description: 'Page number (default 1)' }) + @ApiQuery({ name: 'limit', required: false, type: Number, description: 'Items per page (default 20)' }) + @ApiResponse({ status: 200, description: 'Flagged listings queue retrieved successfully' }) + @ApiResponse({ status: 401, description: 'Unauthorized – missing or invalid JWT' }) + @ApiResponse({ status: 403, description: 'Forbidden – requires ADMIN role' }) + async getFlaggedListings( + @Query('page') page?: string, + @Query('limit') limit?: string, + ): Promise { + return this.queryBus.execute( + new GetFlaggedListingsQuery( + page ? parseInt(page, 10) : 1, + limit ? parseInt(limit, 10) : 20, + ), + ); + } + // ── KYC ── @Get('kyc') diff --git a/apps/api/src/modules/listings/application/commands/report-listing/report-listing.command.ts b/apps/api/src/modules/listings/application/commands/report-listing/report-listing.command.ts new file mode 100644 index 0000000..a8a35ab --- /dev/null +++ b/apps/api/src/modules/listings/application/commands/report-listing/report-listing.command.ts @@ -0,0 +1,10 @@ +import { FlagReason } from '@prisma/client'; + +export class ReportListingCommand { + constructor( + public readonly listingId: string, + public readonly reporterId: string, + public readonly reason: FlagReason, + public readonly description?: string, + ) {} +} diff --git a/apps/api/src/modules/listings/application/commands/report-listing/report-listing.handler.ts b/apps/api/src/modules/listings/application/commands/report-listing/report-listing.handler.ts new file mode 100644 index 0000000..db222f8 --- /dev/null +++ b/apps/api/src/modules/listings/application/commands/report-listing/report-listing.handler.ts @@ -0,0 +1,119 @@ +import { HttpStatus, InternalServerErrorException } from '@nestjs/common'; +import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs'; +import { DomainException, ErrorCode, LoggerService, PrismaService } from '@modules/shared'; +import { ReportListingCommand } from './report-listing.command'; + +/** Threshold: auto-flag listing for moderator review when it reaches this many reports. */ +const AUTO_FLAG_THRESHOLD = 3; + +export interface ReportListingResult { + flagId: string; + listingId: string; + totalReports: number; + autoFlagged: boolean; +} + +@CommandHandler(ReportListingCommand) +export class ReportListingHandler implements ICommandHandler { + constructor( + private readonly prisma: PrismaService, + private readonly logger: LoggerService, + ) {} + + async execute(command: ReportListingCommand): Promise { + try { + // Verify listing exists and is active + const listing = await this.prisma.listing.findUnique({ + where: { id: command.listingId }, + select: { id: true, status: true }, + }); + + if (!listing) { + throw new DomainException( + ErrorCode.NOT_FOUND, + 'Tin đăng không tồn tại', + HttpStatus.NOT_FOUND, + ); + } + + // Prevent self-reporting + const isSeller = await this.prisma.listing.findFirst({ + where: { id: command.listingId, sellerId: command.reporterId }, + select: { id: true }, + }); + + if (isSeller) { + throw new DomainException( + ErrorCode.BAD_REQUEST, + 'Không thể báo cáo tin đăng của chính mình', + HttpStatus.BAD_REQUEST, + ); + } + + // Check for duplicate report (unique constraint will also catch this) + const existingFlag = await this.prisma.listingFlag.findUnique({ + where: { + listingId_reporterId: { + listingId: command.listingId, + reporterId: command.reporterId, + }, + }, + }); + + if (existingFlag) { + throw new DomainException( + ErrorCode.CONFLICT, + 'Bạn đã báo cáo tin đăng này rồi', + HttpStatus.CONFLICT, + ); + } + + // Create the flag + const flag = await this.prisma.listingFlag.create({ + data: { + listingId: command.listingId, + reporterId: command.reporterId, + reason: command.reason, + description: command.description ?? null, + }, + }); + + // Count total reports for this listing + const totalReports = await this.prisma.listingFlag.count({ + where: { listingId: command.listingId }, + }); + + // Auto-flag: when ≥3 reports, move listing to PENDING_REVIEW for moderator + let autoFlagged = false; + if (totalReports >= AUTO_FLAG_THRESHOLD && listing.status === 'ACTIVE') { + await this.prisma.listing.update({ + where: { id: command.listingId }, + data: { + status: 'PENDING_REVIEW', + moderationNotes: `Tự động chuyển sang chờ duyệt: ${totalReports} báo cáo từ người dùng`, + }, + }); + autoFlagged = true; + this.logger.log( + `Listing ${command.listingId} auto-flagged for moderation (${totalReports} reports)`, + 'ReportListingHandler', + ); + } + + return { + flagId: flag.id, + listingId: command.listingId, + totalReports, + autoFlagged, + }; + } catch (error) { + if (error instanceof DomainException) throw error; + this.logger.error( + `Failed to report listing: ${error instanceof Error ? error.message : String(error)}`, + error instanceof Error ? error.stack : undefined, + 'ReportListingHandler', + ); + throw new InternalServerErrorException('Không thể báo cáo tin đăng'); + } + } +} 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 c26ec2e..ffe1d7d 100644 --- a/apps/api/src/modules/listings/presentation/controllers/listings.controller.ts +++ b/apps/api/src/modules/listings/presentation/controllers/listings.controller.ts @@ -65,6 +65,9 @@ import { PromoteFeaturedListingDto } from '../dto/promote-featured-listing.dto'; import { SearchListingsDto } from '../dto/search-listings.dto'; import { UpdateListingStatusDto } from '../dto/update-listing-status.dto'; import { UpdateListingDto } from '../dto/update-listing.dto'; +import { ReportListingDto } from '../dto/report-listing.dto'; +import { ReportListingCommand } from '../../application/commands/report-listing/report-listing.command'; +import type { ReportListingResult } from '../../application/commands/report-listing/report-listing.handler'; @ApiTags('listings') @Controller('listings') @@ -129,6 +132,7 @@ export class ListingsController { petFriendly: dto.petFriendly, suitableFor: dto.suitableFor, whyThisLocation: dto.whyThisLocation, + certificateVerified: dto.certificateVerified, }, ), ); @@ -334,6 +338,7 @@ export class ListingsController { petFriendly: dto.petFriendly, suitableFor: dto.suitableFor, whyThisLocation: dto.whyThisLocation, + certificateVerified: dto.certificateVerified, }, dto.agentId, ), @@ -524,4 +529,27 @@ export class ListingsController { new PromoteFeaturedListingCommand(id, user.sub, dto.durationDays), ); } + + // ── Report / Flag ── + + @ApiBearerAuth('JWT') + @ApiOperation({ summary: 'Report a listing (Báo cáo tin đăng)' }) + @ApiParam({ name: 'id', description: 'Listing ID' }) + @ApiResponse({ status: 201, description: 'Báo cáo thành công' }) + @ApiResponse({ status: 400, description: 'Validation error' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ status: 404, description: 'Listing not found' }) + @ApiResponse({ status: 409, description: 'Đã báo cáo tin đăng này' }) + @UseGuards(JwtAuthGuard) + @Throttle({ default: { limit: 5, ttl: 60000 } }) + @Post(':id/report') + async reportListing( + @Param('id') id: string, + @Body() dto: ReportListingDto, + @CurrentUser() user: JwtPayload, + ): Promise { + return this.commandBus.execute( + new ReportListingCommand(id, user.sub, dto.reason as any, dto.description), + ); + } } diff --git a/apps/api/src/modules/listings/presentation/dto/report-listing.dto.ts b/apps/api/src/modules/listings/presentation/dto/report-listing.dto.ts new file mode 100644 index 0000000..deeeffb --- /dev/null +++ b/apps/api/src/modules/listings/presentation/dto/report-listing.dto.ts @@ -0,0 +1,18 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsEnum, IsOptional, IsString, MaxLength } from 'class-validator'; + +export class ReportListingDto { + @ApiProperty({ + enum: ['SCAM', 'DUPLICATE', 'WRONG_INFO', 'ALREADY_SOLD', 'INAPPROPRIATE'], + example: 'SCAM', + description: 'Lý do báo cáo', + }) + @IsEnum(['SCAM', 'DUPLICATE', 'WRONG_INFO', 'ALREADY_SOLD', 'INAPPROPRIATE'] as const) + reason!: 'SCAM' | 'DUPLICATE' | 'WRONG_INFO' | 'ALREADY_SOLD' | 'INAPPROPRIATE'; + + @ApiPropertyOptional({ example: 'Tin đăng có dấu hiệu lừa đảo', description: 'Mô tả chi tiết (tuỳ chọn)' }) + @IsOptional() + @IsString() + @MaxLength(1000) + description?: string; +} diff --git a/apps/api/src/modules/search/domain/repositories/search.repository.ts b/apps/api/src/modules/search/domain/repositories/search.repository.ts index c8dce60..5e63790 100644 --- a/apps/api/src/modules/search/domain/repositories/search.repository.ts +++ b/apps/api/src/modules/search/domain/repositories/search.repository.ts @@ -27,6 +27,7 @@ export interface ListingDocument { viewCount: number; saveCount: number; projectName: string | null; + legalStatus: string | null; amenities: string[]; isFeatured: number; // 1 if featuredUntil > now, 0 otherwise } diff --git a/apps/api/src/modules/search/infrastructure/__tests__/listing-indexer.service.spec.ts b/apps/api/src/modules/search/infrastructure/__tests__/listing-indexer.service.spec.ts index 9e1fabe..6aeea8c 100644 --- a/apps/api/src/modules/search/infrastructure/__tests__/listing-indexer.service.spec.ts +++ b/apps/api/src/modules/search/infrastructure/__tests__/listing-indexer.service.spec.ts @@ -26,6 +26,7 @@ const mockListing = { district: 'District 1', city: 'HCMC', projectName: null, + legalStatus: null, amenities: ['parking'], }, }; diff --git a/apps/api/src/modules/search/infrastructure/__tests__/typesense-search.repository.spec.ts b/apps/api/src/modules/search/infrastructure/__tests__/typesense-search.repository.spec.ts index f87b9b0..95b3d24 100644 --- a/apps/api/src/modules/search/infrastructure/__tests__/typesense-search.repository.spec.ts +++ b/apps/api/src/modules/search/infrastructure/__tests__/typesense-search.repository.spec.ts @@ -29,6 +29,7 @@ function makeDocument(overrides?: Partial): ListingDocument { viewCount: 10, saveCount: 5, projectName: null, + legalStatus: null, amenities: ['parking'], ...overrides, }; diff --git a/apps/api/src/modules/search/infrastructure/services/listing-indexer.service.ts b/apps/api/src/modules/search/infrastructure/services/listing-indexer.service.ts index d30336c..5124480 100644 --- a/apps/api/src/modules/search/infrastructure/services/listing-indexer.service.ts +++ b/apps/api/src/modules/search/infrastructure/services/listing-indexer.service.ts @@ -119,6 +119,7 @@ export class ListingIndexerService { viewCount: l.viewCount, saveCount: l.saveCount, projectName: p.projectName, + legalStatus: p.legalStatus, amenities: Array.isArray(p.amenities) ? (p.amenities as string[]) : [], isFeatured: l.featuredUntil && l.featuredUntil > new Date() ? featuredTierWeight(l.featuredPackage as string | null) @@ -170,6 +171,7 @@ export class ListingIndexerService { viewCount: listing.viewCount, saveCount: listing.saveCount, projectName: p.projectName, + legalStatus: p.legalStatus, amenities: Array.isArray(p.amenities) ? (p.amenities as string[]) : [], isFeatured: listing.featuredUntil && listing.featuredUntil > new Date() ? featuredTierWeight(listing.featuredPackage as string | null) diff --git a/apps/api/src/modules/search/infrastructure/services/postgres-search.repository.ts b/apps/api/src/modules/search/infrastructure/services/postgres-search.repository.ts index 8943080..980b164 100644 --- a/apps/api/src/modules/search/infrastructure/services/postgres-search.repository.ts +++ b/apps/api/src/modules/search/infrastructure/services/postgres-search.repository.ts @@ -63,7 +63,7 @@ export class PostgresSearchRepository implements ISearchRepository { ST_Y(p."location"::geometry) AS "lat", ST_X(p."location"::geometry) AS "lng", l."agentId", l."sellerId", l."status", l."publishedAt", - l."viewCount", l."saveCount", p."projectName", p."amenities" + l."viewCount", l."saveCount", p."projectName", p."legalStatus", p."amenities" FROM "Listing" l JOIN "Property" p ON l."propertyId" = p."id" ${whereClause} ${orderClause} diff --git a/apps/api/src/modules/search/infrastructure/services/search-result-mapper.ts b/apps/api/src/modules/search/infrastructure/services/search-result-mapper.ts index f512de0..f0ce8ba 100644 --- a/apps/api/src/modules/search/infrastructure/services/search-result-mapper.ts +++ b/apps/api/src/modules/search/infrastructure/services/search-result-mapper.ts @@ -27,6 +27,7 @@ export interface RawListingRow { viewCount: number; saveCount: number; projectName: string | null; + legalStatus?: string | null; amenities: unknown; featuredUntil?: Date | string | null; } @@ -60,6 +61,7 @@ export function mapRowToListingDocument(row: RawListingRow): ListingDocument { viewCount: row.viewCount ?? 0, saveCount: row.saveCount ?? 0, projectName: row.projectName, + legalStatus: row.legalStatus ?? null, amenities: Array.isArray(row.amenities) ? (row.amenities as string[]) : [], isFeatured: row.featuredUntil && new Date(row.featuredUntil) > new Date() ? 1 : 0, }; diff --git a/apps/api/src/modules/search/infrastructure/services/typesense-client.service.ts b/apps/api/src/modules/search/infrastructure/services/typesense-client.service.ts index aa5d24e..5835993 100644 --- a/apps/api/src/modules/search/infrastructure/services/typesense-client.service.ts +++ b/apps/api/src/modules/search/infrastructure/services/typesense-client.service.ts @@ -1,32 +1,3 @@ -import { Injectable, type OnModuleInit } from '@nestjs/common'; -import { Client as TypesenseClient } from 'typesense'; -import { LoggerService } from '@modules/shared'; - -@Injectable() -export class TypesenseClientService implements OnModuleInit { - private client!: TypesenseClient; - - constructor(private readonly logger: LoggerService) {} - - onModuleInit(): void { - this.client = new TypesenseClient({ - nodes: [ - { - host: process.env['TYPESENSE_HOST'] || 'localhost', - port: parseInt(process.env['TYPESENSE_PORT'] || '8108', 10), - protocol: process.env['TYPESENSE_PROTOCOL'] || 'http', - }, - ], - apiKey: process.env['TYPESENSE_API_KEY'] || 'ts_dev_key_change_me', - connectionTimeoutSeconds: 5, - retryIntervalSeconds: 0.1, - numRetries: 3, - }); - - this.logger.log('TypesenseClientService initialized', 'TypesenseClient'); - } - - getClient(): TypesenseClient { - return this.client; - } -} +// Re-export from SharedModule for backward compatibility. +// The canonical location is now @modules/shared. +export { TypesenseClientService } from '@modules/shared'; diff --git a/apps/api/src/modules/search/infrastructure/services/typesense-search.repository.ts b/apps/api/src/modules/search/infrastructure/services/typesense-search.repository.ts index 6742da2..cb65e71 100644 --- a/apps/api/src/modules/search/infrastructure/services/typesense-search.repository.ts +++ b/apps/api/src/modules/search/infrastructure/services/typesense-search.repository.ts @@ -40,6 +40,7 @@ const LISTING_SCHEMA: CollectionCreateSchema = { { name: 'viewCount', type: 'int32', facet: false }, { name: 'saveCount', type: 'int32', facet: false }, { name: 'projectName', type: 'string', facet: true, optional: true }, + { name: 'legalStatus', type: 'string', facet: true, optional: true }, { name: 'amenities', type: 'string[]', facet: true, optional: true }, { name: 'isFeatured', type: 'int32', facet: true }, ], diff --git a/apps/api/src/modules/search/search.module.ts b/apps/api/src/modules/search/search.module.ts index a5e9287..f0d1c54 100644 --- a/apps/api/src/modules/search/search.module.ts +++ b/apps/api/src/modules/search/search.module.ts @@ -1,7 +1,7 @@ import { Module, type OnModuleInit } from '@nestjs/common'; import { CqrsModule } from '@nestjs/cqrs'; import { makeCounterProvider } from '@willsoto/nestjs-prometheus'; -import { LoggerService } from '@modules/shared'; +import { LoggerService, TypesenseClientService } from '@modules/shared'; import { SubscriptionsModule } from '@modules/subscriptions'; import { CreateSavedSearchHandler } from './application/commands/create-saved-search/create-saved-search.handler'; import { DeleteSavedSearchHandler } from './application/commands/delete-saved-search/delete-saved-search.handler'; @@ -21,7 +21,6 @@ import { SavedSearchAlertHandler } from './infrastructure/event-handlers/saved-s import { ListingIndexerService } from './infrastructure/services/listing-indexer.service'; import { PostgresSearchRepository } from './infrastructure/services/postgres-search.repository'; import { ResilientSearchRepository, SEARCH_DEGRADATION_TOTAL } from './infrastructure/services/resilient-search.repository'; -import { TypesenseClientService } from './infrastructure/services/typesense-client.service'; import { TypesenseSearchRepository } from './infrastructure/services/typesense-search.repository'; import { SavedSearchController } from './presentation/controllers/saved-search.controller'; import { SearchController } from './presentation/controllers/search.controller'; @@ -34,7 +33,6 @@ const QueryHandlers = [SearchPropertiesHandler, GeoSearchHandler, GetSavedSearch controllers: [SearchController, SavedSearchController], providers: [ // Infrastructure - TypesenseClientService, TypesenseSearchRepository, PostgresSearchRepository, ResilientSearchRepository, @@ -61,11 +59,10 @@ const QueryHandlers = [SearchPropertiesHandler, GeoSearchHandler, GetSavedSearch ...CommandHandlers, ...QueryHandlers, ], - exports: [ListingIndexerService, SEARCH_REPOSITORY, TypesenseClientService], + exports: [ListingIndexerService, SEARCH_REPOSITORY], }) export class SearchModule implements OnModuleInit { constructor( - private readonly typesenseClient: TypesenseClientService, private readonly searchRepo: ResilientSearchRepository, private readonly logger: LoggerService, ) {} diff --git a/apps/api/src/modules/shared/infrastructure/index.ts b/apps/api/src/modules/shared/infrastructure/index.ts index 0f322f2..9d3fb95 100644 --- a/apps/api/src/modules/shared/infrastructure/index.ts +++ b/apps/api/src/modules/shared/infrastructure/index.ts @@ -14,6 +14,7 @@ export { RedisService } from './redis.service'; export { RedisIoAdapter } from './redis-io.adapter'; export { CacheService, CachePrefix, CacheTTL } from './cache.service'; export { LoggerService } from './logger.service'; +export { TypesenseClientService } from './typesense-client.service'; export { EventBusService } from './event-bus.service'; export { GlobalExceptionFilter } from './filters/global-exception.filter'; export { CorrelationIdMiddleware } from './middleware/correlation-id.middleware'; diff --git a/apps/api/src/modules/shared/infrastructure/typesense-client.service.ts b/apps/api/src/modules/shared/infrastructure/typesense-client.service.ts new file mode 100644 index 0000000..3f30fc8 --- /dev/null +++ b/apps/api/src/modules/shared/infrastructure/typesense-client.service.ts @@ -0,0 +1,37 @@ +import { Injectable, type OnModuleInit } from '@nestjs/common'; +import { Client as TypesenseClient } from 'typesense'; +import { LoggerService } from './logger.service'; + +/** + * Provides a shared Typesense client for search, indexers, and health probes. + * Lives in SharedModule so any feature module can inject it without importing + * SearchModule. + */ +@Injectable() +export class TypesenseClientService implements OnModuleInit { + private client!: TypesenseClient; + + constructor(private readonly logger: LoggerService) {} + + onModuleInit(): void { + this.client = new TypesenseClient({ + nodes: [ + { + host: process.env['TYPESENSE_HOST'] || 'localhost', + port: parseInt(process.env['TYPESENSE_PORT'] || '8108', 10), + protocol: process.env['TYPESENSE_PROTOCOL'] || 'http', + }, + ], + apiKey: process.env['TYPESENSE_API_KEY'] || 'ts_dev_key_change_me', + connectionTimeoutSeconds: 5, + retryIntervalSeconds: 0.1, + numRetries: 3, + }); + + this.logger.log('TypesenseClientService initialized', 'TypesenseClient'); + } + + getClient(): TypesenseClient { + return this.client; + } +} diff --git a/apps/api/src/modules/shared/shared.module.ts b/apps/api/src/modules/shared/shared.module.ts index 3888891..a0334e8 100644 --- a/apps/api/src/modules/shared/shared.module.ts +++ b/apps/api/src/modules/shared/shared.module.ts @@ -19,6 +19,7 @@ import { RequestLoggingMiddleware } from './infrastructure/middleware/request-lo import { SanitizeInputMiddleware } from './infrastructure/middleware/sanitize-input.middleware'; import { PrismaService } from './infrastructure/prisma.service'; import { RedisService } from './infrastructure/redis.service'; +import { TypesenseClientService } from './infrastructure/typesense-client.service'; @Global() @Module({ @@ -34,6 +35,7 @@ import { RedisService } from './infrastructure/redis.service'; RedisService, CacheService, EventBusService, + TypesenseClientService, makeCounterProvider({ name: CACHE_HIT_TOTAL, help: 'Total number of cache hits', @@ -54,7 +56,7 @@ import { RedisService } from './infrastructure/redis.service'; useClass: GlobalExceptionFilter, }, ], - exports: [PrismaService, RedisService, CacheService, LoggerService, EventBusService, FieldEncryptionService, PrometheusModule], + exports: [PrismaService, RedisService, CacheService, LoggerService, EventBusService, FieldEncryptionService, TypesenseClientService, PrometheusModule], }) export class SharedModule implements NestModule { configure(consumer: MiddlewareConsumer): void { diff --git a/apps/api/src/modules/subscriptions/infrastructure/event-handlers/bank-transfer-subscription-activation.handler.ts b/apps/api/src/modules/subscriptions/infrastructure/event-handlers/bank-transfer-subscription-activation.handler.ts index 7a61de9..2465a69 100644 --- a/apps/api/src/modules/subscriptions/infrastructure/event-handlers/bank-transfer-subscription-activation.handler.ts +++ b/apps/api/src/modules/subscriptions/infrastructure/event-handlers/bank-transfer-subscription-activation.handler.ts @@ -1,7 +1,11 @@ -import { Injectable } from '@nestjs/common'; +import { Inject, Injectable } from '@nestjs/common'; import { OnEvent } from '@nestjs/event-emitter'; import { type BankTransferConfirmedEvent } from '@modules/payments'; -import { LoggerService, PrismaService } from '@modules/shared'; +import { LoggerService } from '@modules/shared'; +import { + SUBSCRIPTION_REPOSITORY, + type ISubscriptionRepository, +} from '../../domain/repositories/subscription.repository'; /** * Handles subscription activation once a bank-transfer payment is confirmed. @@ -15,13 +19,17 @@ import { LoggerService, PrismaService } from '@modules/shared'; * happens upstream during payment creation; this listener is the * side-effect hook that flips the subscription status. * + * Uses ISubscriptionRepository to keep the domain entity authoritative — + * no raw Prisma access in this handler. + * * NOTE: Intentionally defensive — if no subscription exists yet the event * is logged and skipped; downstream processes (CS or renewal cron) pick it up. */ @Injectable() export class BankTransferSubscriptionActivationHandler { constructor( - private readonly prisma: PrismaService, + @Inject(SUBSCRIPTION_REPOSITORY) + private readonly subscriptionRepo: ISubscriptionRepository, private readonly logger: LoggerService, ) {} @@ -32,10 +40,7 @@ export class BankTransferSubscriptionActivationHandler { } try { - const subscription = await this.prisma.subscription.findFirst({ - where: { userId: event.userId }, - orderBy: { updatedAt: 'desc' }, - }); + const subscription = await this.subscriptionRepo.findByUserId(event.userId); if (!subscription) { this.logger.warn( @@ -46,21 +51,18 @@ export class BankTransferSubscriptionActivationHandler { } const now = new Date(); - const baseDate = + const baseStart = + subscription.currentPeriodEnd > now ? subscription.currentPeriodStart : now; + const baseEnd = subscription.currentPeriodEnd > now ? subscription.currentPeriodEnd : now; // Default to 30-day extension; renewal command handles more granular math const nextPeriodEnd = new Date( - baseDate.getTime() + 30 * 24 * 60 * 60 * 1000, + baseEnd.getTime() + 30 * 24 * 60 * 60 * 1000, ); - await this.prisma.subscription.update({ - where: { id: subscription.id }, - data: { - status: 'ACTIVE', - currentPeriodEnd: nextPeriodEnd, - }, - }); + subscription.renewPeriod(baseStart, nextPeriodEnd); + await this.subscriptionRepo.update(subscription); this.logger.log( `Subscription activated via bank transfer: subscriptionId=${subscription.id}, userId=${event.userId}, paymentId=${event.aggregateId}, periodEnd=${nextPeriodEnd.toISOString()}`, diff --git a/apps/api/src/modules/subscriptions/subscriptions.module.ts b/apps/api/src/modules/subscriptions/subscriptions.module.ts index 2b14b39..aad3431 100644 --- a/apps/api/src/modules/subscriptions/subscriptions.module.ts +++ b/apps/api/src/modules/subscriptions/subscriptions.module.ts @@ -8,6 +8,7 @@ import { CheckQuotaHandler } from './application/queries/check-quota/check-quota import { GetBillingHistoryHandler } from './application/queries/get-billing-history/get-billing-history.handler'; import { GetPlanHandler } from './application/queries/get-plan/get-plan.handler'; import { SUBSCRIPTION_REPOSITORY } from './domain/repositories/subscription.repository'; +import { BankTransferSubscriptionActivationHandler } from './infrastructure/event-handlers/bank-transfer-subscription-activation.handler'; import { ListingCreatedUsageHandler } from './infrastructure/event-handlers/listing-created-usage.handler'; import { SavedSearchCreatedUsageHandler } from './infrastructure/event-handlers/saved-search-created-usage.handler'; import { PrismaSubscriptionRepository } from './infrastructure/repositories/prisma-subscription.repository'; @@ -40,6 +41,7 @@ const QueryHandlers = [ // Event Listeners ListingCreatedUsageHandler, SavedSearchCreatedUsageHandler, + BankTransferSubscriptionActivationHandler, // CQRS ...CommandHandlers, diff --git a/apps/web/components/listings/listing-detail-client.tsx b/apps/web/components/listings/listing-detail-client.tsx index 471dd20..e137927 100644 --- a/apps/web/components/listings/listing-detail-client.tsx +++ b/apps/web/components/listings/listing-detail-client.tsx @@ -9,6 +9,7 @@ import { ImageGallery } from '@/components/listings/image-gallery'; import { InquiryModal } from '@/components/listings/inquiry-modal'; import { PriceHistoryChart } from '@/components/listings/price-history-chart'; import { SocialShare } from '@/components/listings/social-share'; +import { ReportListingModal } from '@/components/listings/report-listing-modal'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; @@ -399,6 +400,7 @@ export function ListingDetailClient({ listing }: ListingDetailClientProps) { const propertyTypeLabel = getLabel(PROPERTY_TYPES, property.propertyType); const [inquiryOpen, setInquiryOpen] = React.useState(false); + const [reportOpen, setReportOpen] = React.useState(false); const [neighborhoodScore, setNeighborhoodScore] = React.useState(null); const [priceHistory, setPriceHistory] = React.useState([]); const [comps, setComps] = React.useState([]); @@ -651,7 +653,18 @@ export function ListingDetailClient({ listing }: ListingDetailClientProps) { /> - + { + const labels: Record = { + SO_DO: 'Sổ đỏ', SO_HONG: 'Sổ hồng', + LAND_USE_RIGHT: 'Quyền sử dụng đất', JOINT_USE_RIGHT: 'Sở hữu chung', + AWAITING: 'Đang chờ sổ', NO_CERTIFICATE: 'Chưa có giấy tờ', + }; + const label = property.legalStatus ? (labels[property.legalStatus] ?? property.legalStatus) : '---'; + const badge = property.certificateVerified ? ' ✅ Đã xác minh' : ''; + return label + badge; + })() + } /> + {/* Report */} + + + {/* Stats */} diff --git a/apps/web/components/listings/report-listing-modal.tsx b/apps/web/components/listings/report-listing-modal.tsx new file mode 100644 index 0000000..1a86a8e --- /dev/null +++ b/apps/web/components/listings/report-listing-modal.tsx @@ -0,0 +1,139 @@ +'use client'; + +import * as React from 'react'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Label } from '@/components/ui/label'; +import { Textarea } from '@/components/ui/textarea'; +import { type FlagReason, listingsApi } from '@/lib/listings-api'; + +const FLAG_REASONS: { value: FlagReason; label: string }[] = [ + { value: 'SCAM', label: 'Lừa đảo / Scam' }, + { value: 'DUPLICATE', label: 'Tin trùng lặp' }, + { value: 'WRONG_INFO', label: 'Thông tin sai lệch' }, + { value: 'ALREADY_SOLD', label: 'Đã bán / Cho thuê rồi' }, + { value: 'INAPPROPRIATE', label: 'Nội dung không phù hợp' }, +]; + +interface ReportListingModalProps { + listingId: string; + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function ReportListingModal({ listingId, open, onOpenChange }: ReportListingModalProps) { + const [reason, setReason] = React.useState(''); + const [description, setDescription] = React.useState(''); + const [loading, setLoading] = React.useState(false); + const [error, setError] = React.useState(null); + const [success, setSuccess] = React.useState(false); + + const handleSubmit = async () => { + if (!reason) return; + setLoading(true); + setError(null); + try { + await listingsApi.reportListing(listingId, reason, description || undefined); + setSuccess(true); + setTimeout(() => { + onOpenChange(false); + setSuccess(false); + setReason(''); + setDescription(''); + }, 2000); + } catch (err) { + setError(err instanceof Error ? err.message : 'Không thể gửi báo cáo'); + } finally { + setLoading(false); + } + }; + + return ( + + + + Báo cáo tin đăng + + Chọn lý do báo cáo. Chúng tôi sẽ xem xét và xử lý trong thời gian sớm nhất. + + + + {success ? ( +
+
+ + + +
+

Báo cáo thành công!

+

Cảm ơn bạn đã giúp cộng đồng.

+
+ ) : ( + <> +
+
+ +
+ {FLAG_REASONS.map((opt) => ( + + ))} +
+
+ +
+ +