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 { GetAiSettingsHandler } from './application/queries/get-ai-settings/get-ai-settings.handler';
|
||||||
import { GetAuditLogsHandler } from './application/queries/get-audit-logs/get-audit-logs.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 { 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 { 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 { 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';
|
import { GetModerationQueueHandler } from './application/queries/get-moderation-queue/get-moderation-queue.handler';
|
||||||
@@ -56,6 +57,7 @@ const CommandHandlers = [
|
|||||||
|
|
||||||
const QueryHandlers = [
|
const QueryHandlers = [
|
||||||
GetModerationQueueHandler,
|
GetModerationQueueHandler,
|
||||||
|
GetFlaggedListingsHandler,
|
||||||
GetDashboardStatsHandler,
|
GetDashboardStatsHandler,
|
||||||
GetRevenueStatsHandler,
|
GetRevenueStatsHandler,
|
||||||
GetUsersHandler,
|
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 { BulkModerateDto } from '../dto/bulk-moderate.dto';
|
||||||
import { RejectKycDto } from '../dto/reject-kyc.dto';
|
import { RejectKycDto } from '../dto/reject-kyc.dto';
|
||||||
import { RejectListingDto } from '../dto/reject-listing.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')
|
@ApiTags('admin')
|
||||||
@ApiBearerAuth('JWT')
|
@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 ──
|
// ── KYC ──
|
||||||
|
|
||||||
@Get('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 { SearchListingsDto } from '../dto/search-listings.dto';
|
||||||
import { UpdateListingStatusDto } from '../dto/update-listing-status.dto';
|
import { UpdateListingStatusDto } from '../dto/update-listing-status.dto';
|
||||||
import { UpdateListingDto } from '../dto/update-listing.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')
|
@ApiTags('listings')
|
||||||
@Controller('listings')
|
@Controller('listings')
|
||||||
@@ -129,6 +132,7 @@ export class ListingsController {
|
|||||||
petFriendly: dto.petFriendly,
|
petFriendly: dto.petFriendly,
|
||||||
suitableFor: dto.suitableFor,
|
suitableFor: dto.suitableFor,
|
||||||
whyThisLocation: dto.whyThisLocation,
|
whyThisLocation: dto.whyThisLocation,
|
||||||
|
certificateVerified: dto.certificateVerified,
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -334,6 +338,7 @@ export class ListingsController {
|
|||||||
petFriendly: dto.petFriendly,
|
petFriendly: dto.petFriendly,
|
||||||
suitableFor: dto.suitableFor,
|
suitableFor: dto.suitableFor,
|
||||||
whyThisLocation: dto.whyThisLocation,
|
whyThisLocation: dto.whyThisLocation,
|
||||||
|
certificateVerified: dto.certificateVerified,
|
||||||
},
|
},
|
||||||
dto.agentId,
|
dto.agentId,
|
||||||
),
|
),
|
||||||
@@ -524,4 +529,27 @@ export class ListingsController {
|
|||||||
new PromoteFeaturedListingCommand(id, user.sub, dto.durationDays),
|
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;
|
viewCount: number;
|
||||||
saveCount: number;
|
saveCount: number;
|
||||||
projectName: string | null;
|
projectName: string | null;
|
||||||
|
legalStatus: string | null;
|
||||||
amenities: string[];
|
amenities: string[];
|
||||||
isFeatured: number; // 1 if featuredUntil > now, 0 otherwise
|
isFeatured: number; // 1 if featuredUntil > now, 0 otherwise
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ const mockListing = {
|
|||||||
district: 'District 1',
|
district: 'District 1',
|
||||||
city: 'HCMC',
|
city: 'HCMC',
|
||||||
projectName: null,
|
projectName: null,
|
||||||
|
legalStatus: null,
|
||||||
amenities: ['parking'],
|
amenities: ['parking'],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ function makeDocument(overrides?: Partial<ListingDocument>): ListingDocument {
|
|||||||
viewCount: 10,
|
viewCount: 10,
|
||||||
saveCount: 5,
|
saveCount: 5,
|
||||||
projectName: null,
|
projectName: null,
|
||||||
|
legalStatus: null,
|
||||||
amenities: ['parking'],
|
amenities: ['parking'],
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -119,6 +119,7 @@ export class ListingIndexerService {
|
|||||||
viewCount: l.viewCount,
|
viewCount: l.viewCount,
|
||||||
saveCount: l.saveCount,
|
saveCount: l.saveCount,
|
||||||
projectName: p.projectName,
|
projectName: p.projectName,
|
||||||
|
legalStatus: p.legalStatus,
|
||||||
amenities: Array.isArray(p.amenities) ? (p.amenities as string[]) : [],
|
amenities: Array.isArray(p.amenities) ? (p.amenities as string[]) : [],
|
||||||
isFeatured: l.featuredUntil && l.featuredUntil > new Date()
|
isFeatured: l.featuredUntil && l.featuredUntil > new Date()
|
||||||
? featuredTierWeight(l.featuredPackage as string | null)
|
? featuredTierWeight(l.featuredPackage as string | null)
|
||||||
@@ -170,6 +171,7 @@ export class ListingIndexerService {
|
|||||||
viewCount: listing.viewCount,
|
viewCount: listing.viewCount,
|
||||||
saveCount: listing.saveCount,
|
saveCount: listing.saveCount,
|
||||||
projectName: p.projectName,
|
projectName: p.projectName,
|
||||||
|
legalStatus: p.legalStatus,
|
||||||
amenities: Array.isArray(p.amenities) ? (p.amenities as string[]) : [],
|
amenities: Array.isArray(p.amenities) ? (p.amenities as string[]) : [],
|
||||||
isFeatured: listing.featuredUntil && listing.featuredUntil > new Date()
|
isFeatured: listing.featuredUntil && listing.featuredUntil > new Date()
|
||||||
? featuredTierWeight(listing.featuredPackage as string | null)
|
? featuredTierWeight(listing.featuredPackage as string | null)
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ export class PostgresSearchRepository implements ISearchRepository {
|
|||||||
ST_Y(p."location"::geometry) AS "lat",
|
ST_Y(p."location"::geometry) AS "lat",
|
||||||
ST_X(p."location"::geometry) AS "lng",
|
ST_X(p."location"::geometry) AS "lng",
|
||||||
l."agentId", l."sellerId", l."status", l."publishedAt",
|
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"
|
FROM "Listing" l JOIN "Property" p ON l."propertyId" = p."id"
|
||||||
${whereClause}
|
${whereClause}
|
||||||
${orderClause}
|
${orderClause}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export interface RawListingRow {
|
|||||||
viewCount: number;
|
viewCount: number;
|
||||||
saveCount: number;
|
saveCount: number;
|
||||||
projectName: string | null;
|
projectName: string | null;
|
||||||
|
legalStatus?: string | null;
|
||||||
amenities: unknown;
|
amenities: unknown;
|
||||||
featuredUntil?: Date | string | null;
|
featuredUntil?: Date | string | null;
|
||||||
}
|
}
|
||||||
@@ -60,6 +61,7 @@ export function mapRowToListingDocument(row: RawListingRow): ListingDocument {
|
|||||||
viewCount: row.viewCount ?? 0,
|
viewCount: row.viewCount ?? 0,
|
||||||
saveCount: row.saveCount ?? 0,
|
saveCount: row.saveCount ?? 0,
|
||||||
projectName: row.projectName,
|
projectName: row.projectName,
|
||||||
|
legalStatus: row.legalStatus ?? null,
|
||||||
amenities: Array.isArray(row.amenities) ? (row.amenities as string[]) : [],
|
amenities: Array.isArray(row.amenities) ? (row.amenities as string[]) : [],
|
||||||
isFeatured: row.featuredUntil && new Date(row.featuredUntil) > new Date() ? 1 : 0,
|
isFeatured: row.featuredUntil && new Date(row.featuredUntil) > new Date() ? 1 : 0,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,32 +1,3 @@
|
|||||||
import { Injectable, type OnModuleInit } from '@nestjs/common';
|
// Re-export from SharedModule for backward compatibility.
|
||||||
import { Client as TypesenseClient } from 'typesense';
|
// The canonical location is now @modules/shared.
|
||||||
import { LoggerService } from '@modules/shared';
|
export { TypesenseClientService } 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ const LISTING_SCHEMA: CollectionCreateSchema = {
|
|||||||
{ name: 'viewCount', type: 'int32', facet: false },
|
{ name: 'viewCount', type: 'int32', facet: false },
|
||||||
{ name: 'saveCount', type: 'int32', facet: false },
|
{ name: 'saveCount', type: 'int32', facet: false },
|
||||||
{ name: 'projectName', type: 'string', facet: true, optional: true },
|
{ 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: 'amenities', type: 'string[]', facet: true, optional: true },
|
||||||
{ name: 'isFeatured', type: 'int32', facet: true },
|
{ name: 'isFeatured', type: 'int32', facet: true },
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Module, type OnModuleInit } from '@nestjs/common';
|
import { Module, type OnModuleInit } from '@nestjs/common';
|
||||||
import { CqrsModule } from '@nestjs/cqrs';
|
import { CqrsModule } from '@nestjs/cqrs';
|
||||||
import { makeCounterProvider } from '@willsoto/nestjs-prometheus';
|
import { makeCounterProvider } from '@willsoto/nestjs-prometheus';
|
||||||
import { LoggerService } from '@modules/shared';
|
import { LoggerService, TypesenseClientService } from '@modules/shared';
|
||||||
import { SubscriptionsModule } from '@modules/subscriptions';
|
import { SubscriptionsModule } from '@modules/subscriptions';
|
||||||
import { CreateSavedSearchHandler } from './application/commands/create-saved-search/create-saved-search.handler';
|
import { CreateSavedSearchHandler } from './application/commands/create-saved-search/create-saved-search.handler';
|
||||||
import { DeleteSavedSearchHandler } from './application/commands/delete-saved-search/delete-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 { ListingIndexerService } from './infrastructure/services/listing-indexer.service';
|
||||||
import { PostgresSearchRepository } from './infrastructure/services/postgres-search.repository';
|
import { PostgresSearchRepository } from './infrastructure/services/postgres-search.repository';
|
||||||
import { ResilientSearchRepository, SEARCH_DEGRADATION_TOTAL } from './infrastructure/services/resilient-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 { TypesenseSearchRepository } from './infrastructure/services/typesense-search.repository';
|
||||||
import { SavedSearchController } from './presentation/controllers/saved-search.controller';
|
import { SavedSearchController } from './presentation/controllers/saved-search.controller';
|
||||||
import { SearchController } from './presentation/controllers/search.controller';
|
import { SearchController } from './presentation/controllers/search.controller';
|
||||||
@@ -34,7 +33,6 @@ const QueryHandlers = [SearchPropertiesHandler, GeoSearchHandler, GetSavedSearch
|
|||||||
controllers: [SearchController, SavedSearchController],
|
controllers: [SearchController, SavedSearchController],
|
||||||
providers: [
|
providers: [
|
||||||
// Infrastructure
|
// Infrastructure
|
||||||
TypesenseClientService,
|
|
||||||
TypesenseSearchRepository,
|
TypesenseSearchRepository,
|
||||||
PostgresSearchRepository,
|
PostgresSearchRepository,
|
||||||
ResilientSearchRepository,
|
ResilientSearchRepository,
|
||||||
@@ -61,11 +59,10 @@ const QueryHandlers = [SearchPropertiesHandler, GeoSearchHandler, GetSavedSearch
|
|||||||
...CommandHandlers,
|
...CommandHandlers,
|
||||||
...QueryHandlers,
|
...QueryHandlers,
|
||||||
],
|
],
|
||||||
exports: [ListingIndexerService, SEARCH_REPOSITORY, TypesenseClientService],
|
exports: [ListingIndexerService, SEARCH_REPOSITORY],
|
||||||
})
|
})
|
||||||
export class SearchModule implements OnModuleInit {
|
export class SearchModule implements OnModuleInit {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly typesenseClient: TypesenseClientService,
|
|
||||||
private readonly searchRepo: ResilientSearchRepository,
|
private readonly searchRepo: ResilientSearchRepository,
|
||||||
private readonly logger: LoggerService,
|
private readonly logger: LoggerService,
|
||||||
) {}
|
) {}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export { RedisService } from './redis.service';
|
|||||||
export { RedisIoAdapter } from './redis-io.adapter';
|
export { RedisIoAdapter } from './redis-io.adapter';
|
||||||
export { CacheService, CachePrefix, CacheTTL } from './cache.service';
|
export { CacheService, CachePrefix, CacheTTL } from './cache.service';
|
||||||
export { LoggerService } from './logger.service';
|
export { LoggerService } from './logger.service';
|
||||||
|
export { TypesenseClientService } from './typesense-client.service';
|
||||||
export { EventBusService } from './event-bus.service';
|
export { EventBusService } from './event-bus.service';
|
||||||
export { GlobalExceptionFilter } from './filters/global-exception.filter';
|
export { GlobalExceptionFilter } from './filters/global-exception.filter';
|
||||||
export { CorrelationIdMiddleware } from './middleware/correlation-id.middleware';
|
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 { SanitizeInputMiddleware } from './infrastructure/middleware/sanitize-input.middleware';
|
||||||
import { PrismaService } from './infrastructure/prisma.service';
|
import { PrismaService } from './infrastructure/prisma.service';
|
||||||
import { RedisService } from './infrastructure/redis.service';
|
import { RedisService } from './infrastructure/redis.service';
|
||||||
|
import { TypesenseClientService } from './infrastructure/typesense-client.service';
|
||||||
|
|
||||||
@Global()
|
@Global()
|
||||||
@Module({
|
@Module({
|
||||||
@@ -34,6 +35,7 @@ import { RedisService } from './infrastructure/redis.service';
|
|||||||
RedisService,
|
RedisService,
|
||||||
CacheService,
|
CacheService,
|
||||||
EventBusService,
|
EventBusService,
|
||||||
|
TypesenseClientService,
|
||||||
makeCounterProvider({
|
makeCounterProvider({
|
||||||
name: CACHE_HIT_TOTAL,
|
name: CACHE_HIT_TOTAL,
|
||||||
help: 'Total number of cache hits',
|
help: 'Total number of cache hits',
|
||||||
@@ -54,7 +56,7 @@ import { RedisService } from './infrastructure/redis.service';
|
|||||||
useClass: GlobalExceptionFilter,
|
useClass: GlobalExceptionFilter,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
exports: [PrismaService, RedisService, CacheService, LoggerService, EventBusService, FieldEncryptionService, PrometheusModule],
|
exports: [PrismaService, RedisService, CacheService, LoggerService, EventBusService, FieldEncryptionService, TypesenseClientService, PrometheusModule],
|
||||||
})
|
})
|
||||||
export class SharedModule implements NestModule {
|
export class SharedModule implements NestModule {
|
||||||
configure(consumer: MiddlewareConsumer): void {
|
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 { OnEvent } from '@nestjs/event-emitter';
|
||||||
import { type BankTransferConfirmedEvent } from '@modules/payments';
|
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.
|
* 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
|
* happens upstream during payment creation; this listener is the
|
||||||
* side-effect hook that flips the subscription status.
|
* 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
|
* NOTE: Intentionally defensive — if no subscription exists yet the event
|
||||||
* is logged and skipped; downstream processes (CS or renewal cron) pick it up.
|
* is logged and skipped; downstream processes (CS or renewal cron) pick it up.
|
||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class BankTransferSubscriptionActivationHandler {
|
export class BankTransferSubscriptionActivationHandler {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
@Inject(SUBSCRIPTION_REPOSITORY)
|
||||||
|
private readonly subscriptionRepo: ISubscriptionRepository,
|
||||||
private readonly logger: LoggerService,
|
private readonly logger: LoggerService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -32,10 +40,7 @@ export class BankTransferSubscriptionActivationHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const subscription = await this.prisma.subscription.findFirst({
|
const subscription = await this.subscriptionRepo.findByUserId(event.userId);
|
||||||
where: { userId: event.userId },
|
|
||||||
orderBy: { updatedAt: 'desc' },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!subscription) {
|
if (!subscription) {
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
@@ -46,21 +51,18 @@ export class BankTransferSubscriptionActivationHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const baseDate =
|
const baseStart =
|
||||||
|
subscription.currentPeriodEnd > now ? subscription.currentPeriodStart : now;
|
||||||
|
const baseEnd =
|
||||||
subscription.currentPeriodEnd > now ? subscription.currentPeriodEnd : now;
|
subscription.currentPeriodEnd > now ? subscription.currentPeriodEnd : now;
|
||||||
|
|
||||||
// Default to 30-day extension; renewal command handles more granular math
|
// Default to 30-day extension; renewal command handles more granular math
|
||||||
const nextPeriodEnd = new Date(
|
const nextPeriodEnd = new Date(
|
||||||
baseDate.getTime() + 30 * 24 * 60 * 60 * 1000,
|
baseEnd.getTime() + 30 * 24 * 60 * 60 * 1000,
|
||||||
);
|
);
|
||||||
|
|
||||||
await this.prisma.subscription.update({
|
subscription.renewPeriod(baseStart, nextPeriodEnd);
|
||||||
where: { id: subscription.id },
|
await this.subscriptionRepo.update(subscription);
|
||||||
data: {
|
|
||||||
status: 'ACTIVE',
|
|
||||||
currentPeriodEnd: nextPeriodEnd,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`Subscription activated via bank transfer: subscriptionId=${subscription.id}, userId=${event.userId}, paymentId=${event.aggregateId}, periodEnd=${nextPeriodEnd.toISOString()}`,
|
`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 { GetBillingHistoryHandler } from './application/queries/get-billing-history/get-billing-history.handler';
|
||||||
import { GetPlanHandler } from './application/queries/get-plan/get-plan.handler';
|
import { GetPlanHandler } from './application/queries/get-plan/get-plan.handler';
|
||||||
import { SUBSCRIPTION_REPOSITORY } from './domain/repositories/subscription.repository';
|
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 { ListingCreatedUsageHandler } from './infrastructure/event-handlers/listing-created-usage.handler';
|
||||||
import { SavedSearchCreatedUsageHandler } from './infrastructure/event-handlers/saved-search-created-usage.handler';
|
import { SavedSearchCreatedUsageHandler } from './infrastructure/event-handlers/saved-search-created-usage.handler';
|
||||||
import { PrismaSubscriptionRepository } from './infrastructure/repositories/prisma-subscription.repository';
|
import { PrismaSubscriptionRepository } from './infrastructure/repositories/prisma-subscription.repository';
|
||||||
@@ -40,6 +41,7 @@ const QueryHandlers = [
|
|||||||
// Event Listeners
|
// Event Listeners
|
||||||
ListingCreatedUsageHandler,
|
ListingCreatedUsageHandler,
|
||||||
SavedSearchCreatedUsageHandler,
|
SavedSearchCreatedUsageHandler,
|
||||||
|
BankTransferSubscriptionActivationHandler,
|
||||||
|
|
||||||
// CQRS
|
// CQRS
|
||||||
...CommandHandlers,
|
...CommandHandlers,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { ImageGallery } from '@/components/listings/image-gallery';
|
|||||||
import { InquiryModal } from '@/components/listings/inquiry-modal';
|
import { InquiryModal } from '@/components/listings/inquiry-modal';
|
||||||
import { PriceHistoryChart } from '@/components/listings/price-history-chart';
|
import { PriceHistoryChart } from '@/components/listings/price-history-chart';
|
||||||
import { SocialShare } from '@/components/listings/social-share';
|
import { SocialShare } from '@/components/listings/social-share';
|
||||||
|
import { ReportListingModal } from '@/components/listings/report-listing-modal';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
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 propertyTypeLabel = getLabel(PROPERTY_TYPES, property.propertyType);
|
||||||
|
|
||||||
const [inquiryOpen, setInquiryOpen] = React.useState(false);
|
const [inquiryOpen, setInquiryOpen] = React.useState(false);
|
||||||
|
const [reportOpen, setReportOpen] = React.useState(false);
|
||||||
const [neighborhoodScore, setNeighborhoodScore] = React.useState<NeighborhoodScoreResult | null>(null);
|
const [neighborhoodScore, setNeighborhoodScore] = React.useState<NeighborhoodScoreResult | null>(null);
|
||||||
const [priceHistory, setPriceHistory] = React.useState<PriceHistoryItem[]>([]);
|
const [priceHistory, setPriceHistory] = React.useState<PriceHistoryItem[]>([]);
|
||||||
const [comps, setComps] = React.useState<ListingSimilarItem[]>([]);
|
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="Hướng" value={getLabel(DIRECTIONS, property.direction) || '---'} />
|
||||||
<InfoItem label="Năm xây" value={property.yearBuilt ? `${property.yearBuilt}` : '---'} />
|
<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="Dự án" value={property.projectName || '---'} />
|
||||||
<InfoItem
|
<InfoItem
|
||||||
label="Cách metro gần nhất"
|
label="Cách metro gần nhất"
|
||||||
@@ -867,6 +880,24 @@ export function ListingDetailClient({ listing }: ListingDetailClientProps) {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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 */}
|
{/* Stats */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="pt-5">
|
<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 Furnishing = 'FULLY_FURNISHED' | 'BASIC_FURNISHED' | 'UNFURNISHED';
|
||||||
export type PropertyCondition = 'NEW' | 'LIKE_NEW' | 'RENOVATED' | 'USED';
|
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 ──────────────────────────────────────────
|
// ─── Interfaces ──────────────────────────────────────────
|
||||||
|
|
||||||
@@ -99,7 +109,8 @@ export interface ListingDetail {
|
|||||||
totalFloors: number | null;
|
totalFloors: number | null;
|
||||||
direction: Direction | null;
|
direction: Direction | null;
|
||||||
yearBuilt: number | null;
|
yearBuilt: number | null;
|
||||||
legalStatus: string | null;
|
legalStatus: LegalStatus | null;
|
||||||
|
certificateVerified: boolean;
|
||||||
amenities: string[] | null;
|
amenities: string[] | null;
|
||||||
nearbyPOIs: unknown;
|
nearbyPOIs: unknown;
|
||||||
metroDistanceM: number | null;
|
metroDistanceM: number | null;
|
||||||
@@ -303,4 +314,7 @@ export const listingsApi = {
|
|||||||
`/analytics/neighborhoods/${encodeURIComponent(district)}/score?city=${encodeURIComponent(city)}`,
|
`/analytics/neighborhoods/${encodeURIComponent(district)}/score?city=${encodeURIComponent(city)}`,
|
||||||
)
|
)
|
||||||
.then((res) => res.data),
|
.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