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

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