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:
@@ -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,
|
||||
) {}
|
||||
|
||||
Reference in New Issue
Block a user