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:
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export class GetFlaggedListingsQuery {
|
||||
constructor(
|
||||
public readonly page: number = 1,
|
||||
public readonly limit: number = 20,
|
||||
) {}
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
@@ -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,
|
||||
) {}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ const mockListing = {
|
||||
district: 'District 1',
|
||||
city: 'HCMC',
|
||||
projectName: null,
|
||||
legalStatus: null,
|
||||
amenities: ['parking'],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -29,6 +29,7 @@ function makeDocument(overrides?: Partial<ListingDocument>): ListingDocument {
|
||||
viewCount: 10,
|
||||
saveCount: 5,
|
||||
projectName: null,
|
||||
legalStatus: null,
|
||||
amenities: ['parking'],
|
||||
...overrides,
|
||||
};
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 },
|
||||
],
|
||||
|
||||
@@ -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,
|
||||
) {}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()}`,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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">
|
||||
|
||||
139
apps/web/components/listings/report-listing-modal.tsx
Normal file
139
apps/web/components/listings/report-listing-modal.tsx
Normal 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 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.
|
||||
</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>Lý 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">Mô 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>
|
||||
);
|
||||
}
|
||||
@@ -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 }),
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user