feat(listings): add user-facing scam/abuse report flow (GOO-19)

- Add ListingFlag model with FlagReason enum (SCAM, DUPLICATE, WRONG_INFO, ALREADY_SOLD, INAPPROPRIATE)
- Add POST /listings/:id/report endpoint with rate limiting and duplicate prevention
- Auto-flag listings with ≥3 reports to PENDING_REVIEW for moderator review
- Add GET /admin/flagged-listings endpoint for admin moderation queue
- Add "Báo cáo" button + modal on listing detail page (Vietnamese UI)
- Add Prisma migration for listing_flags table with unique constraint per user/listing

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-23 00:19:12 +07:00
parent 94d462ef4f
commit 0329455e9a
26 changed files with 615 additions and 57 deletions

View File

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

View File

@@ -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<GetFlaggedListingsQuery> {
constructor(
private readonly prisma: PrismaService,
private readonly logger: LoggerService,
) {}
async execute(query: GetFlaggedListingsQuery): Promise<FlaggedListingsResult> {
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<string, string[]>();
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');
}
}
}

View File

@@ -0,0 +1,6 @@
export class GetFlaggedListingsQuery {
constructor(
public readonly page: number = 1,
public readonly limit: number = 20,
) {}
}

View File

@@ -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<FlaggedListingsResult> {
return this.queryBus.execute(
new GetFlaggedListingsQuery(
page ? parseInt(page, 10) : 1,
limit ? parseInt(limit, 10) : 20,
),
);
}
// ── KYC ──
@Get('kyc')

View File

@@ -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,
) {}
}

View File

@@ -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<ReportListingCommand> {
constructor(
private readonly prisma: PrismaService,
private readonly logger: LoggerService,
) {}
async execute(command: ReportListingCommand): Promise<ReportListingResult> {
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');
}
}
}

View File

@@ -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<ReportListingResult> {
return this.commandBus.execute(
new ReportListingCommand(id, user.sub, dto.reason as any, dto.description),
);
}
}

View File

@@ -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;
}

View File

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

View File

@@ -26,6 +26,7 @@ const mockListing = {
district: 'District 1',
city: 'HCMC',
projectName: null,
legalStatus: null,
amenities: ['parking'],
},
};

View File

@@ -29,6 +29,7 @@ function makeDocument(overrides?: Partial<ListingDocument>): ListingDocument {
viewCount: 10,
saveCount: 5,
projectName: null,
legalStatus: null,
amenities: ['parking'],
...overrides,
};

View File

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

View File

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

View File

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

View File

@@ -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';

View File

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

View File

@@ -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,
) {}

View File

@@ -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';

View File

@@ -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;
}
}

View File

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

View File

@@ -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()}`,

View File

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

View File

@@ -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<NeighborhoodScoreResult | null>(null);
const [priceHistory, setPriceHistory] = React.useState<PriceHistoryItem[]>([]);
const [comps, setComps] = React.useState<ListingSimilarItem[]>([]);
@@ -651,7 +653,18 @@ export function ListingDetailClient({ listing }: ListingDetailClientProps) {
/>
<InfoItem label="Hướng" value={getLabel(DIRECTIONS, property.direction) || '---'} />
<InfoItem label="Năm xây" value={property.yearBuilt ? `${property.yearBuilt}` : '---'} />
<InfoItem label="Pháp lý" value={property.legalStatus || '---'} />
<InfoItem label="Pháp lý" value={
(() => {
const labels: Record<string, string> = {
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;
})()
} />
<InfoItem label="Dự án" value={property.projectName || '---'} />
<InfoItem
label="Cách metro gần nhất"
@@ -867,6 +880,24 @@ export function ListingDetailClient({ listing }: ListingDetailClientProps) {
</CardContent>
</Card>
{/* Report */}
<Button
variant="ghost"
size="sm"
className="w-full gap-2 text-muted-foreground hover:text-destructive"
onClick={() => setReportOpen(true)}
>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 21v-4m0 0V5a2 2 0 012-2h6.5l1 1H21l-3 6 3 6h-8.5l-1-1H5a2 2 0 00-2 2zm9-13.5V9" />
</svg>
Báo cáo tin đăng
</Button>
<ReportListingModal
listingId={listing.id}
open={reportOpen}
onOpenChange={setReportOpen}
/>
{/* Stats */}
<Card>
<CardContent className="pt-5">

View File

@@ -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<FlagReason | ''>('');
const [description, setDescription] = React.useState('');
const [loading, setLoading] = React.useState(false);
const [error, setError] = React.useState<string | null>(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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Báo cáo tin đăng</DialogTitle>
<DialogDescription>
Chọn do báo cáo. Chúng tôi sẽ xem xét xử trong thời gian sớm nhất.
</DialogDescription>
</DialogHeader>
{success ? (
<div className="py-6 text-center">
<div className="mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-full bg-green-100">
<svg className="h-6 w-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
<p className="font-medium text-green-700">Báo cáo thành công!</p>
<p className="mt-1 text-sm text-muted-foreground">Cảm ơn bạn đã giúp cộng đng.</p>
</div>
) : (
<>
<div className="space-y-4 py-2">
<div className="space-y-2">
<Label> do báo cáo *</Label>
<div className="space-y-2">
{FLAG_REASONS.map((opt) => (
<label
key={opt.value}
className={`flex cursor-pointer items-center gap-3 rounded-lg border p-3 transition-colors hover:bg-muted/50 ${
reason === opt.value ? 'border-primary bg-primary/5' : ''
}`}
>
<input
type="radio"
name="flag-reason"
value={opt.value}
checked={reason === opt.value}
onChange={() => setReason(opt.value)}
className="h-4 w-4 text-primary"
/>
<span className="text-sm">{opt.label}</span>
</label>
))}
</div>
</div>
<div className="space-y-2">
<Label htmlFor="report-desc"> tả chi tiết (tuỳ chọn)</Label>
<Textarea
id="report-desc"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Nhập thông tin chi tiết về vấn đề bạn gặp..."
maxLength={1000}
rows={3}
/>
</div>
{error && (
<p className="text-sm text-destructive">{error}</p>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={loading}>
Huỷ
</Button>
<Button
onClick={handleSubmit}
disabled={!reason || loading}
variant="destructive"
>
{loading ? 'Đang gửi...' : 'Gửi báo cáo'}
</Button>
</DialogFooter>
</>
)}
</DialogContent>
</Dialog>
);
}

View File

@@ -25,6 +25,16 @@ export type Direction =
export type Furnishing = 'FULLY_FURNISHED' | 'BASIC_FURNISHED' | 'UNFURNISHED';
export type PropertyCondition = 'NEW' | 'LIKE_NEW' | 'RENOVATED' | 'USED';
export type LegalStatus = 'SO_DO' | 'SO_HONG' | 'LAND_USE_RIGHT' | 'JOINT_USE_RIGHT' | 'AWAITING' | 'NO_CERTIFICATE';
export type FlagReason = 'SCAM' | 'DUPLICATE' | 'WRONG_INFO' | 'ALREADY_SOLD' | 'INAPPROPRIATE';
export interface ReportListingResult {
flagId: string;
listingId: string;
totalReports: number;
autoFlagged: boolean;
}
// ─── Interfaces ──────────────────────────────────────────
@@ -99,7 +109,8 @@ export interface ListingDetail {
totalFloors: number | null;
direction: Direction | null;
yearBuilt: number | null;
legalStatus: string | null;
legalStatus: LegalStatus | null;
certificateVerified: boolean;
amenities: string[] | null;
nearbyPOIs: unknown;
metroDistanceM: number | null;
@@ -303,4 +314,7 @@ export const listingsApi = {
`/analytics/neighborhoods/${encodeURIComponent(district)}/score?city=${encodeURIComponent(city)}`,
)
.then((res) => res.data),
reportListing: (listingId: string, reason: FlagReason, description?: string) =>
apiClient.post<ReportListingResult>(`/listings/${listingId}/report`, { reason, description }),
};

View File

@@ -0,0 +1,39 @@
-- CreateEnum
CREATE TYPE "FlagReason" AS ENUM ('SCAM', 'DUPLICATE', 'WRONG_INFO', 'ALREADY_SOLD', 'INAPPROPRIATE');
-- CreateEnum
CREATE TYPE "FlagStatus" AS ENUM ('PENDING', 'REVIEWED', 'DISMISSED');
-- CreateTable
CREATE TABLE "listing_flags" (
"id" TEXT NOT NULL,
"listingId" TEXT NOT NULL,
"reporterId" TEXT NOT NULL,
"reason" "FlagReason" NOT NULL,
"description" TEXT,
"status" "FlagStatus" NOT NULL DEFAULT 'PENDING',
"reviewedBy" TEXT,
"reviewedAt" TIMESTAMP(3),
"reviewNotes" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "listing_flags_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "listing_flags_listingId_idx" ON "listing_flags"("listingId");
-- CreateIndex
CREATE INDEX "listing_flags_status_createdAt_idx" ON "listing_flags"("status", "createdAt" DESC);
-- CreateIndex
CREATE INDEX "listing_flags_reporterId_idx" ON "listing_flags"("reporterId");
-- CreateIndex (unique: one report per user per listing)
CREATE UNIQUE INDEX "listing_flags_listingId_reporterId_key" ON "listing_flags"("listingId", "reporterId");
-- AddForeignKey
ALTER TABLE "listing_flags" ADD CONSTRAINT "listing_flags_listingId_fkey" FOREIGN KEY ("listingId") REFERENCES "Listing"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "listing_flags" ADD CONSTRAINT "listing_flags_reporterId_fkey" FOREIGN KEY ("reporterId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;