Compare commits
7 Commits
8706fff92f
...
8681eb9aa9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8681eb9aa9 | ||
|
|
7a854373b3 | ||
|
|
36a9b00cf1 | ||
|
|
0329455e9a | ||
|
|
94d462ef4f | ||
|
|
4be5eb90a4 | ||
|
|
05be5f4467 |
@@ -2,6 +2,7 @@ import { forwardRef, Module } from '@nestjs/common';
|
||||
import { CqrsModule } from '@nestjs/cqrs';
|
||||
import { AuthModule } from '@modules/auth';
|
||||
import { ListingsModule } from '@modules/listings';
|
||||
import { AI_CONFIG_PROVIDER } from '@modules/shared';
|
||||
import { SubscriptionsModule } from '@modules/subscriptions';
|
||||
import { AdjustSubscriptionHandler } from './application/commands/adjust-subscription/adjust-subscription.handler';
|
||||
import { ApproveKycHandler } from './application/commands/approve-kyc/approve-kyc.handler';
|
||||
@@ -21,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';
|
||||
@@ -34,6 +36,7 @@ import { MODERATION_AUDIT_LOG_REPOSITORY } from './domain/repositories/moderatio
|
||||
import { PrismaAdminQueryRepository } from './infrastructure/repositories/prisma-admin-query.repository';
|
||||
import { PrismaAuditLogRepository } from './infrastructure/repositories/prisma-audit-log.repository';
|
||||
import { PrismaModerationAuditLogRepository } from './infrastructure/repositories/prisma-moderation-audit-log.repository';
|
||||
import { SystemSettingsAiConfigProvider } from './infrastructure/adapters/system-settings-ai-config.provider';
|
||||
import { AdminModerationAuditController } from './presentation/controllers/admin-moderation-audit.controller';
|
||||
import { AdminModerationController } from './presentation/controllers/admin-moderation.controller';
|
||||
import { AdminController } from './presentation/controllers/admin.controller';
|
||||
@@ -54,6 +57,7 @@ const CommandHandlers = [
|
||||
|
||||
const QueryHandlers = [
|
||||
GetModerationQueueHandler,
|
||||
GetFlaggedListingsHandler,
|
||||
GetDashboardStatsHandler,
|
||||
GetRevenueStatsHandler,
|
||||
GetUsersHandler,
|
||||
@@ -82,6 +86,7 @@ const QueryHandlers = [
|
||||
|
||||
// Services
|
||||
SystemSettingsService,
|
||||
{ provide: AI_CONFIG_PROVIDER, useClass: SystemSettingsAiConfigProvider },
|
||||
|
||||
// CQRS
|
||||
...CommandHandlers,
|
||||
@@ -93,6 +98,6 @@ const QueryHandlers = [
|
||||
AdminAuditListener,
|
||||
ModerationAuditListener,
|
||||
],
|
||||
exports: [SystemSettingsService],
|
||||
exports: [SystemSettingsService, AI_CONFIG_PROVIDER],
|
||||
})
|
||||
export class AdminModule {}
|
||||
|
||||
@@ -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,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import {
|
||||
type AiRuntimeConfig,
|
||||
type IAIConfigProvider,
|
||||
} from '@modules/shared';
|
||||
import { SystemSettingsService } from '../../application/services/system-settings.service';
|
||||
|
||||
/**
|
||||
* Adapter that exposes the admin-owned `SystemSettingsService` through the
|
||||
* shared `IAIConfigProvider` port. Lets analytics (and any other module)
|
||||
* read AI runtime config without importing AdminModule (A-09).
|
||||
*/
|
||||
@Injectable()
|
||||
export class SystemSettingsAiConfigProvider implements IAIConfigProvider {
|
||||
constructor(private readonly systemSettings: SystemSettingsService) {}
|
||||
|
||||
async getAiConfig(): Promise<AiRuntimeConfig> {
|
||||
const settings = await this.systemSettings.getAiSettings();
|
||||
return {
|
||||
apiUrl: settings.apiUrl,
|
||||
apiKey: settings.apiKey,
|
||||
model: settings.model,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -43,67 +43,71 @@ export async function getDashboardStats(prisma: PrismaService): Promise<Dashboar
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Simple in-process cache for revenue stats (TTL = 60 seconds)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface RevenueCacheEntry {
|
||||
expiresAt: number;
|
||||
data: RevenueStatsItem[];
|
||||
}
|
||||
|
||||
const revenueStatsCache = new Map<string, RevenueCacheEntry>();
|
||||
|
||||
function buildCacheKey(startDate: Date, endDate: Date, groupBy: string): string {
|
||||
return `${startDate.toISOString()}|${endDate.toISOString()}|${groupBy}`;
|
||||
}
|
||||
|
||||
// Raw row returned by Postgres for the aggregation query
|
||||
interface RevenueRawRow {
|
||||
period: string;
|
||||
total_revenue: bigint;
|
||||
subscription_revenue: bigint;
|
||||
listing_fee_revenue: bigint;
|
||||
featured_listing_revenue: bigint;
|
||||
transaction_count: bigint;
|
||||
}
|
||||
|
||||
export async function getRevenueStats(
|
||||
prisma: PrismaService,
|
||||
startDate: Date,
|
||||
endDate: Date,
|
||||
groupBy: 'day' | 'month',
|
||||
): Promise<RevenueStatsItem[]> {
|
||||
const payments = await prisma.payment.findMany({
|
||||
where: {
|
||||
status: 'COMPLETED',
|
||||
createdAt: { gte: startDate, lte: endDate },
|
||||
},
|
||||
select: {
|
||||
type: true,
|
||||
amountVND: true,
|
||||
createdAt: true,
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
|
||||
const grouped = new Map<string, {
|
||||
totalRevenue: bigint;
|
||||
subscriptionRevenue: bigint;
|
||||
listingFeeRevenue: bigint;
|
||||
featuredListingRevenue: bigint;
|
||||
transactionCount: number;
|
||||
}>();
|
||||
|
||||
for (const payment of payments) {
|
||||
const period = groupBy === 'day'
|
||||
? payment.createdAt.toISOString().slice(0, 10)
|
||||
: payment.createdAt.toISOString().slice(0, 7);
|
||||
|
||||
if (!grouped.has(period)) {
|
||||
grouped.set(period, {
|
||||
totalRevenue: 0n,
|
||||
subscriptionRevenue: 0n,
|
||||
listingFeeRevenue: 0n,
|
||||
featuredListingRevenue: 0n,
|
||||
transactionCount: 0,
|
||||
});
|
||||
}
|
||||
|
||||
const stats = grouped.get(period)!;
|
||||
stats.totalRevenue += payment.amountVND;
|
||||
stats.transactionCount++;
|
||||
|
||||
switch (payment.type) {
|
||||
case 'SUBSCRIPTION':
|
||||
stats.subscriptionRevenue += payment.amountVND;
|
||||
break;
|
||||
case 'LISTING_FEE':
|
||||
stats.listingFeeRevenue += payment.amountVND;
|
||||
break;
|
||||
case 'FEATURED_LISTING':
|
||||
stats.featuredListingRevenue += payment.amountVND;
|
||||
break;
|
||||
}
|
||||
const cacheKey = buildCacheKey(startDate, endDate, groupBy);
|
||||
const cached = revenueStatsCache.get(cacheKey);
|
||||
if (cached && cached.expiresAt > Date.now()) {
|
||||
return cached.data;
|
||||
}
|
||||
|
||||
return Array.from(grouped.entries()).map(([period, stats]) => ({
|
||||
period,
|
||||
...stats,
|
||||
const truncUnit = groupBy === 'day' ? 'day' : 'month';
|
||||
|
||||
const rows = await prisma.$queryRaw<RevenueRawRow[]>`
|
||||
SELECT
|
||||
TO_CHAR(DATE_TRUNC(${truncUnit}, "createdAt"), 'YYYY-MM-DD') AS period,
|
||||
SUM("amountVND") AS total_revenue,
|
||||
SUM(CASE WHEN type = 'SUBSCRIPTION' THEN "amountVND" ELSE 0 END) AS subscription_revenue,
|
||||
SUM(CASE WHEN type = 'LISTING_FEE' THEN "amountVND" ELSE 0 END) AS listing_fee_revenue,
|
||||
SUM(CASE WHEN type = 'FEATURED_LISTING' THEN "amountVND" ELSE 0 END) AS featured_listing_revenue,
|
||||
COUNT(*) AS transaction_count
|
||||
FROM "Payment"
|
||||
WHERE status = 'COMPLETED'
|
||||
AND "createdAt" >= ${startDate}
|
||||
AND "createdAt" <= ${endDate}
|
||||
GROUP BY DATE_TRUNC(${truncUnit}, "createdAt")
|
||||
ORDER BY DATE_TRUNC(${truncUnit}, "createdAt") ASC
|
||||
`;
|
||||
|
||||
const data: RevenueStatsItem[] = rows.map((row) => ({
|
||||
period: row.period,
|
||||
totalRevenue: BigInt(row.total_revenue),
|
||||
subscriptionRevenue: BigInt(row.subscription_revenue),
|
||||
listingFeeRevenue: BigInt(row.listing_fee_revenue),
|
||||
featuredListingRevenue: BigInt(row.featured_listing_revenue),
|
||||
transactionCount: Number(row.transaction_count),
|
||||
}));
|
||||
|
||||
revenueStatsCache.set(cacheKey, { expiresAt: Date.now() + 60_000, data });
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -1,5 +1,31 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsDateString, IsIn, IsOptional } from 'class-validator';
|
||||
import { IsDateString, IsIn, IsOptional, registerDecorator, ValidationArguments, ValidationOptions } from 'class-validator';
|
||||
|
||||
function MaxDateRangeDays(maxDays: number, validationOptions?: ValidationOptions) {
|
||||
return function (object: object, propertyName: string) {
|
||||
registerDecorator({
|
||||
name: 'maxDateRangeDays',
|
||||
target: (object as { constructor: new (...args: unknown[]) => unknown }).constructor,
|
||||
propertyName,
|
||||
options: validationOptions,
|
||||
validator: {
|
||||
validate(_value: unknown, args: ValidationArguments) {
|
||||
const dto = args.object as RevenueStatsDto;
|
||||
if (!dto.startDate || !dto.endDate) return true;
|
||||
const start = new Date(dto.startDate);
|
||||
const end = new Date(dto.endDate);
|
||||
const diffMs = end.getTime() - start.getTime();
|
||||
const diffDays = diffMs / (1000 * 60 * 60 * 24);
|
||||
return diffDays >= 0 && diffDays <= maxDays;
|
||||
},
|
||||
defaultMessage(args: ValidationArguments) {
|
||||
return `Date range must not exceed ${(args.constraints as number[])[0]} days`;
|
||||
},
|
||||
},
|
||||
constraints: [maxDays],
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export class RevenueStatsDto {
|
||||
@ApiProperty({ description: 'Start date (ISO 8601)', example: '2025-01-01' })
|
||||
@@ -8,6 +34,7 @@ export class RevenueStatsDto {
|
||||
|
||||
@ApiProperty({ description: 'End date (ISO 8601)', example: '2025-12-31' })
|
||||
@IsDateString()
|
||||
@MaxDateRangeDays(366, { message: 'Date range must not exceed 366 days' })
|
||||
endDate!: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Group results by day or month', enum: ['day', 'month'], default: 'month' })
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { forwardRef, Module } from '@nestjs/common';
|
||||
import { CqrsModule } from '@nestjs/cqrs';
|
||||
import { AdminModule } from '@modules/admin';
|
||||
import { ListingsModule } from '@modules/listings';
|
||||
import { ProjectsModule } from '@modules/projects';
|
||||
import { GenerateReportHandler } from './application/commands/generate-report/generate-report.handler';
|
||||
@@ -78,7 +77,7 @@ const EventHandlers = [
|
||||
];
|
||||
|
||||
@Module({
|
||||
imports: [CqrsModule, forwardRef(() => ListingsModule), forwardRef(() => AdminModule), ProjectsModule],
|
||||
imports: [CqrsModule, forwardRef(() => ListingsModule), ProjectsModule],
|
||||
controllers: [AnalyticsController, AvmController],
|
||||
providers: [
|
||||
// AI service client
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { HttpStatus, Inject } from '@nestjs/common';
|
||||
import { QueryBus, QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
|
||||
import { DomainException, ErrorCode, LoggerService } from '@modules/shared';
|
||||
import { SystemSettingsService } from '@modules/admin/application/services/system-settings.service';
|
||||
import {
|
||||
AI_CONFIG_PROVIDER,
|
||||
DomainException,
|
||||
ErrorCode,
|
||||
type IAIConfigProvider,
|
||||
LoggerService,
|
||||
} from '@modules/shared';
|
||||
import {
|
||||
LISTING_REPOSITORY,
|
||||
type IListingRepository,
|
||||
@@ -91,7 +96,8 @@ export class GetListingAiAdviceHandler
|
||||
@Inject(LISTING_REPOSITORY)
|
||||
private readonly listingRepo: IListingRepository,
|
||||
private readonly queryBus: QueryBus,
|
||||
private readonly systemSettings: SystemSettingsService,
|
||||
@Inject(AI_CONFIG_PROVIDER)
|
||||
private readonly aiConfig: IAIConfigProvider,
|
||||
private readonly logger: LoggerService,
|
||||
) {}
|
||||
|
||||
@@ -113,7 +119,7 @@ export class GetListingAiAdviceHandler
|
||||
this.fetchScore(listing),
|
||||
]);
|
||||
|
||||
const settings = await this.systemSettings.getAiSettings();
|
||||
const settings = await this.aiConfig.getAiConfig();
|
||||
if (!settings.apiKey) {
|
||||
throw new DomainException(
|
||||
ErrorCode.AI_NOT_CONFIGURED,
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { HttpStatus, Inject } from '@nestjs/common';
|
||||
import { QueryBus, QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
|
||||
import { DomainException, ErrorCode, LoggerService } from '@modules/shared';
|
||||
import { SystemSettingsService } from '@modules/admin/application/services/system-settings.service';
|
||||
import {
|
||||
AI_CONFIG_PROVIDER,
|
||||
DomainException,
|
||||
ErrorCode,
|
||||
type IAIConfigProvider,
|
||||
LoggerService,
|
||||
} from '@modules/shared';
|
||||
import {
|
||||
PROJECT_REPOSITORY,
|
||||
type IProjectRepository,
|
||||
@@ -75,7 +80,8 @@ export class GetProjectAiAdviceHandler
|
||||
@Inject(PROJECT_REPOSITORY)
|
||||
private readonly projectRepo: IProjectRepository,
|
||||
private readonly queryBus: QueryBus,
|
||||
private readonly systemSettings: SystemSettingsService,
|
||||
@Inject(AI_CONFIG_PROVIDER)
|
||||
private readonly aiConfig: IAIConfigProvider,
|
||||
private readonly logger: LoggerService,
|
||||
) {}
|
||||
|
||||
@@ -96,7 +102,7 @@ export class GetProjectAiAdviceHandler
|
||||
this.fetchScore(project),
|
||||
]);
|
||||
|
||||
const settings = await this.systemSettings.getAiSettings();
|
||||
const settings = await this.aiConfig.getAiConfig();
|
||||
if (!settings.apiKey) {
|
||||
throw new DomainException(
|
||||
ErrorCode.AI_NOT_CONFIGURED,
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import { InternalServerErrorException } from '@nestjs/common';
|
||||
import { NotFoundException, ValidationException } from '@modules/shared';
|
||||
import { PropertyDocumentEntity } from '../../domain/entities/property-document.entity';
|
||||
import { type IPropertyDocumentRepository } from '../../domain/repositories/property-document.repository';
|
||||
import { ApproveDocumentCommand } from '../commands/approve-document/approve-document.command';
|
||||
import { ApproveDocumentHandler } from '../commands/approve-document/approve-document.handler';
|
||||
|
||||
describe('ApproveDocumentHandler', () => {
|
||||
let handler: ApproveDocumentHandler;
|
||||
let mockDocRepo: { [K in keyof IPropertyDocumentRepository]: ReturnType<typeof vi.fn> };
|
||||
let mockPrisma: { property: { update: ReturnType<typeof vi.fn> } };
|
||||
let mockLogger: { log: ReturnType<typeof vi.fn>; warn: ReturnType<typeof vi.fn>; error: ReturnType<typeof vi.fn>; debug: ReturnType<typeof vi.fn>; verbose: ReturnType<typeof vi.fn> };
|
||||
|
||||
const createPendingDoc = (id = 'doc-1') =>
|
||||
PropertyDocumentEntity.createNew(
|
||||
id, 'prop-1', 'user-1', 'SO_DO',
|
||||
'http://storage.local/documents/test.pdf',
|
||||
'sodo.pdf', 'application/pdf', 1024,
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
mockDocRepo = {
|
||||
findById: vi.fn(),
|
||||
findByPropertyId: vi.fn(),
|
||||
save: vi.fn(),
|
||||
update: vi.fn().mockResolvedValue(undefined),
|
||||
delete: vi.fn(),
|
||||
findPendingReview: vi.fn(),
|
||||
countApprovedByPropertyId: vi.fn(),
|
||||
};
|
||||
|
||||
mockPrisma = {
|
||||
property: {
|
||||
update: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
};
|
||||
|
||||
mockLogger = { log: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), verbose: vi.fn() };
|
||||
|
||||
handler = new ApproveDocumentHandler(
|
||||
mockDocRepo as any,
|
||||
mockPrisma as any,
|
||||
mockLogger as any,
|
||||
);
|
||||
});
|
||||
|
||||
it('approves a pending document successfully', async () => {
|
||||
const doc = createPendingDoc();
|
||||
mockDocRepo.findById.mockResolvedValue(doc);
|
||||
|
||||
const command = new ApproveDocumentCommand('doc-1', 'admin-1');
|
||||
const result = await handler.execute(command);
|
||||
|
||||
expect(result.documentId).toBe('doc-1');
|
||||
expect(result.status).toBe('APPROVED');
|
||||
expect(result.message).toContain('xác minh thành công');
|
||||
expect(mockDocRepo.update).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('updates the document entity status to APPROVED', async () => {
|
||||
const doc = createPendingDoc();
|
||||
mockDocRepo.findById.mockResolvedValue(doc);
|
||||
|
||||
const command = new ApproveDocumentCommand('doc-1', 'admin-1');
|
||||
await handler.execute(command);
|
||||
|
||||
const updatedDoc = mockDocRepo.update.mock.calls[0]![0];
|
||||
expect(updatedDoc.status).toBe('APPROVED');
|
||||
expect(updatedDoc.reviewedById).toBe('admin-1');
|
||||
expect(updatedDoc.reviewedAt).not.toBeNull();
|
||||
expect(updatedDoc.rejectionReason).toBeNull();
|
||||
});
|
||||
|
||||
it('sets certificateVerified on the property', async () => {
|
||||
const doc = createPendingDoc();
|
||||
mockDocRepo.findById.mockResolvedValue(doc);
|
||||
|
||||
const command = new ApproveDocumentCommand('doc-1', 'admin-1');
|
||||
await handler.execute(command);
|
||||
|
||||
expect(mockPrisma.property.update).toHaveBeenCalledWith({
|
||||
where: { id: 'prop-1' },
|
||||
data: { certificateVerified: true },
|
||||
});
|
||||
});
|
||||
|
||||
it('throws NotFoundException when document does not exist', async () => {
|
||||
mockDocRepo.findById.mockResolvedValue(null);
|
||||
|
||||
const command = new ApproveDocumentCommand('nonexistent', 'admin-1');
|
||||
|
||||
await expect(handler.execute(command)).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('throws ValidationException when document is not PENDING_REVIEW', async () => {
|
||||
const doc = createPendingDoc();
|
||||
doc.approve('admin-old'); // status becomes APPROVED
|
||||
mockDocRepo.findById.mockResolvedValue(doc);
|
||||
|
||||
const command = new ApproveDocumentCommand('doc-1', 'admin-2');
|
||||
|
||||
await expect(handler.execute(command)).rejects.toThrow(ValidationException);
|
||||
await expect(handler.execute(command)).rejects.toThrow(/APPROVED/);
|
||||
});
|
||||
|
||||
it('throws ValidationException for REJECTED document', async () => {
|
||||
const doc = createPendingDoc();
|
||||
doc.reject('admin-old', 'bad'); // status becomes REJECTED
|
||||
mockDocRepo.findById.mockResolvedValue(doc);
|
||||
|
||||
const command = new ApproveDocumentCommand('doc-1', 'admin-2');
|
||||
|
||||
await expect(handler.execute(command)).rejects.toThrow(ValidationException);
|
||||
await expect(handler.execute(command)).rejects.toThrow(/REJECTED/);
|
||||
});
|
||||
|
||||
it('re-throws DomainException without wrapping', async () => {
|
||||
mockDocRepo.findById.mockResolvedValue(null);
|
||||
|
||||
const command = new ApproveDocumentCommand('doc-1', 'admin-1');
|
||||
|
||||
await expect(handler.execute(command)).rejects.toThrow(NotFoundException);
|
||||
await expect(handler.execute(command)).rejects.not.toThrow(InternalServerErrorException);
|
||||
});
|
||||
|
||||
it('wraps unexpected errors in InternalServerErrorException', async () => {
|
||||
mockDocRepo.findById.mockRejectedValue(new Error('DB timeout'));
|
||||
|
||||
const command = new ApproveDocumentCommand('doc-1', 'admin-1');
|
||||
|
||||
await expect(handler.execute(command)).rejects.toThrow(InternalServerErrorException);
|
||||
expect(mockLogger.error).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('accepts optional notes parameter', () => {
|
||||
const command = new ApproveDocumentCommand('doc-1', 'admin-1', 'Giay to hop le');
|
||||
expect(command.notes).toBe('Giay to hop le');
|
||||
});
|
||||
|
||||
it('notes parameter is undefined when not provided', () => {
|
||||
const command = new ApproveDocumentCommand('doc-1', 'admin-1');
|
||||
expect(command.notes).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
import { PropertyDocumentEntity } from '../../domain/entities/property-document.entity';
|
||||
import { type IPropertyDocumentRepository } from '../../domain/repositories/property-document.repository';
|
||||
import { GetPendingDocumentsHandler } from '../queries/get-pending-documents/get-pending-documents.handler';
|
||||
import { GetPendingDocumentsQuery } from '../queries/get-pending-documents/get-pending-documents.query';
|
||||
|
||||
describe('GetPendingDocumentsHandler', () => {
|
||||
let handler: GetPendingDocumentsHandler;
|
||||
let mockDocRepo: { [K in keyof IPropertyDocumentRepository]: ReturnType<typeof vi.fn> };
|
||||
|
||||
const createDoc = (id: string, propertyId = 'prop-1') =>
|
||||
PropertyDocumentEntity.createNew(
|
||||
id, propertyId, 'user-1', 'SO_DO',
|
||||
'http://storage.local/documents/test.pdf',
|
||||
'sodo.pdf', 'application/pdf', 1024, 'Mo ta',
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
mockDocRepo = {
|
||||
findById: vi.fn(),
|
||||
findByPropertyId: vi.fn(),
|
||||
save: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
findPendingReview: vi.fn(),
|
||||
countApprovedByPropertyId: vi.fn(),
|
||||
};
|
||||
|
||||
handler = new GetPendingDocumentsHandler(mockDocRepo as any);
|
||||
});
|
||||
|
||||
it('returns paginated pending documents', async () => {
|
||||
const docs = [createDoc('doc-1'), createDoc('doc-2')];
|
||||
mockDocRepo.findPendingReview.mockResolvedValue({ items: docs, total: 5 });
|
||||
|
||||
const query = new GetPendingDocumentsQuery(1, 2);
|
||||
const result = await handler.execute(query);
|
||||
|
||||
expect(result.items).toHaveLength(2);
|
||||
expect(result.total).toBe(5);
|
||||
expect(result.page).toBe(1);
|
||||
expect(result.limit).toBe(2);
|
||||
expect(mockDocRepo.findPendingReview).toHaveBeenCalledWith(1, 2);
|
||||
});
|
||||
|
||||
it('maps entity fields to DTO correctly', async () => {
|
||||
const doc = createDoc('doc-1');
|
||||
mockDocRepo.findPendingReview.mockResolvedValue({ items: [doc], total: 1 });
|
||||
|
||||
const query = new GetPendingDocumentsQuery(1, 10);
|
||||
const result = await handler.execute(query);
|
||||
|
||||
const item = result.items[0]!;
|
||||
expect(item.id).toBe('doc-1');
|
||||
expect(item.propertyId).toBe('prop-1');
|
||||
expect(item.uploadedById).toBe('user-1');
|
||||
expect(item.documentType).toBe('SO_DO');
|
||||
expect(item.status).toBe('PENDING_REVIEW');
|
||||
expect(item.url).toBe('http://storage.local/documents/test.pdf');
|
||||
expect(item.fileName).toBe('sodo.pdf');
|
||||
expect(item.mimeType).toBe('application/pdf');
|
||||
expect(item.fileSizeBytes).toBe(1024);
|
||||
expect(item.description).toBe('Mo ta');
|
||||
expect(item.rejectionReason).toBeNull();
|
||||
expect(item.reviewedById).toBeNull();
|
||||
expect(item.reviewedAt).toBeNull();
|
||||
expect(item.createdAt).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it('returns empty items when no pending documents', async () => {
|
||||
mockDocRepo.findPendingReview.mockResolvedValue({ items: [], total: 0 });
|
||||
|
||||
const query = new GetPendingDocumentsQuery(1, 20);
|
||||
const result = await handler.execute(query);
|
||||
|
||||
expect(result.items).toHaveLength(0);
|
||||
expect(result.total).toBe(0);
|
||||
expect(result.page).toBe(1);
|
||||
expect(result.limit).toBe(20);
|
||||
});
|
||||
|
||||
it('passes page and limit from query to repository', async () => {
|
||||
mockDocRepo.findPendingReview.mockResolvedValue({ items: [], total: 0 });
|
||||
|
||||
const query = new GetPendingDocumentsQuery(3, 50);
|
||||
await handler.execute(query);
|
||||
|
||||
expect(mockDocRepo.findPendingReview).toHaveBeenCalledWith(3, 50);
|
||||
});
|
||||
|
||||
it('returns correct page and limit in result', async () => {
|
||||
mockDocRepo.findPendingReview.mockResolvedValue({ items: [], total: 100 });
|
||||
|
||||
const query = new GetPendingDocumentsQuery(5, 25);
|
||||
const result = await handler.execute(query);
|
||||
|
||||
expect(result.page).toBe(5);
|
||||
expect(result.limit).toBe(25);
|
||||
expect(result.total).toBe(100);
|
||||
});
|
||||
|
||||
it('handles multiple documents from different properties', async () => {
|
||||
const docs = [
|
||||
createDoc('doc-1', 'prop-1'),
|
||||
createDoc('doc-2', 'prop-2'),
|
||||
createDoc('doc-3', 'prop-3'),
|
||||
];
|
||||
mockDocRepo.findPendingReview.mockResolvedValue({ items: docs, total: 3 });
|
||||
|
||||
const query = new GetPendingDocumentsQuery(1, 10);
|
||||
const result = await handler.execute(query);
|
||||
|
||||
expect(result.items).toHaveLength(3);
|
||||
expect(result.items[0]!.propertyId).toBe('prop-1');
|
||||
expect(result.items[1]!.propertyId).toBe('prop-2');
|
||||
expect(result.items[2]!.propertyId).toBe('prop-3');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
import { PropertyDocumentEntity } from '../../domain/entities/property-document.entity';
|
||||
import { type IPropertyDocumentRepository } from '../../domain/repositories/property-document.repository';
|
||||
import { GetPropertyDocumentsHandler } from '../queries/get-property-documents/get-property-documents.handler';
|
||||
import { GetPropertyDocumentsQuery } from '../queries/get-property-documents/get-property-documents.query';
|
||||
|
||||
describe('GetPropertyDocumentsHandler', () => {
|
||||
let handler: GetPropertyDocumentsHandler;
|
||||
let mockDocRepo: { [K in keyof IPropertyDocumentRepository]: ReturnType<typeof vi.fn> };
|
||||
|
||||
const createDoc = (id: string, docType: 'SO_DO' | 'SO_HONG' | 'GCNQSD' | 'OTHER' = 'SO_DO') =>
|
||||
PropertyDocumentEntity.createNew(
|
||||
id, 'prop-1', 'user-1', docType,
|
||||
'http://storage.local/documents/test.pdf',
|
||||
'doc.pdf', 'application/pdf', 1024, 'Mo ta',
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
mockDocRepo = {
|
||||
findById: vi.fn(),
|
||||
findByPropertyId: vi.fn(),
|
||||
save: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
findPendingReview: vi.fn(),
|
||||
countApprovedByPropertyId: vi.fn(),
|
||||
};
|
||||
|
||||
handler = new GetPropertyDocumentsHandler(mockDocRepo as any);
|
||||
});
|
||||
|
||||
it('returns documents for a property', async () => {
|
||||
const docs = [createDoc('doc-1'), createDoc('doc-2')];
|
||||
mockDocRepo.findByPropertyId.mockResolvedValue(docs);
|
||||
|
||||
const query = new GetPropertyDocumentsQuery('prop-1');
|
||||
const result = await handler.execute(query);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(mockDocRepo.findByPropertyId).toHaveBeenCalledWith('prop-1');
|
||||
});
|
||||
|
||||
it('maps entity fields to DTO correctly', async () => {
|
||||
const doc = createDoc('doc-1');
|
||||
mockDocRepo.findByPropertyId.mockResolvedValue([doc]);
|
||||
|
||||
const query = new GetPropertyDocumentsQuery('prop-1');
|
||||
const result = await handler.execute(query);
|
||||
|
||||
const item = result[0]!;
|
||||
expect(item.id).toBe('doc-1');
|
||||
expect(item.propertyId).toBe('prop-1');
|
||||
expect(item.uploadedById).toBe('user-1');
|
||||
expect(item.documentType).toBe('SO_DO');
|
||||
expect(item.status).toBe('PENDING_REVIEW');
|
||||
expect(item.url).toBe('http://storage.local/documents/test.pdf');
|
||||
expect(item.fileName).toBe('doc.pdf');
|
||||
expect(item.mimeType).toBe('application/pdf');
|
||||
expect(item.fileSizeBytes).toBe(1024);
|
||||
expect(item.description).toBe('Mo ta');
|
||||
expect(item.rejectionReason).toBeNull();
|
||||
expect(item.reviewedById).toBeNull();
|
||||
expect(item.reviewedAt).toBeNull();
|
||||
expect(item.createdAt).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it('returns empty array when no documents exist', async () => {
|
||||
mockDocRepo.findByPropertyId.mockResolvedValue([]);
|
||||
|
||||
const query = new GetPropertyDocumentsQuery('prop-empty');
|
||||
const result = await handler.execute(query);
|
||||
|
||||
expect(result).toHaveLength(0);
|
||||
expect(mockDocRepo.findByPropertyId).toHaveBeenCalledWith('prop-empty');
|
||||
});
|
||||
|
||||
it('maps documents with different types', async () => {
|
||||
const docs = [
|
||||
createDoc('doc-1', 'SO_DO'),
|
||||
createDoc('doc-2', 'SO_HONG'),
|
||||
createDoc('doc-3', 'GCNQSD'),
|
||||
createDoc('doc-4', 'OTHER'),
|
||||
];
|
||||
mockDocRepo.findByPropertyId.mockResolvedValue(docs);
|
||||
|
||||
const query = new GetPropertyDocumentsQuery('prop-1');
|
||||
const result = await handler.execute(query);
|
||||
|
||||
expect(result).toHaveLength(4);
|
||||
expect(result[0]!.documentType).toBe('SO_DO');
|
||||
expect(result[1]!.documentType).toBe('SO_HONG');
|
||||
expect(result[2]!.documentType).toBe('GCNQSD');
|
||||
expect(result[3]!.documentType).toBe('OTHER');
|
||||
});
|
||||
|
||||
it('maps reviewed document fields correctly', async () => {
|
||||
const doc = createDoc('doc-1');
|
||||
doc.approve('admin-1');
|
||||
mockDocRepo.findByPropertyId.mockResolvedValue([doc]);
|
||||
|
||||
const query = new GetPropertyDocumentsQuery('prop-1');
|
||||
const result = await handler.execute(query);
|
||||
|
||||
const item = result[0]!;
|
||||
expect(item.status).toBe('APPROVED');
|
||||
expect(item.reviewedById).toBe('admin-1');
|
||||
expect(item.reviewedAt).toBeInstanceOf(Date);
|
||||
expect(item.rejectionReason).toBeNull();
|
||||
});
|
||||
|
||||
it('maps rejected document fields correctly', async () => {
|
||||
const doc = createDoc('doc-1');
|
||||
doc.reject('admin-1', 'Anh khong ro');
|
||||
mockDocRepo.findByPropertyId.mockResolvedValue([doc]);
|
||||
|
||||
const query = new GetPropertyDocumentsQuery('prop-1');
|
||||
const result = await handler.execute(query);
|
||||
|
||||
const item = result[0]!;
|
||||
expect(item.status).toBe('REJECTED');
|
||||
expect(item.rejectionReason).toBe('Anh khong ro');
|
||||
expect(item.reviewedById).toBe('admin-1');
|
||||
expect(item.reviewedAt).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it('preserves null description in mapping', async () => {
|
||||
const doc = PropertyDocumentEntity.createNew(
|
||||
'doc-1', 'prop-1', 'user-1', 'SO_DO',
|
||||
'http://storage.local/documents/test.pdf',
|
||||
'doc.pdf', 'application/pdf', 1024,
|
||||
);
|
||||
mockDocRepo.findByPropertyId.mockResolvedValue([doc]);
|
||||
|
||||
const query = new GetPropertyDocumentsQuery('prop-1');
|
||||
const result = await handler.execute(query);
|
||||
|
||||
expect(result[0]!.description).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
import { InternalServerErrorException } from '@nestjs/common';
|
||||
import { NotFoundException, ValidationException } from '@modules/shared';
|
||||
import { PropertyDocumentEntity } from '../../domain/entities/property-document.entity';
|
||||
import { type IPropertyDocumentRepository } from '../../domain/repositories/property-document.repository';
|
||||
import { RejectDocumentCommand } from '../commands/reject-document/reject-document.command';
|
||||
import { RejectDocumentHandler } from '../commands/reject-document/reject-document.handler';
|
||||
|
||||
describe('RejectDocumentHandler', () => {
|
||||
let handler: RejectDocumentHandler;
|
||||
let mockDocRepo: { [K in keyof IPropertyDocumentRepository]: ReturnType<typeof vi.fn> };
|
||||
let mockLogger: { log: ReturnType<typeof vi.fn>; warn: ReturnType<typeof vi.fn>; error: ReturnType<typeof vi.fn>; debug: ReturnType<typeof vi.fn>; verbose: ReturnType<typeof vi.fn> };
|
||||
|
||||
const createPendingDoc = (id = 'doc-1') =>
|
||||
PropertyDocumentEntity.createNew(
|
||||
id, 'prop-1', 'user-1', 'SO_DO',
|
||||
'http://storage.local/documents/test.pdf',
|
||||
'sodo.pdf', 'application/pdf', 1024,
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
mockDocRepo = {
|
||||
findById: vi.fn(),
|
||||
findByPropertyId: vi.fn(),
|
||||
save: vi.fn(),
|
||||
update: vi.fn().mockResolvedValue(undefined),
|
||||
delete: vi.fn(),
|
||||
findPendingReview: vi.fn(),
|
||||
countApprovedByPropertyId: vi.fn(),
|
||||
};
|
||||
|
||||
mockLogger = { log: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), verbose: vi.fn() };
|
||||
|
||||
handler = new RejectDocumentHandler(
|
||||
mockDocRepo as any,
|
||||
mockLogger as any,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a pending document successfully', async () => {
|
||||
const doc = createPendingDoc();
|
||||
mockDocRepo.findById.mockResolvedValue(doc);
|
||||
|
||||
const command = new RejectDocumentCommand('doc-1', 'admin-1', 'Anh khong ro rang');
|
||||
const result = await handler.execute(command);
|
||||
|
||||
expect(result.documentId).toBe('doc-1');
|
||||
expect(result.status).toBe('REJECTED');
|
||||
expect(result.message).toContain('từ chối');
|
||||
expect(mockDocRepo.update).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('updates the document entity status to REJECTED with reason', async () => {
|
||||
const doc = createPendingDoc();
|
||||
mockDocRepo.findById.mockResolvedValue(doc);
|
||||
|
||||
const command = new RejectDocumentCommand('doc-1', 'admin-1', 'Giay to het han');
|
||||
await handler.execute(command);
|
||||
|
||||
const updatedDoc = mockDocRepo.update.mock.calls[0]![0];
|
||||
expect(updatedDoc.status).toBe('REJECTED');
|
||||
expect(updatedDoc.rejectionReason).toBe('Giay to het han');
|
||||
expect(updatedDoc.reviewedById).toBe('admin-1');
|
||||
expect(updatedDoc.reviewedAt).not.toBeNull();
|
||||
});
|
||||
|
||||
it('throws NotFoundException when document does not exist', async () => {
|
||||
mockDocRepo.findById.mockResolvedValue(null);
|
||||
|
||||
const command = new RejectDocumentCommand('nonexistent', 'admin-1', 'reason');
|
||||
|
||||
await expect(handler.execute(command)).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('throws ValidationException when document is not PENDING_REVIEW', async () => {
|
||||
const doc = createPendingDoc();
|
||||
doc.approve('admin-old'); // status becomes APPROVED
|
||||
mockDocRepo.findById.mockResolvedValue(doc);
|
||||
|
||||
const command = new RejectDocumentCommand('doc-1', 'admin-2', 'reason');
|
||||
|
||||
await expect(handler.execute(command)).rejects.toThrow(ValidationException);
|
||||
await expect(handler.execute(command)).rejects.toThrow(/APPROVED/);
|
||||
});
|
||||
|
||||
it('throws ValidationException for already REJECTED document', async () => {
|
||||
const doc = createPendingDoc();
|
||||
doc.reject('admin-old', 'previous reason');
|
||||
mockDocRepo.findById.mockResolvedValue(doc);
|
||||
|
||||
const command = new RejectDocumentCommand('doc-1', 'admin-2', 'new reason');
|
||||
|
||||
await expect(handler.execute(command)).rejects.toThrow(ValidationException);
|
||||
await expect(handler.execute(command)).rejects.toThrow(/REJECTED/);
|
||||
});
|
||||
|
||||
it('re-throws DomainException without wrapping', async () => {
|
||||
mockDocRepo.findById.mockResolvedValue(null);
|
||||
|
||||
const command = new RejectDocumentCommand('doc-1', 'admin-1', 'reason');
|
||||
|
||||
await expect(handler.execute(command)).rejects.toThrow(NotFoundException);
|
||||
await expect(handler.execute(command)).rejects.not.toThrow(InternalServerErrorException);
|
||||
});
|
||||
|
||||
it('wraps unexpected errors in InternalServerErrorException', async () => {
|
||||
mockDocRepo.findById.mockRejectedValue(new Error('DB timeout'));
|
||||
|
||||
const command = new RejectDocumentCommand('doc-1', 'admin-1', 'reason');
|
||||
|
||||
await expect(handler.execute(command)).rejects.toThrow(InternalServerErrorException);
|
||||
expect(mockLogger.error).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('stores the reason in the command', () => {
|
||||
const command = new RejectDocumentCommand('doc-1', 'admin-1', 'Giay to khong hop le');
|
||||
expect(command.reason).toBe('Giay to khong hop le');
|
||||
expect(command.documentId).toBe('doc-1');
|
||||
expect(command.adminId).toBe('admin-1');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
import { InternalServerErrorException } from '@nestjs/common';
|
||||
// Mock the @modules/listings barrel to prevent ListingsModule → AnalyticsModule → cockatiel
|
||||
// from being loaded during unit tests. The constants are all we need at runtime.
|
||||
vi.mock('@modules/listings', () => ({
|
||||
PROPERTY_REPOSITORY: 'PROPERTY_REPOSITORY',
|
||||
MEDIA_STORAGE_SERVICE: 'MEDIA_STORAGE_SERVICE',
|
||||
ListingsModule: class {},
|
||||
}));
|
||||
import { type IPropertyRepository } from '@modules/listings/domain/repositories/property.repository';
|
||||
import { type IMediaStorageService } from '@modules/listings/infrastructure/services/media-storage.service';
|
||||
import { NotFoundException, ValidationException } from '@modules/shared';
|
||||
import { type IPropertyDocumentRepository } from '../../domain/repositories/property-document.repository';
|
||||
import { UploadDocumentCommand } from '../commands/upload-document/upload-document.command';
|
||||
import { UploadDocumentHandler } from '../commands/upload-document/upload-document.handler';
|
||||
|
||||
describe('UploadDocumentHandler', () => {
|
||||
let handler: UploadDocumentHandler;
|
||||
let mockDocRepo: { [K in keyof IPropertyDocumentRepository]: ReturnType<typeof vi.fn> };
|
||||
let mockPropertyRepo: { [K in keyof IPropertyRepository]: ReturnType<typeof vi.fn> };
|
||||
let mockMediaStorage: { [K in keyof IMediaStorageService]: ReturnType<typeof vi.fn> };
|
||||
let mockLogger: { log: ReturnType<typeof vi.fn>; warn: ReturnType<typeof vi.fn>; error: ReturnType<typeof vi.fn>; debug: ReturnType<typeof vi.fn>; verbose: ReturnType<typeof vi.fn> };
|
||||
|
||||
beforeEach(() => {
|
||||
mockDocRepo = {
|
||||
findById: vi.fn(),
|
||||
findByPropertyId: vi.fn().mockResolvedValue([]),
|
||||
save: vi.fn().mockResolvedValue(undefined),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
findPendingReview: vi.fn(),
|
||||
countApprovedByPropertyId: vi.fn(),
|
||||
};
|
||||
|
||||
mockPropertyRepo = {
|
||||
findById: vi.fn().mockResolvedValue({ id: 'prop-1' }),
|
||||
save: vi.fn(),
|
||||
update: vi.fn(),
|
||||
addMedia: vi.fn(),
|
||||
findMediaByPropertyId: vi.fn(),
|
||||
deleteMedia: vi.fn(),
|
||||
countMediaByPropertyId: vi.fn(),
|
||||
updateMediaOrder: vi.fn(),
|
||||
};
|
||||
|
||||
mockMediaStorage = {
|
||||
upload: vi.fn().mockResolvedValue('http://storage.local/documents/prop-1/test.pdf'),
|
||||
delete: vi.fn(),
|
||||
getPresignedUploadUrl: vi.fn(),
|
||||
generatePresignedUpload: vi.fn(),
|
||||
getPublicUrl: vi.fn(),
|
||||
isTrustedUrl: vi.fn(),
|
||||
};
|
||||
|
||||
mockLogger = { log: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), verbose: vi.fn() };
|
||||
|
||||
handler = new UploadDocumentHandler(
|
||||
mockDocRepo as any,
|
||||
mockPropertyRepo as any,
|
||||
mockMediaStorage as any,
|
||||
mockLogger as any,
|
||||
);
|
||||
});
|
||||
|
||||
const makeCommand = (overrides?: Partial<ConstructorParameters<typeof UploadDocumentCommand>[0] & Record<string, unknown>>) =>
|
||||
new UploadDocumentCommand(
|
||||
overrides?.propertyId as string ?? 'prop-1',
|
||||
overrides?.userId as string ?? 'user-1',
|
||||
(overrides?.documentType as any) ?? 'SO_DO',
|
||||
overrides?.file as any ?? {
|
||||
buffer: Buffer.from('fake-pdf-content'),
|
||||
mimetype: 'application/pdf',
|
||||
originalname: 'sodo.pdf',
|
||||
size: 2048,
|
||||
},
|
||||
overrides?.description as string | undefined,
|
||||
);
|
||||
|
||||
it('uploads document successfully', async () => {
|
||||
const command = makeCommand();
|
||||
|
||||
const result = await handler.execute(command);
|
||||
|
||||
expect(result.documentId).toBeDefined();
|
||||
expect(result.url).toBe('http://storage.local/documents/prop-1/test.pdf');
|
||||
expect(mockPropertyRepo.findById).toHaveBeenCalledWith('prop-1');
|
||||
expect(mockDocRepo.findByPropertyId).toHaveBeenCalledWith('prop-1');
|
||||
expect(mockMediaStorage.upload).toHaveBeenCalledWith(
|
||||
expect.any(Buffer), 'sodo.pdf', 'application/pdf', 'documents/prop-1',
|
||||
);
|
||||
expect(mockDocRepo.save).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('uploads document with description', async () => {
|
||||
const command = makeCommand({ description: 'So do chinh chu' });
|
||||
|
||||
const result = await handler.execute(command);
|
||||
|
||||
expect(result.documentId).toBeDefined();
|
||||
expect(result.url).toBe('http://storage.local/documents/prop-1/test.pdf');
|
||||
expect(mockDocRepo.save).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('throws NotFoundException when property does not exist', async () => {
|
||||
mockPropertyRepo.findById.mockResolvedValue(null);
|
||||
|
||||
const command = makeCommand({ propertyId: 'nonexistent' });
|
||||
|
||||
await expect(handler.execute(command)).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('throws ValidationException when max documents limit reached', async () => {
|
||||
const existingDocs = Array.from({ length: 10 }, (_, i) => ({ id: `doc-${i}` }));
|
||||
mockDocRepo.findByPropertyId.mockResolvedValue(existingDocs);
|
||||
|
||||
const command = makeCommand();
|
||||
|
||||
await expect(handler.execute(command)).rejects.toThrow(ValidationException);
|
||||
await expect(handler.execute(command)).rejects.toThrow(/10/);
|
||||
});
|
||||
|
||||
it('throws ValidationException when media upload fails', async () => {
|
||||
mockMediaStorage.upload.mockRejectedValue(new Error('Storage unavailable'));
|
||||
|
||||
const command = makeCommand();
|
||||
|
||||
await expect(handler.execute(command)).rejects.toThrow(ValidationException);
|
||||
expect(mockLogger.error).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('saves entity with correct fields after successful upload', async () => {
|
||||
const command = makeCommand({ description: 'Mo ta' });
|
||||
|
||||
await handler.execute(command);
|
||||
|
||||
const savedEntity = mockDocRepo.save.mock.calls[0]![0];
|
||||
expect(savedEntity.propertyId).toBe('prop-1');
|
||||
expect(savedEntity.uploadedById).toBe('user-1');
|
||||
expect(savedEntity.documentType).toBe('SO_DO');
|
||||
expect(savedEntity.status).toBe('PENDING_REVIEW');
|
||||
expect(savedEntity.url).toBe('http://storage.local/documents/prop-1/test.pdf');
|
||||
expect(savedEntity.fileName).toBe('sodo.pdf');
|
||||
expect(savedEntity.mimeType).toBe('application/pdf');
|
||||
expect(savedEntity.fileSizeBytes).toBe(2048);
|
||||
expect(savedEntity.description).toBe('Mo ta');
|
||||
expect(savedEntity.rejectionReason).toBeNull();
|
||||
expect(savedEntity.reviewedById).toBeNull();
|
||||
expect(savedEntity.reviewedAt).toBeNull();
|
||||
});
|
||||
|
||||
it('re-throws DomainException without wrapping', async () => {
|
||||
mockPropertyRepo.findById.mockResolvedValue(null);
|
||||
|
||||
const command = makeCommand();
|
||||
|
||||
await expect(handler.execute(command)).rejects.toThrow(NotFoundException);
|
||||
// Should NOT throw InternalServerErrorException
|
||||
await expect(handler.execute(command)).rejects.not.toThrow(InternalServerErrorException);
|
||||
});
|
||||
|
||||
it('wraps unexpected errors in InternalServerErrorException', async () => {
|
||||
mockPropertyRepo.findById.mockRejectedValue(new Error('DB connection lost'));
|
||||
|
||||
const command = makeCommand();
|
||||
|
||||
await expect(handler.execute(command)).rejects.toThrow(InternalServerErrorException);
|
||||
expect(mockLogger.error).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows upload when under document limit', async () => {
|
||||
const existingDocs = Array.from({ length: 9 }, (_, i) => ({ id: `doc-${i}` }));
|
||||
mockDocRepo.findByPropertyId.mockResolvedValue(existingDocs);
|
||||
|
||||
const command = makeCommand();
|
||||
|
||||
const result = await handler.execute(command);
|
||||
|
||||
expect(result.documentId).toBeDefined();
|
||||
expect(mockDocRepo.save).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
export class ApproveDocumentCommand {
|
||||
constructor(
|
||||
public readonly documentId: string,
|
||||
public readonly adminId: string,
|
||||
public readonly notes?: string,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
||||
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- PrismaService & LoggerService are constructor-injected (NestJS DI)
|
||||
import { DomainException, LoggerService, NotFoundException, PrismaService, ValidationException } from '@modules/shared';
|
||||
import { PROPERTY_DOCUMENT_REPOSITORY, type IPropertyDocumentRepository } from '../../../domain/repositories/property-document.repository';
|
||||
import { ApproveDocumentCommand } from './approve-document.command';
|
||||
|
||||
export interface ApproveDocumentResult {
|
||||
documentId: string;
|
||||
status: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
@CommandHandler(ApproveDocumentCommand)
|
||||
export class ApproveDocumentHandler implements ICommandHandler<ApproveDocumentCommand> {
|
||||
constructor(
|
||||
@Inject(PROPERTY_DOCUMENT_REPOSITORY) private readonly docRepo: IPropertyDocumentRepository,
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly logger: LoggerService,
|
||||
) {}
|
||||
|
||||
async execute(command: ApproveDocumentCommand): Promise<ApproveDocumentResult> {
|
||||
try {
|
||||
const doc = await this.docRepo.findById(command.documentId);
|
||||
if (!doc) {
|
||||
throw new NotFoundException('Giấy tờ pháp lý', command.documentId);
|
||||
}
|
||||
|
||||
if (doc.status !== 'PENDING_REVIEW') {
|
||||
throw new ValidationException(
|
||||
`Giấy tờ đang ở trạng thái ${doc.status}, chỉ có thể duyệt giấy tờ đang chờ duyệt`,
|
||||
{ currentStatus: doc.status },
|
||||
);
|
||||
}
|
||||
|
||||
doc.approve(command.adminId);
|
||||
await this.docRepo.update(doc);
|
||||
|
||||
// Set certificateVerified on the property
|
||||
await this.prisma.property.update({
|
||||
where: { id: doc.propertyId },
|
||||
data: { certificateVerified: true },
|
||||
});
|
||||
|
||||
return {
|
||||
documentId: doc.id,
|
||||
status: 'APPROVED',
|
||||
message: 'Giấy tờ pháp lý đã được xác minh thành công',
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof DomainException) throw error;
|
||||
this.logger.error(
|
||||
`Failed to approve document ${command.documentId}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
error instanceof Error ? error.stack : undefined,
|
||||
'ApproveDocumentHandler',
|
||||
);
|
||||
throw new InternalServerErrorException('Lỗi khi duyệt giấy tờ pháp lý');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export class RejectDocumentCommand {
|
||||
constructor(
|
||||
public readonly documentId: string,
|
||||
public readonly adminId: string,
|
||||
public readonly reason: string,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
||||
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- LoggerService is constructor-injected (NestJS DI)
|
||||
import { DomainException, LoggerService, NotFoundException, ValidationException } from '@modules/shared';
|
||||
import { PROPERTY_DOCUMENT_REPOSITORY, type IPropertyDocumentRepository } from '../../../domain/repositories/property-document.repository';
|
||||
import { RejectDocumentCommand } from './reject-document.command';
|
||||
|
||||
export interface RejectDocumentResult {
|
||||
documentId: string;
|
||||
status: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
@CommandHandler(RejectDocumentCommand)
|
||||
export class RejectDocumentHandler implements ICommandHandler<RejectDocumentCommand> {
|
||||
constructor(
|
||||
@Inject(PROPERTY_DOCUMENT_REPOSITORY) private readonly docRepo: IPropertyDocumentRepository,
|
||||
private readonly logger: LoggerService,
|
||||
) {}
|
||||
|
||||
async execute(command: RejectDocumentCommand): Promise<RejectDocumentResult> {
|
||||
try {
|
||||
const doc = await this.docRepo.findById(command.documentId);
|
||||
if (!doc) {
|
||||
throw new NotFoundException('Giấy tờ pháp lý', command.documentId);
|
||||
}
|
||||
|
||||
if (doc.status !== 'PENDING_REVIEW') {
|
||||
throw new ValidationException(
|
||||
`Giấy tờ đang ở trạng thái ${doc.status}, chỉ có thể từ chối giấy tờ đang chờ duyệt`,
|
||||
{ currentStatus: doc.status },
|
||||
);
|
||||
}
|
||||
|
||||
doc.reject(command.adminId, command.reason);
|
||||
await this.docRepo.update(doc);
|
||||
|
||||
return {
|
||||
documentId: doc.id,
|
||||
status: 'REJECTED',
|
||||
message: 'Giấy tờ pháp lý đã bị từ chối',
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof DomainException) throw error;
|
||||
this.logger.error(
|
||||
`Failed to reject document ${command.documentId}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
error instanceof Error ? error.stack : undefined,
|
||||
'RejectDocumentHandler',
|
||||
);
|
||||
throw new InternalServerErrorException('Lỗi khi từ chối giấy tờ pháp lý');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { type DocumentType } from '../../../domain/entities/property-document.entity';
|
||||
|
||||
export class UploadDocumentCommand {
|
||||
constructor(
|
||||
public readonly propertyId: string,
|
||||
public readonly userId: string,
|
||||
public readonly documentType: DocumentType,
|
||||
public readonly file: {
|
||||
buffer: Buffer;
|
||||
mimetype: string;
|
||||
originalname: string;
|
||||
size: number;
|
||||
},
|
||||
public readonly description?: string,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
||||
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
|
||||
import { createId } from '@paralleldrive/cuid2';
|
||||
import { PROPERTY_REPOSITORY, type IPropertyRepository, MEDIA_STORAGE_SERVICE, type IMediaStorageService } from '@modules/listings';
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- LoggerService is constructor-injected (NestJS DI requires runtime reference)
|
||||
import { DomainException, LoggerService, NotFoundException, ValidationException } from '@modules/shared';
|
||||
import { PropertyDocumentEntity } from '../../../domain/entities/property-document.entity';
|
||||
import { PROPERTY_DOCUMENT_REPOSITORY, type IPropertyDocumentRepository } from '../../../domain/repositories/property-document.repository';
|
||||
import { UploadDocumentCommand } from './upload-document.command';
|
||||
|
||||
const MAX_DOCUMENTS_PER_PROPERTY = 10;
|
||||
|
||||
export interface UploadDocumentResult {
|
||||
documentId: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
@CommandHandler(UploadDocumentCommand)
|
||||
export class UploadDocumentHandler implements ICommandHandler<UploadDocumentCommand> {
|
||||
constructor(
|
||||
@Inject(PROPERTY_DOCUMENT_REPOSITORY) private readonly docRepo: IPropertyDocumentRepository,
|
||||
@Inject(PROPERTY_REPOSITORY) private readonly propertyRepo: IPropertyRepository,
|
||||
@Inject(MEDIA_STORAGE_SERVICE) private readonly mediaStorage: IMediaStorageService,
|
||||
private readonly logger: LoggerService,
|
||||
) {}
|
||||
|
||||
async execute(command: UploadDocumentCommand): Promise<UploadDocumentResult> {
|
||||
try {
|
||||
const property = await this.propertyRepo.findById(command.propertyId);
|
||||
if (!property) {
|
||||
throw new NotFoundException('Bất động sản', command.propertyId);
|
||||
}
|
||||
|
||||
const existing = await this.docRepo.findByPropertyId(command.propertyId);
|
||||
if (existing.length >= MAX_DOCUMENTS_PER_PROPERTY) {
|
||||
throw new ValidationException(`Tối đa ${MAX_DOCUMENTS_PER_PROPERTY} giấy tờ pháp lý cho mỗi bất động sản`);
|
||||
}
|
||||
|
||||
let url: string;
|
||||
try {
|
||||
url = await this.mediaStorage.upload(
|
||||
command.file.buffer,
|
||||
command.file.originalname,
|
||||
command.file.mimetype,
|
||||
`documents/${command.propertyId}`,
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Document upload failed for property ${command.propertyId}: ${error instanceof Error ? error.message : error}`,
|
||||
error instanceof Error ? error.stack : undefined,
|
||||
'UploadDocumentHandler',
|
||||
);
|
||||
throw new ValidationException('Tải lên giấy tờ thất bại, vui lòng thử lại');
|
||||
}
|
||||
|
||||
const documentId = createId();
|
||||
const doc = PropertyDocumentEntity.createNew(
|
||||
documentId,
|
||||
command.propertyId,
|
||||
command.userId,
|
||||
command.documentType,
|
||||
url,
|
||||
command.file.originalname,
|
||||
command.file.mimetype,
|
||||
command.file.size,
|
||||
command.description,
|
||||
);
|
||||
|
||||
await this.docRepo.save(doc);
|
||||
|
||||
return { documentId, url };
|
||||
} catch (error) {
|
||||
if (error instanceof DomainException) throw error;
|
||||
this.logger.error(
|
||||
`Failed to upload document for property ${command.propertyId}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
error instanceof Error ? error.stack : undefined,
|
||||
this.constructor.name,
|
||||
);
|
||||
throw new InternalServerErrorException('Không thể tải lên giấy tờ pháp lý');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Inject } from '@nestjs/common';
|
||||
import { type IQueryHandler, QueryHandler } from '@nestjs/cqrs';
|
||||
import { PROPERTY_DOCUMENT_REPOSITORY, type IPropertyDocumentRepository } from '../../../domain/repositories/property-document.repository';
|
||||
import { type PropertyDocumentDto } from '../get-property-documents/get-property-documents.handler';
|
||||
import { GetPendingDocumentsQuery } from './get-pending-documents.query';
|
||||
|
||||
export interface PendingDocumentsResult {
|
||||
items: PropertyDocumentDto[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
@QueryHandler(GetPendingDocumentsQuery)
|
||||
export class GetPendingDocumentsHandler implements IQueryHandler<GetPendingDocumentsQuery> {
|
||||
constructor(
|
||||
@Inject(PROPERTY_DOCUMENT_REPOSITORY) private readonly docRepo: IPropertyDocumentRepository,
|
||||
) {}
|
||||
|
||||
async execute(query: GetPendingDocumentsQuery): Promise<PendingDocumentsResult> {
|
||||
const { items, total } = await this.docRepo.findPendingReview(query.page, query.limit);
|
||||
return {
|
||||
items: items.map((d) => ({
|
||||
id: d.id,
|
||||
propertyId: d.propertyId,
|
||||
uploadedById: d.uploadedById,
|
||||
documentType: d.documentType,
|
||||
status: d.status,
|
||||
url: d.url,
|
||||
fileName: d.fileName,
|
||||
mimeType: d.mimeType,
|
||||
fileSizeBytes: d.fileSizeBytes,
|
||||
description: d.description,
|
||||
rejectionReason: d.rejectionReason,
|
||||
reviewedById: d.reviewedById,
|
||||
reviewedAt: d.reviewedAt,
|
||||
createdAt: d.createdAt,
|
||||
})),
|
||||
total,
|
||||
page: query.page,
|
||||
limit: query.limit,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export class GetPendingDocumentsQuery {
|
||||
constructor(
|
||||
public readonly page: number,
|
||||
public readonly limit: number,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Inject } from '@nestjs/common';
|
||||
import { type IQueryHandler, QueryHandler } from '@nestjs/cqrs';
|
||||
import { PROPERTY_DOCUMENT_REPOSITORY, type IPropertyDocumentRepository } from '../../../domain/repositories/property-document.repository';
|
||||
import { GetPropertyDocumentsQuery } from './get-property-documents.query';
|
||||
|
||||
export interface PropertyDocumentDto {
|
||||
id: string;
|
||||
propertyId: string;
|
||||
uploadedById: string;
|
||||
documentType: string;
|
||||
status: string;
|
||||
url: string;
|
||||
fileName: string;
|
||||
mimeType: string;
|
||||
fileSizeBytes: number;
|
||||
description: string | null;
|
||||
rejectionReason: string | null;
|
||||
reviewedById: string | null;
|
||||
reviewedAt: Date | null;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
@QueryHandler(GetPropertyDocumentsQuery)
|
||||
export class GetPropertyDocumentsHandler implements IQueryHandler<GetPropertyDocumentsQuery> {
|
||||
constructor(
|
||||
@Inject(PROPERTY_DOCUMENT_REPOSITORY) private readonly docRepo: IPropertyDocumentRepository,
|
||||
) {}
|
||||
|
||||
async execute(query: GetPropertyDocumentsQuery): Promise<PropertyDocumentDto[]> {
|
||||
const docs = await this.docRepo.findByPropertyId(query.propertyId);
|
||||
return docs.map((d) => ({
|
||||
id: d.id,
|
||||
propertyId: d.propertyId,
|
||||
uploadedById: d.uploadedById,
|
||||
documentType: d.documentType,
|
||||
status: d.status,
|
||||
url: d.url,
|
||||
fileName: d.fileName,
|
||||
mimeType: d.mimeType,
|
||||
fileSizeBytes: d.fileSizeBytes,
|
||||
description: d.description,
|
||||
rejectionReason: d.rejectionReason,
|
||||
reviewedById: d.reviewedById,
|
||||
reviewedAt: d.reviewedAt,
|
||||
createdAt: d.createdAt,
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export class GetPropertyDocumentsQuery {
|
||||
constructor(
|
||||
public readonly propertyId: string,
|
||||
) {}
|
||||
}
|
||||
47
apps/api/src/modules/documents/documents.module.ts
Normal file
47
apps/api/src/modules/documents/documents.module.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { CqrsModule } from '@nestjs/cqrs';
|
||||
import { MulterModule } from '@nestjs/platform-express';
|
||||
import { ListingsModule, MEDIA_STORAGE_SERVICE, MinioMediaStorageService } from '@modules/listings';
|
||||
import { ApproveDocumentHandler } from './application/commands/approve-document/approve-document.handler';
|
||||
import { RejectDocumentHandler } from './application/commands/reject-document/reject-document.handler';
|
||||
import { UploadDocumentHandler } from './application/commands/upload-document/upload-document.handler';
|
||||
import { GetPendingDocumentsHandler } from './application/queries/get-pending-documents/get-pending-documents.handler';
|
||||
import { GetPropertyDocumentsHandler } from './application/queries/get-property-documents/get-property-documents.handler';
|
||||
import { PROPERTY_DOCUMENT_REPOSITORY } from './domain/repositories/property-document.repository';
|
||||
import { PrismaPropertyDocumentRepository } from './infrastructure/repositories/prisma-property-document.repository';
|
||||
import { PropertyDocumentsController } from './presentation/controllers/property-documents.controller';
|
||||
|
||||
const CommandHandlers = [
|
||||
UploadDocumentHandler,
|
||||
ApproveDocumentHandler,
|
||||
RejectDocumentHandler,
|
||||
];
|
||||
|
||||
const QueryHandlers = [
|
||||
GetPropertyDocumentsHandler,
|
||||
GetPendingDocumentsHandler,
|
||||
];
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
CqrsModule,
|
||||
ListingsModule,
|
||||
MulterModule.register({
|
||||
limits: { fileSize: 20 * 1024 * 1024 }, // 20 MB
|
||||
}),
|
||||
],
|
||||
controllers: [PropertyDocumentsController],
|
||||
providers: [
|
||||
// Repositories
|
||||
{ provide: PROPERTY_DOCUMENT_REPOSITORY, useClass: PrismaPropertyDocumentRepository },
|
||||
|
||||
// Storage (reuse MinIO implementation)
|
||||
{ provide: MEDIA_STORAGE_SERVICE, useClass: MinioMediaStorageService },
|
||||
|
||||
// CQRS
|
||||
...CommandHandlers,
|
||||
...QueryHandlers,
|
||||
],
|
||||
exports: [PROPERTY_DOCUMENT_REPOSITORY],
|
||||
})
|
||||
export class DocumentsModule {}
|
||||
@@ -0,0 +1,279 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { PropertyDocumentEntity } from '../entities/property-document.entity';
|
||||
|
||||
describe('PropertyDocumentEntity', () => {
|
||||
const defaultProps = {
|
||||
propertyId: 'prop-1',
|
||||
uploadedById: 'user-1',
|
||||
documentType: 'SO_DO' as const,
|
||||
status: 'PENDING_REVIEW' as const,
|
||||
url: 'http://storage.local/documents/test.pdf',
|
||||
fileName: 'sodo.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
fileSizeBytes: 1024,
|
||||
description: 'So do chinh chu',
|
||||
rejectionReason: null,
|
||||
reviewedById: null,
|
||||
reviewedAt: null,
|
||||
};
|
||||
|
||||
const now = new Date('2026-04-01T10:00:00Z');
|
||||
const later = new Date('2026-04-01T10:05:00Z');
|
||||
|
||||
describe('constructor', () => {
|
||||
it('should create entity with all properties', () => {
|
||||
const entity = new PropertyDocumentEntity('doc-1', defaultProps, now, later);
|
||||
|
||||
expect(entity.id).toBe('doc-1');
|
||||
expect(entity.propertyId).toBe('prop-1');
|
||||
expect(entity.uploadedById).toBe('user-1');
|
||||
expect(entity.documentType).toBe('SO_DO');
|
||||
expect(entity.status).toBe('PENDING_REVIEW');
|
||||
expect(entity.url).toBe('http://storage.local/documents/test.pdf');
|
||||
expect(entity.fileName).toBe('sodo.pdf');
|
||||
expect(entity.mimeType).toBe('application/pdf');
|
||||
expect(entity.fileSizeBytes).toBe(1024);
|
||||
expect(entity.description).toBe('So do chinh chu');
|
||||
expect(entity.rejectionReason).toBeNull();
|
||||
expect(entity.reviewedById).toBeNull();
|
||||
expect(entity.reviewedAt).toBeNull();
|
||||
expect(entity.createdAt).toEqual(now);
|
||||
expect(entity.updatedAt).toEqual(later);
|
||||
});
|
||||
|
||||
it('should default createdAt and updatedAt when not provided', () => {
|
||||
const before = new Date();
|
||||
const entity = new PropertyDocumentEntity('doc-1', defaultProps);
|
||||
const after = new Date();
|
||||
|
||||
expect(entity.createdAt.getTime()).toBeGreaterThanOrEqual(before.getTime());
|
||||
expect(entity.createdAt.getTime()).toBeLessThanOrEqual(after.getTime());
|
||||
expect(entity.updatedAt.getTime()).toBeGreaterThanOrEqual(before.getTime());
|
||||
expect(entity.updatedAt.getTime()).toBeLessThanOrEqual(after.getTime());
|
||||
});
|
||||
|
||||
it('should handle null description', () => {
|
||||
const entity = new PropertyDocumentEntity('doc-1', {
|
||||
...defaultProps,
|
||||
description: null,
|
||||
});
|
||||
|
||||
expect(entity.description).toBeNull();
|
||||
});
|
||||
|
||||
it('should store all document types correctly', () => {
|
||||
const types = ['SO_DO', 'SO_HONG', 'GCNQSD', 'OTHER'] as const;
|
||||
for (const docType of types) {
|
||||
const entity = new PropertyDocumentEntity('doc-1', {
|
||||
...defaultProps,
|
||||
documentType: docType,
|
||||
});
|
||||
expect(entity.documentType).toBe(docType);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('createNew', () => {
|
||||
it('should create a new document with PENDING_REVIEW status', () => {
|
||||
const entity = PropertyDocumentEntity.createNew(
|
||||
'doc-1',
|
||||
'prop-1',
|
||||
'user-1',
|
||||
'SO_DO',
|
||||
'http://storage.local/documents/test.pdf',
|
||||
'sodo.pdf',
|
||||
'application/pdf',
|
||||
2048,
|
||||
'Mo ta',
|
||||
);
|
||||
|
||||
expect(entity.id).toBe('doc-1');
|
||||
expect(entity.propertyId).toBe('prop-1');
|
||||
expect(entity.uploadedById).toBe('user-1');
|
||||
expect(entity.documentType).toBe('SO_DO');
|
||||
expect(entity.status).toBe('PENDING_REVIEW');
|
||||
expect(entity.url).toBe('http://storage.local/documents/test.pdf');
|
||||
expect(entity.fileName).toBe('sodo.pdf');
|
||||
expect(entity.mimeType).toBe('application/pdf');
|
||||
expect(entity.fileSizeBytes).toBe(2048);
|
||||
expect(entity.description).toBe('Mo ta');
|
||||
expect(entity.rejectionReason).toBeNull();
|
||||
expect(entity.reviewedById).toBeNull();
|
||||
expect(entity.reviewedAt).toBeNull();
|
||||
});
|
||||
|
||||
it('should set description to null when not provided', () => {
|
||||
const entity = PropertyDocumentEntity.createNew(
|
||||
'doc-1',
|
||||
'prop-1',
|
||||
'user-1',
|
||||
'SO_HONG',
|
||||
'http://storage.local/documents/test.pdf',
|
||||
'sohong.pdf',
|
||||
'application/pdf',
|
||||
1024,
|
||||
);
|
||||
|
||||
expect(entity.description).toBeNull();
|
||||
});
|
||||
|
||||
it('should set description to null when undefined is passed', () => {
|
||||
const entity = PropertyDocumentEntity.createNew(
|
||||
'doc-1',
|
||||
'prop-1',
|
||||
'user-1',
|
||||
'GCNQSD',
|
||||
'http://storage.local/documents/test.pdf',
|
||||
'gcnqsd.pdf',
|
||||
'image/jpeg',
|
||||
512,
|
||||
undefined,
|
||||
);
|
||||
|
||||
expect(entity.description).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('approve', () => {
|
||||
it('should set status to APPROVED', () => {
|
||||
const entity = PropertyDocumentEntity.createNew(
|
||||
'doc-1', 'prop-1', 'user-1', 'SO_DO',
|
||||
'http://storage.local/test.pdf', 'test.pdf', 'application/pdf', 1024,
|
||||
);
|
||||
|
||||
entity.approve('admin-1');
|
||||
|
||||
expect(entity.status).toBe('APPROVED');
|
||||
});
|
||||
|
||||
it('should set reviewedById to the reviewer', () => {
|
||||
const entity = PropertyDocumentEntity.createNew(
|
||||
'doc-1', 'prop-1', 'user-1', 'SO_DO',
|
||||
'http://storage.local/test.pdf', 'test.pdf', 'application/pdf', 1024,
|
||||
);
|
||||
|
||||
entity.approve('admin-42');
|
||||
|
||||
expect(entity.reviewedById).toBe('admin-42');
|
||||
});
|
||||
|
||||
it('should set reviewedAt to current time', () => {
|
||||
const entity = PropertyDocumentEntity.createNew(
|
||||
'doc-1', 'prop-1', 'user-1', 'SO_DO',
|
||||
'http://storage.local/test.pdf', 'test.pdf', 'application/pdf', 1024,
|
||||
);
|
||||
|
||||
const before = new Date();
|
||||
entity.approve('admin-1');
|
||||
const after = new Date();
|
||||
|
||||
expect(entity.reviewedAt).not.toBeNull();
|
||||
expect(entity.reviewedAt!.getTime()).toBeGreaterThanOrEqual(before.getTime());
|
||||
expect(entity.reviewedAt!.getTime()).toBeLessThanOrEqual(after.getTime());
|
||||
});
|
||||
|
||||
it('should clear rejectionReason on approval', () => {
|
||||
const entity = new PropertyDocumentEntity('doc-1', {
|
||||
...defaultProps,
|
||||
status: 'REJECTED',
|
||||
rejectionReason: 'Anh khong ro',
|
||||
reviewedById: 'admin-old',
|
||||
reviewedAt: new Date('2026-01-01'),
|
||||
});
|
||||
|
||||
entity.approve('admin-2');
|
||||
|
||||
expect(entity.status).toBe('APPROVED');
|
||||
expect(entity.rejectionReason).toBeNull();
|
||||
expect(entity.reviewedById).toBe('admin-2');
|
||||
});
|
||||
|
||||
it('should update updatedAt timestamp', () => {
|
||||
const entity = new PropertyDocumentEntity('doc-1', defaultProps, now, now);
|
||||
|
||||
entity.approve('admin-1');
|
||||
|
||||
expect(entity.updatedAt.getTime()).toBeGreaterThanOrEqual(now.getTime());
|
||||
});
|
||||
});
|
||||
|
||||
describe('reject', () => {
|
||||
it('should set status to REJECTED', () => {
|
||||
const entity = PropertyDocumentEntity.createNew(
|
||||
'doc-1', 'prop-1', 'user-1', 'SO_DO',
|
||||
'http://storage.local/test.pdf', 'test.pdf', 'application/pdf', 1024,
|
||||
);
|
||||
|
||||
entity.reject('admin-1', 'Anh khong ro rang');
|
||||
|
||||
expect(entity.status).toBe('REJECTED');
|
||||
});
|
||||
|
||||
it('should set rejectionReason', () => {
|
||||
const entity = PropertyDocumentEntity.createNew(
|
||||
'doc-1', 'prop-1', 'user-1', 'SO_DO',
|
||||
'http://storage.local/test.pdf', 'test.pdf', 'application/pdf', 1024,
|
||||
);
|
||||
|
||||
entity.reject('admin-1', 'Giay to het han');
|
||||
|
||||
expect(entity.rejectionReason).toBe('Giay to het han');
|
||||
});
|
||||
|
||||
it('should set reviewedById to the reviewer', () => {
|
||||
const entity = PropertyDocumentEntity.createNew(
|
||||
'doc-1', 'prop-1', 'user-1', 'SO_DO',
|
||||
'http://storage.local/test.pdf', 'test.pdf', 'application/pdf', 1024,
|
||||
);
|
||||
|
||||
entity.reject('admin-99', 'reason');
|
||||
|
||||
expect(entity.reviewedById).toBe('admin-99');
|
||||
});
|
||||
|
||||
it('should set reviewedAt to current time', () => {
|
||||
const entity = PropertyDocumentEntity.createNew(
|
||||
'doc-1', 'prop-1', 'user-1', 'SO_DO',
|
||||
'http://storage.local/test.pdf', 'test.pdf', 'application/pdf', 1024,
|
||||
);
|
||||
|
||||
const before = new Date();
|
||||
entity.reject('admin-1', 'reason');
|
||||
const after = new Date();
|
||||
|
||||
expect(entity.reviewedAt).not.toBeNull();
|
||||
expect(entity.reviewedAt!.getTime()).toBeGreaterThanOrEqual(before.getTime());
|
||||
expect(entity.reviewedAt!.getTime()).toBeLessThanOrEqual(after.getTime());
|
||||
});
|
||||
|
||||
it('should update updatedAt timestamp', () => {
|
||||
const entity = new PropertyDocumentEntity('doc-1', defaultProps, now, now);
|
||||
|
||||
entity.reject('admin-1', 'reason');
|
||||
|
||||
expect(entity.updatedAt.getTime()).toBeGreaterThanOrEqual(now.getTime());
|
||||
});
|
||||
});
|
||||
|
||||
describe('equals (BaseEntity)', () => {
|
||||
it('should return true for same id', () => {
|
||||
const a = new PropertyDocumentEntity('doc-1', defaultProps);
|
||||
const b = new PropertyDocumentEntity('doc-1', { ...defaultProps, fileName: 'other.pdf' });
|
||||
|
||||
expect(a.equals(b)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for different id', () => {
|
||||
const a = new PropertyDocumentEntity('doc-1', defaultProps);
|
||||
const b = new PropertyDocumentEntity('doc-2', defaultProps);
|
||||
|
||||
expect(a.equals(b)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true when comparing to itself', () => {
|
||||
const a = new PropertyDocumentEntity('doc-1', defaultProps);
|
||||
|
||||
expect(a.equals(a)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
1
apps/api/src/modules/documents/domain/entities/index.ts
Normal file
1
apps/api/src/modules/documents/domain/entities/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { PropertyDocumentEntity, type PropertyDocumentProps, type DocumentType, type DocumentVerificationStatus } from './property-document.entity';
|
||||
@@ -0,0 +1,106 @@
|
||||
import { BaseEntity } from '@modules/shared';
|
||||
|
||||
export type DocumentType = 'SO_DO' | 'SO_HONG' | 'GCNQSD' | 'OTHER';
|
||||
export type DocumentVerificationStatus = 'PENDING_REVIEW' | 'APPROVED' | 'REJECTED';
|
||||
|
||||
export interface PropertyDocumentProps {
|
||||
propertyId: string;
|
||||
uploadedById: string;
|
||||
documentType: DocumentType;
|
||||
status: DocumentVerificationStatus;
|
||||
url: string;
|
||||
fileName: string;
|
||||
mimeType: string;
|
||||
fileSizeBytes: number;
|
||||
description: string | null;
|
||||
rejectionReason: string | null;
|
||||
reviewedById: string | null;
|
||||
reviewedAt: Date | null;
|
||||
}
|
||||
|
||||
export class PropertyDocumentEntity extends BaseEntity<string> {
|
||||
private _propertyId: string;
|
||||
private _uploadedById: string;
|
||||
private _documentType: DocumentType;
|
||||
private _status: DocumentVerificationStatus;
|
||||
private _url: string;
|
||||
private _fileName: string;
|
||||
private _mimeType: string;
|
||||
private _fileSizeBytes: number;
|
||||
private _description: string | null;
|
||||
private _rejectionReason: string | null;
|
||||
private _reviewedById: string | null;
|
||||
private _reviewedAt: Date | null;
|
||||
|
||||
constructor(id: string, props: PropertyDocumentProps, createdAt?: Date, updatedAt?: Date) {
|
||||
super(id, createdAt, updatedAt);
|
||||
this._propertyId = props.propertyId;
|
||||
this._uploadedById = props.uploadedById;
|
||||
this._documentType = props.documentType;
|
||||
this._status = props.status;
|
||||
this._url = props.url;
|
||||
this._fileName = props.fileName;
|
||||
this._mimeType = props.mimeType;
|
||||
this._fileSizeBytes = props.fileSizeBytes;
|
||||
this._description = props.description;
|
||||
this._rejectionReason = props.rejectionReason;
|
||||
this._reviewedById = props.reviewedById;
|
||||
this._reviewedAt = props.reviewedAt;
|
||||
}
|
||||
|
||||
get propertyId(): string { return this._propertyId; }
|
||||
get uploadedById(): string { return this._uploadedById; }
|
||||
get documentType(): DocumentType { return this._documentType; }
|
||||
get status(): DocumentVerificationStatus { return this._status; }
|
||||
get url(): string { return this._url; }
|
||||
get fileName(): string { return this._fileName; }
|
||||
get mimeType(): string { return this._mimeType; }
|
||||
get fileSizeBytes(): number { return this._fileSizeBytes; }
|
||||
get description(): string | null { return this._description; }
|
||||
get rejectionReason(): string | null { return this._rejectionReason; }
|
||||
get reviewedById(): string | null { return this._reviewedById; }
|
||||
get reviewedAt(): Date | null { return this._reviewedAt; }
|
||||
|
||||
approve(reviewerId: string): void {
|
||||
this._status = 'APPROVED';
|
||||
this._reviewedById = reviewerId;
|
||||
this._reviewedAt = new Date();
|
||||
this._rejectionReason = null;
|
||||
this.updatedAt = new Date();
|
||||
}
|
||||
|
||||
reject(reviewerId: string, reason: string): void {
|
||||
this._status = 'REJECTED';
|
||||
this._reviewedById = reviewerId;
|
||||
this._reviewedAt = new Date();
|
||||
this._rejectionReason = reason;
|
||||
this.updatedAt = new Date();
|
||||
}
|
||||
|
||||
static createNew(
|
||||
id: string,
|
||||
propertyId: string,
|
||||
uploadedById: string,
|
||||
documentType: DocumentType,
|
||||
url: string,
|
||||
fileName: string,
|
||||
mimeType: string,
|
||||
fileSizeBytes: number,
|
||||
description?: string,
|
||||
): PropertyDocumentEntity {
|
||||
return new PropertyDocumentEntity(id, {
|
||||
propertyId,
|
||||
uploadedById,
|
||||
documentType,
|
||||
status: 'PENDING_REVIEW',
|
||||
url,
|
||||
fileName,
|
||||
mimeType,
|
||||
fileSizeBytes,
|
||||
description: description ?? null,
|
||||
rejectionReason: null,
|
||||
reviewedById: null,
|
||||
reviewedAt: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { PROPERTY_DOCUMENT_REPOSITORY, type IPropertyDocumentRepository } from './property-document.repository';
|
||||
@@ -0,0 +1,13 @@
|
||||
import { type PropertyDocumentEntity } from '../entities/property-document.entity';
|
||||
|
||||
export const PROPERTY_DOCUMENT_REPOSITORY = Symbol('PROPERTY_DOCUMENT_REPOSITORY');
|
||||
|
||||
export interface IPropertyDocumentRepository {
|
||||
findById(id: string): Promise<PropertyDocumentEntity | null>;
|
||||
findByPropertyId(propertyId: string): Promise<PropertyDocumentEntity[]>;
|
||||
save(doc: PropertyDocumentEntity): Promise<void>;
|
||||
update(doc: PropertyDocumentEntity): Promise<void>;
|
||||
delete(id: string): Promise<void>;
|
||||
findPendingReview(page: number, limit: number): Promise<{ items: PropertyDocumentEntity[]; total: number }>;
|
||||
countApprovedByPropertyId(propertyId: string): Promise<number>;
|
||||
}
|
||||
3
apps/api/src/modules/documents/index.ts
Normal file
3
apps/api/src/modules/documents/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { DocumentsModule } from './documents.module';
|
||||
export { PROPERTY_DOCUMENT_REPOSITORY, type IPropertyDocumentRepository } from './domain/repositories/property-document.repository';
|
||||
export { PropertyDocumentEntity, type DocumentType, type DocumentVerificationStatus } from './domain/entities/property-document.entity';
|
||||
@@ -0,0 +1,317 @@
|
||||
import { type DocumentType, type DocumentVerificationStatus } from '@prisma/client';
|
||||
import { PrismaPropertyDocumentRepository } from '../repositories/prisma-property-document.repository';
|
||||
|
||||
describe('PrismaPropertyDocumentRepository', () => {
|
||||
let repository: PrismaPropertyDocumentRepository;
|
||||
let mockPrisma: {
|
||||
propertyDocument: {
|
||||
findUnique: ReturnType<typeof vi.fn>;
|
||||
findMany: ReturnType<typeof vi.fn>;
|
||||
create: ReturnType<typeof vi.fn>;
|
||||
update: ReturnType<typeof vi.fn>;
|
||||
delete: ReturnType<typeof vi.fn>;
|
||||
count: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
$transaction: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
const now = new Date('2026-04-01T10:00:00Z');
|
||||
const later = new Date('2026-04-01T10:05:00Z');
|
||||
|
||||
const mockPrismaDoc = {
|
||||
id: 'doc-1',
|
||||
propertyId: 'prop-1',
|
||||
uploadedById: 'user-1',
|
||||
documentType: 'SO_DO' as DocumentType,
|
||||
status: 'PENDING_REVIEW' as DocumentVerificationStatus,
|
||||
url: 'http://storage.local/documents/test.pdf',
|
||||
fileName: 'sodo.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
fileSizeBytes: 1024,
|
||||
description: 'So do chinh chu',
|
||||
rejectionReason: null,
|
||||
reviewedById: null,
|
||||
reviewedAt: null,
|
||||
createdAt: now,
|
||||
updatedAt: later,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockPrisma = {
|
||||
propertyDocument: {
|
||||
findUnique: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
count: vi.fn(),
|
||||
},
|
||||
$transaction: vi.fn(),
|
||||
};
|
||||
repository = new PrismaPropertyDocumentRepository(mockPrisma as any);
|
||||
});
|
||||
|
||||
describe('findById', () => {
|
||||
it('returns domain entity when document exists', async () => {
|
||||
mockPrisma.propertyDocument.findUnique.mockResolvedValue(mockPrismaDoc);
|
||||
|
||||
const result = await repository.findById('doc-1');
|
||||
|
||||
expect(mockPrisma.propertyDocument.findUnique).toHaveBeenCalledWith({
|
||||
where: { id: 'doc-1' },
|
||||
});
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.id).toBe('doc-1');
|
||||
expect(result!.propertyId).toBe('prop-1');
|
||||
expect(result!.uploadedById).toBe('user-1');
|
||||
expect(result!.documentType).toBe('SO_DO');
|
||||
expect(result!.status).toBe('PENDING_REVIEW');
|
||||
expect(result!.url).toBe('http://storage.local/documents/test.pdf');
|
||||
expect(result!.fileName).toBe('sodo.pdf');
|
||||
expect(result!.mimeType).toBe('application/pdf');
|
||||
expect(result!.fileSizeBytes).toBe(1024);
|
||||
expect(result!.description).toBe('So do chinh chu');
|
||||
expect(result!.rejectionReason).toBeNull();
|
||||
expect(result!.reviewedById).toBeNull();
|
||||
expect(result!.reviewedAt).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when document does not exist', async () => {
|
||||
mockPrisma.propertyDocument.findUnique.mockResolvedValue(null);
|
||||
|
||||
const result = await repository.findById('nonexistent');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('preserves createdAt and updatedAt timestamps', async () => {
|
||||
mockPrisma.propertyDocument.findUnique.mockResolvedValue(mockPrismaDoc);
|
||||
|
||||
const result = await repository.findById('doc-1');
|
||||
|
||||
expect(result!.createdAt).toEqual(now);
|
||||
expect(result!.updatedAt).toEqual(later);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findByPropertyId', () => {
|
||||
it('returns array of domain entities ordered by createdAt desc', async () => {
|
||||
const docs = [
|
||||
{ ...mockPrismaDoc, id: 'doc-2', createdAt: later },
|
||||
{ ...mockPrismaDoc, id: 'doc-1', createdAt: now },
|
||||
];
|
||||
mockPrisma.propertyDocument.findMany.mockResolvedValue(docs);
|
||||
|
||||
const result = await repository.findByPropertyId('prop-1');
|
||||
|
||||
expect(mockPrisma.propertyDocument.findMany).toHaveBeenCalledWith({
|
||||
where: { propertyId: 'prop-1' },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0]!.id).toBe('doc-2');
|
||||
expect(result[1]!.id).toBe('doc-1');
|
||||
});
|
||||
|
||||
it('returns empty array when no documents exist', async () => {
|
||||
mockPrisma.propertyDocument.findMany.mockResolvedValue([]);
|
||||
|
||||
const result = await repository.findByPropertyId('prop-no-docs');
|
||||
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('save', () => {
|
||||
it('persists a new document with correct field mapping', async () => {
|
||||
mockPrisma.propertyDocument.create.mockResolvedValue(mockPrismaDoc);
|
||||
|
||||
const { PropertyDocumentEntity } = await import('../../domain/entities/property-document.entity');
|
||||
const entity = PropertyDocumentEntity.createNew(
|
||||
'doc-1', 'prop-1', 'user-1', 'SO_DO',
|
||||
'http://storage.local/documents/test.pdf',
|
||||
'sodo.pdf', 'application/pdf', 1024, 'So do chinh chu',
|
||||
);
|
||||
|
||||
await repository.save(entity);
|
||||
|
||||
expect(mockPrisma.propertyDocument.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
id: 'doc-1',
|
||||
propertyId: 'prop-1',
|
||||
uploadedById: 'user-1',
|
||||
documentType: 'SO_DO',
|
||||
status: 'PENDING_REVIEW',
|
||||
url: 'http://storage.local/documents/test.pdf',
|
||||
fileName: 'sodo.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
fileSizeBytes: 1024,
|
||||
description: 'So do chinh chu',
|
||||
rejectionReason: null,
|
||||
reviewedById: null,
|
||||
reviewedAt: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('updates status, rejectionReason, reviewedById, reviewedAt, updatedAt', async () => {
|
||||
mockPrisma.propertyDocument.update.mockResolvedValue(mockPrismaDoc);
|
||||
|
||||
const { PropertyDocumentEntity } = await import('../../domain/entities/property-document.entity');
|
||||
const entity = PropertyDocumentEntity.createNew(
|
||||
'doc-1', 'prop-1', 'user-1', 'SO_DO',
|
||||
'http://storage.local/documents/test.pdf',
|
||||
'sodo.pdf', 'application/pdf', 1024,
|
||||
);
|
||||
entity.approve('admin-1');
|
||||
|
||||
await repository.update(entity);
|
||||
|
||||
expect(mockPrisma.propertyDocument.update).toHaveBeenCalledWith({
|
||||
where: { id: 'doc-1' },
|
||||
data: {
|
||||
status: 'APPROVED',
|
||||
rejectionReason: null,
|
||||
reviewedById: 'admin-1',
|
||||
reviewedAt: expect.any(Date),
|
||||
updatedAt: expect.any(Date),
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete', () => {
|
||||
it('deletes document by id', async () => {
|
||||
mockPrisma.propertyDocument.delete.mockResolvedValue(mockPrismaDoc);
|
||||
|
||||
await repository.delete('doc-1');
|
||||
|
||||
expect(mockPrisma.propertyDocument.delete).toHaveBeenCalledWith({
|
||||
where: { id: 'doc-1' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('findPendingReview', () => {
|
||||
it('returns paginated items and total count', async () => {
|
||||
const pendingDocs = [
|
||||
{ ...mockPrismaDoc, id: 'doc-1' },
|
||||
{ ...mockPrismaDoc, id: 'doc-2' },
|
||||
];
|
||||
mockPrisma.$transaction.mockResolvedValue([pendingDocs, 5]);
|
||||
|
||||
const result = await repository.findPendingReview(1, 2);
|
||||
|
||||
expect(mockPrisma.$transaction).toHaveBeenCalled();
|
||||
expect(result.items).toHaveLength(2);
|
||||
expect(result.total).toBe(5);
|
||||
expect(result.items[0]!.id).toBe('doc-1');
|
||||
expect(result.items[1]!.id).toBe('doc-2');
|
||||
});
|
||||
|
||||
it('applies correct pagination (page 2, limit 10)', async () => {
|
||||
mockPrisma.$transaction.mockImplementation(async (queries: unknown[]) => {
|
||||
// The transaction receives an array of promises
|
||||
return Promise.all(queries as Promise<unknown>[]);
|
||||
});
|
||||
mockPrisma.propertyDocument.findMany.mockResolvedValue([]);
|
||||
mockPrisma.propertyDocument.count.mockResolvedValue(0);
|
||||
|
||||
await repository.findPendingReview(2, 10);
|
||||
|
||||
expect(mockPrisma.propertyDocument.findMany).toHaveBeenCalledWith({
|
||||
where: { status: 'PENDING_REVIEW' },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
skip: 10,
|
||||
take: 10,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns empty items when no pending documents', async () => {
|
||||
mockPrisma.$transaction.mockResolvedValue([[], 0]);
|
||||
|
||||
const result = await repository.findPendingReview(1, 20);
|
||||
|
||||
expect(result.items).toHaveLength(0);
|
||||
expect(result.total).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('countApprovedByPropertyId', () => {
|
||||
it('counts approved documents for a property', async () => {
|
||||
mockPrisma.propertyDocument.count.mockResolvedValue(3);
|
||||
|
||||
const result = await repository.countApprovedByPropertyId('prop-1');
|
||||
|
||||
expect(mockPrisma.propertyDocument.count).toHaveBeenCalledWith({
|
||||
where: { propertyId: 'prop-1', status: 'APPROVED' },
|
||||
});
|
||||
expect(result).toBe(3);
|
||||
});
|
||||
|
||||
it('returns 0 when no approved documents', async () => {
|
||||
mockPrisma.propertyDocument.count.mockResolvedValue(0);
|
||||
|
||||
const result = await repository.countApprovedByPropertyId('prop-no-approved');
|
||||
|
||||
expect(result).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toDomain mapping', () => {
|
||||
it('correctly maps all document types', async () => {
|
||||
const docTypes: DocumentType[] = ['SO_DO', 'SO_HONG', 'GCNQSD', 'OTHER'];
|
||||
|
||||
for (const dt of docTypes) {
|
||||
mockPrisma.propertyDocument.findUnique.mockResolvedValue({
|
||||
...mockPrismaDoc,
|
||||
documentType: dt,
|
||||
});
|
||||
const result = await repository.findById('doc-1');
|
||||
expect(result!.documentType).toBe(dt);
|
||||
}
|
||||
});
|
||||
|
||||
it('correctly maps all verification statuses', async () => {
|
||||
const statuses: DocumentVerificationStatus[] = ['PENDING_REVIEW', 'APPROVED', 'REJECTED'];
|
||||
|
||||
for (const st of statuses) {
|
||||
mockPrisma.propertyDocument.findUnique.mockResolvedValue({
|
||||
...mockPrismaDoc,
|
||||
status: st,
|
||||
});
|
||||
const result = await repository.findById('doc-1');
|
||||
expect(result!.status).toBe(st);
|
||||
}
|
||||
});
|
||||
|
||||
it('preserves null description', async () => {
|
||||
mockPrisma.propertyDocument.findUnique.mockResolvedValue({
|
||||
...mockPrismaDoc,
|
||||
description: null,
|
||||
});
|
||||
|
||||
const result = await repository.findById('doc-1');
|
||||
expect(result!.description).toBeNull();
|
||||
});
|
||||
|
||||
it('preserves rejection reason and reviewer info', async () => {
|
||||
const reviewedAt = new Date('2026-04-01T12:00:00Z');
|
||||
mockPrisma.propertyDocument.findUnique.mockResolvedValue({
|
||||
...mockPrismaDoc,
|
||||
status: 'REJECTED',
|
||||
rejectionReason: 'Anh khong ro',
|
||||
reviewedById: 'admin-1',
|
||||
reviewedAt,
|
||||
});
|
||||
|
||||
const result = await repository.findById('doc-1');
|
||||
expect(result!.status).toBe('REJECTED');
|
||||
expect(result!.rejectionReason).toBe('Anh khong ro');
|
||||
expect(result!.reviewedById).toBe('admin-1');
|
||||
expect(result!.reviewedAt).toEqual(reviewedAt);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { type PropertyDocument as PrismaPropertyDocument, type DocumentType, type DocumentVerificationStatus } from '@prisma/client';
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- PrismaService is constructor-injected (NestJS DI)
|
||||
import { PrismaService } from '@modules/shared';
|
||||
import { PropertyDocumentEntity, type PropertyDocumentProps } from '../../domain/entities/property-document.entity';
|
||||
import { type IPropertyDocumentRepository } from '../../domain/repositories/property-document.repository';
|
||||
|
||||
@Injectable()
|
||||
export class PrismaPropertyDocumentRepository implements IPropertyDocumentRepository {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async findById(id: string): Promise<PropertyDocumentEntity | null> {
|
||||
const row = await this.prisma.propertyDocument.findUnique({ where: { id } });
|
||||
return row ? this.toDomain(row) : null;
|
||||
}
|
||||
|
||||
async findByPropertyId(propertyId: string): Promise<PropertyDocumentEntity[]> {
|
||||
const rows = await this.prisma.propertyDocument.findMany({
|
||||
where: { propertyId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
return rows.map((r) => this.toDomain(r));
|
||||
}
|
||||
|
||||
async save(doc: PropertyDocumentEntity): Promise<void> {
|
||||
await this.prisma.propertyDocument.create({
|
||||
data: {
|
||||
id: doc.id,
|
||||
propertyId: doc.propertyId,
|
||||
uploadedById: doc.uploadedById,
|
||||
documentType: doc.documentType as DocumentType,
|
||||
status: doc.status as DocumentVerificationStatus,
|
||||
url: doc.url,
|
||||
fileName: doc.fileName,
|
||||
mimeType: doc.mimeType,
|
||||
fileSizeBytes: doc.fileSizeBytes,
|
||||
description: doc.description,
|
||||
rejectionReason: doc.rejectionReason,
|
||||
reviewedById: doc.reviewedById,
|
||||
reviewedAt: doc.reviewedAt,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async update(doc: PropertyDocumentEntity): Promise<void> {
|
||||
await this.prisma.propertyDocument.update({
|
||||
where: { id: doc.id },
|
||||
data: {
|
||||
status: doc.status as DocumentVerificationStatus,
|
||||
rejectionReason: doc.rejectionReason,
|
||||
reviewedById: doc.reviewedById,
|
||||
reviewedAt: doc.reviewedAt,
|
||||
updatedAt: doc.updatedAt,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await this.prisma.propertyDocument.delete({ where: { id } });
|
||||
}
|
||||
|
||||
async findPendingReview(page: number, limit: number): Promise<{ items: PropertyDocumentEntity[]; total: number }> {
|
||||
const [rows, total] = await this.prisma.$transaction([
|
||||
this.prisma.propertyDocument.findMany({
|
||||
where: { status: 'PENDING_REVIEW' },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
skip: (page - 1) * limit,
|
||||
take: limit,
|
||||
}),
|
||||
this.prisma.propertyDocument.count({ where: { status: 'PENDING_REVIEW' } }),
|
||||
]);
|
||||
return { items: rows.map((r) => this.toDomain(r)), total };
|
||||
}
|
||||
|
||||
async countApprovedByPropertyId(propertyId: string): Promise<number> {
|
||||
return this.prisma.propertyDocument.count({
|
||||
where: { propertyId, status: 'APPROVED' },
|
||||
});
|
||||
}
|
||||
|
||||
private toDomain(row: PrismaPropertyDocument): PropertyDocumentEntity {
|
||||
const props: PropertyDocumentProps = {
|
||||
propertyId: row.propertyId,
|
||||
uploadedById: row.uploadedById,
|
||||
documentType: row.documentType,
|
||||
status: row.status,
|
||||
url: row.url,
|
||||
fileName: row.fileName,
|
||||
mimeType: row.mimeType,
|
||||
fileSizeBytes: row.fileSizeBytes,
|
||||
description: row.description,
|
||||
rejectionReason: row.rejectionReason,
|
||||
reviewedById: row.reviewedById,
|
||||
reviewedAt: row.reviewedAt,
|
||||
};
|
||||
return new PropertyDocumentEntity(row.id, props, row.createdAt, row.updatedAt);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import { PropertyDocumentsController } from '../controllers/property-documents.controller';
|
||||
|
||||
describe('PropertyDocumentsController', () => {
|
||||
let controller: PropertyDocumentsController;
|
||||
let mockCommandBus: { execute: ReturnType<typeof vi.fn> };
|
||||
let mockQueryBus: { execute: ReturnType<typeof vi.fn> };
|
||||
|
||||
beforeEach(() => {
|
||||
mockCommandBus = { execute: vi.fn() };
|
||||
mockQueryBus = { execute: vi.fn() };
|
||||
|
||||
controller = new PropertyDocumentsController(
|
||||
mockCommandBus as any,
|
||||
mockQueryBus as any,
|
||||
);
|
||||
});
|
||||
|
||||
describe('uploadDocument', () => {
|
||||
it('executes UploadDocumentCommand with correct params', async () => {
|
||||
const expectedResult = { documentId: 'doc-1', url: 'http://storage.local/test.pdf' };
|
||||
mockCommandBus.execute.mockResolvedValue(expectedResult);
|
||||
|
||||
const file = {
|
||||
buffer: Buffer.from('fake'),
|
||||
mimetype: 'application/pdf',
|
||||
originalname: 'sodo.pdf',
|
||||
size: 1024,
|
||||
};
|
||||
const dto = { documentType: 'SO_DO' as const };
|
||||
const user = { sub: 'user-1', email: 'test@example.com', role: 'USER' };
|
||||
|
||||
const result = await controller.uploadDocument('prop-1', file as any, dto, user as any);
|
||||
|
||||
expect(result).toEqual(expectedResult);
|
||||
expect(mockCommandBus.execute).toHaveBeenCalledTimes(1);
|
||||
const command = mockCommandBus.execute.mock.calls[0]![0];
|
||||
expect(command.propertyId).toBe('prop-1');
|
||||
expect(command.userId).toBe('user-1');
|
||||
expect(command.documentType).toBe('SO_DO');
|
||||
expect(command.file).toBe(file);
|
||||
});
|
||||
|
||||
it('passes optional description from dto', async () => {
|
||||
mockCommandBus.execute.mockResolvedValue({ documentId: 'doc-1', url: 'http://test.pdf' });
|
||||
|
||||
const file = {
|
||||
buffer: Buffer.from('fake'),
|
||||
mimetype: 'application/pdf',
|
||||
originalname: 'sodo.pdf',
|
||||
size: 1024,
|
||||
};
|
||||
const dto = { documentType: 'SO_HONG' as const, description: 'So hong chinh chu' };
|
||||
const user = { sub: 'user-1', email: 'test@example.com', role: 'USER' };
|
||||
|
||||
await controller.uploadDocument('prop-1', file as any, dto, user as any);
|
||||
|
||||
const command = mockCommandBus.execute.mock.calls[0]![0];
|
||||
expect(command.description).toBe('So hong chinh chu');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPropertyDocuments', () => {
|
||||
it('executes GetPropertyDocumentsQuery with propertyId', async () => {
|
||||
const expectedDocs = [
|
||||
{ id: 'doc-1', propertyId: 'prop-1', documentType: 'SO_DO' },
|
||||
{ id: 'doc-2', propertyId: 'prop-1', documentType: 'SO_HONG' },
|
||||
];
|
||||
mockQueryBus.execute.mockResolvedValue(expectedDocs);
|
||||
|
||||
const result = await controller.getPropertyDocuments('prop-1');
|
||||
|
||||
expect(result).toEqual(expectedDocs);
|
||||
expect(mockQueryBus.execute).toHaveBeenCalledTimes(1);
|
||||
const query = mockQueryBus.execute.mock.calls[0]![0];
|
||||
expect(query.propertyId).toBe('prop-1');
|
||||
});
|
||||
|
||||
it('returns empty array when no documents', async () => {
|
||||
mockQueryBus.execute.mockResolvedValue([]);
|
||||
|
||||
const result = await controller.getPropertyDocuments('prop-empty');
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPendingDocuments', () => {
|
||||
it('executes GetPendingDocumentsQuery with default page and limit', async () => {
|
||||
const expectedResult = { items: [], total: 0, page: 1, limit: 20 };
|
||||
mockQueryBus.execute.mockResolvedValue(expectedResult);
|
||||
|
||||
const result = await controller.getPendingDocuments();
|
||||
|
||||
expect(result).toEqual(expectedResult);
|
||||
const query = mockQueryBus.execute.mock.calls[0]![0];
|
||||
expect(query.page).toBe(1);
|
||||
expect(query.limit).toBe(20);
|
||||
});
|
||||
|
||||
it('parses string page and limit to integers', async () => {
|
||||
mockQueryBus.execute.mockResolvedValue({ items: [], total: 0, page: 3, limit: 50 });
|
||||
|
||||
await controller.getPendingDocuments('3', '50');
|
||||
|
||||
const query = mockQueryBus.execute.mock.calls[0]![0];
|
||||
expect(query.page).toBe(3);
|
||||
expect(query.limit).toBe(50);
|
||||
});
|
||||
|
||||
it('uses default page=1 when page is not provided', async () => {
|
||||
mockQueryBus.execute.mockResolvedValue({ items: [], total: 0, page: 1, limit: 10 });
|
||||
|
||||
await controller.getPendingDocuments(undefined, '10');
|
||||
|
||||
const query = mockQueryBus.execute.mock.calls[0]![0];
|
||||
expect(query.page).toBe(1);
|
||||
expect(query.limit).toBe(10);
|
||||
});
|
||||
|
||||
it('uses default limit=20 when limit is not provided', async () => {
|
||||
mockQueryBus.execute.mockResolvedValue({ items: [], total: 0, page: 2, limit: 20 });
|
||||
|
||||
await controller.getPendingDocuments('2');
|
||||
|
||||
const query = mockQueryBus.execute.mock.calls[0]![0];
|
||||
expect(query.page).toBe(2);
|
||||
expect(query.limit).toBe(20);
|
||||
});
|
||||
});
|
||||
|
||||
describe('approveDocument', () => {
|
||||
it('executes ApproveDocumentCommand with correct params', async () => {
|
||||
const expectedResult = { documentId: 'doc-1', status: 'APPROVED', message: 'ok' };
|
||||
mockCommandBus.execute.mockResolvedValue(expectedResult);
|
||||
|
||||
const dto = { notes: 'Giay to hop le' };
|
||||
const user = { sub: 'admin-1', email: 'admin@example.com', role: 'ADMIN' };
|
||||
|
||||
const result = await controller.approveDocument('doc-1', dto, user as any);
|
||||
|
||||
expect(result).toEqual(expectedResult);
|
||||
const command = mockCommandBus.execute.mock.calls[0]![0];
|
||||
expect(command.documentId).toBe('doc-1');
|
||||
expect(command.adminId).toBe('admin-1');
|
||||
expect(command.notes).toBe('Giay to hop le');
|
||||
});
|
||||
|
||||
it('passes undefined notes when not provided', async () => {
|
||||
mockCommandBus.execute.mockResolvedValue({ documentId: 'doc-1', status: 'APPROVED', message: 'ok' });
|
||||
|
||||
const dto = {};
|
||||
const user = { sub: 'admin-1', email: 'admin@example.com', role: 'ADMIN' };
|
||||
|
||||
await controller.approveDocument('doc-1', dto, user as any);
|
||||
|
||||
const command = mockCommandBus.execute.mock.calls[0]![0];
|
||||
expect(command.notes).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('rejectDocument', () => {
|
||||
it('executes RejectDocumentCommand with correct params', async () => {
|
||||
const expectedResult = { documentId: 'doc-1', status: 'REJECTED', message: 'rejected' };
|
||||
mockCommandBus.execute.mockResolvedValue(expectedResult);
|
||||
|
||||
const dto = { reason: 'Giay to khong ro rang' };
|
||||
const user = { sub: 'admin-1', email: 'admin@example.com', role: 'ADMIN' };
|
||||
|
||||
const result = await controller.rejectDocument('doc-1', dto, user as any);
|
||||
|
||||
expect(result).toEqual(expectedResult);
|
||||
const command = mockCommandBus.execute.mock.calls[0]![0];
|
||||
expect(command.documentId).toBe('doc-1');
|
||||
expect(command.adminId).toBe('admin-1');
|
||||
expect(command.reason).toBe('Giay to khong ro rang');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
import { validate } from 'class-validator';
|
||||
import { UploadDocumentDto, ApproveDocumentDto, RejectDocumentDto } from '../dto/upload-document.dto';
|
||||
|
||||
describe('UploadDocumentDto', () => {
|
||||
it('accepts valid SO_DO document type', async () => {
|
||||
const dto = new UploadDocumentDto();
|
||||
dto.documentType = 'SO_DO';
|
||||
|
||||
const errors = await validate(dto);
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('accepts valid SO_HONG document type', async () => {
|
||||
const dto = new UploadDocumentDto();
|
||||
dto.documentType = 'SO_HONG';
|
||||
|
||||
const errors = await validate(dto);
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('accepts valid GCNQSD document type', async () => {
|
||||
const dto = new UploadDocumentDto();
|
||||
dto.documentType = 'GCNQSD';
|
||||
|
||||
const errors = await validate(dto);
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('accepts valid OTHER document type', async () => {
|
||||
const dto = new UploadDocumentDto();
|
||||
dto.documentType = 'OTHER';
|
||||
|
||||
const errors = await validate(dto);
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('rejects invalid document type', async () => {
|
||||
const dto = new UploadDocumentDto();
|
||||
(dto as any).documentType = 'INVALID';
|
||||
|
||||
const errors = await validate(dto);
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
expect(errors[0]!.property).toBe('documentType');
|
||||
});
|
||||
|
||||
it('accepts optional description string', async () => {
|
||||
const dto = new UploadDocumentDto();
|
||||
dto.documentType = 'SO_DO';
|
||||
dto.description = 'So do chinh chu';
|
||||
|
||||
const errors = await validate(dto);
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('accepts missing description', async () => {
|
||||
const dto = new UploadDocumentDto();
|
||||
dto.documentType = 'SO_DO';
|
||||
|
||||
const errors = await validate(dto);
|
||||
expect(errors).toHaveLength(0);
|
||||
expect(dto.description).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects non-string description', async () => {
|
||||
const dto = new UploadDocumentDto();
|
||||
dto.documentType = 'SO_DO';
|
||||
(dto as any).description = 12345;
|
||||
|
||||
const errors = await validate(dto);
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
expect(errors.some((e) => e.property === 'description')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ApproveDocumentDto', () => {
|
||||
it('accepts optional notes string', async () => {
|
||||
const dto = new ApproveDocumentDto();
|
||||
dto.notes = 'Giay to hop le';
|
||||
|
||||
const errors = await validate(dto);
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('accepts missing notes', async () => {
|
||||
const dto = new ApproveDocumentDto();
|
||||
|
||||
const errors = await validate(dto);
|
||||
expect(errors).toHaveLength(0);
|
||||
expect(dto.notes).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects non-string notes', async () => {
|
||||
const dto = new ApproveDocumentDto();
|
||||
(dto as any).notes = 999;
|
||||
|
||||
const errors = await validate(dto);
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
expect(errors[0]!.property).toBe('notes');
|
||||
});
|
||||
});
|
||||
|
||||
describe('RejectDocumentDto', () => {
|
||||
it('accepts valid reason string', async () => {
|
||||
const dto = new RejectDocumentDto();
|
||||
dto.reason = 'Giay to khong ro rang';
|
||||
|
||||
const errors = await validate(dto);
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('rejects missing reason', async () => {
|
||||
const dto = new RejectDocumentDto();
|
||||
|
||||
const errors = await validate(dto);
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
expect(errors[0]!.property).toBe('reason');
|
||||
});
|
||||
|
||||
it('rejects non-string reason', async () => {
|
||||
const dto = new RejectDocumentDto();
|
||||
(dto as any).reason = 12345;
|
||||
|
||||
const errors = await validate(dto);
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
expect(errors[0]!.property).toBe('reason');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,156 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
Post,
|
||||
Query,
|
||||
UploadedFile,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- CommandBus & QueryBus are constructor-injected (NestJS DI)
|
||||
import { CommandBus, QueryBus } from '@nestjs/cqrs';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import {
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
ApiResponse,
|
||||
ApiBearerAuth,
|
||||
ApiParam,
|
||||
ApiQuery,
|
||||
ApiConsumes,
|
||||
} from '@nestjs/swagger';
|
||||
import { type JwtPayload, CurrentUser, Roles, JwtAuthGuard, RolesGuard } from '@modules/auth';
|
||||
import { FileValidationPipe, type UploadedFile as ValidatedFile, EndpointRateLimit, EndpointRateLimitGuard, RL_SENSITIVE_WRITE } from '@modules/shared';
|
||||
import { ApproveDocumentCommand } from '../../application/commands/approve-document/approve-document.command';
|
||||
import { type ApproveDocumentResult } from '../../application/commands/approve-document/approve-document.handler';
|
||||
import { RejectDocumentCommand } from '../../application/commands/reject-document/reject-document.command';
|
||||
import { type RejectDocumentResult } from '../../application/commands/reject-document/reject-document.handler';
|
||||
import { UploadDocumentCommand } from '../../application/commands/upload-document/upload-document.command';
|
||||
import { type UploadDocumentResult } from '../../application/commands/upload-document/upload-document.handler';
|
||||
import { type PendingDocumentsResult } from '../../application/queries/get-pending-documents/get-pending-documents.handler';
|
||||
import { GetPendingDocumentsQuery } from '../../application/queries/get-pending-documents/get-pending-documents.query';
|
||||
import { type PropertyDocumentDto } from '../../application/queries/get-property-documents/get-property-documents.handler';
|
||||
import { GetPropertyDocumentsQuery } from '../../application/queries/get-property-documents/get-property-documents.query';
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- DTOs are used at runtime by class-validator via @Body()
|
||||
import { UploadDocumentDto, ApproveDocumentDto, RejectDocumentDto } from '../dto/upload-document.dto';
|
||||
|
||||
@ApiTags('documents')
|
||||
@Controller()
|
||||
export class PropertyDocumentsController {
|
||||
constructor(
|
||||
private readonly commandBus: CommandBus,
|
||||
private readonly queryBus: QueryBus,
|
||||
) {}
|
||||
|
||||
// ── User-facing endpoints ──
|
||||
|
||||
@ApiBearerAuth('JWT')
|
||||
@ApiOperation({ summary: 'Upload a legal document for a property' })
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiParam({ name: 'propertyId', description: 'Property UUID' })
|
||||
@ApiResponse({ status: 201, description: 'Document uploaded successfully' })
|
||||
@ApiResponse({ status: 400, description: 'Validation error (invalid file type/size)' })
|
||||
@ApiResponse({ status: 401, description: 'Unauthorized – missing or invalid JWT' })
|
||||
@ApiResponse({ status: 404, description: 'Property not found' })
|
||||
@EndpointRateLimit(RL_SENSITIVE_WRITE)
|
||||
@UseGuards(JwtAuthGuard, EndpointRateLimitGuard)
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
@Post('properties/:propertyId/documents')
|
||||
async uploadDocument(
|
||||
@Param('propertyId') propertyId: string,
|
||||
@UploadedFile(new FileValidationPipe({
|
||||
maxSizeBytes: 20 * 1024 * 1024, // 20 MB
|
||||
allowedMimeTypes: ['image/jpeg', 'image/png', 'image/webp', 'application/pdf'],
|
||||
}))
|
||||
file: ValidatedFile,
|
||||
@Body() dto: UploadDocumentDto,
|
||||
@CurrentUser() user: JwtPayload,
|
||||
): Promise<UploadDocumentResult> {
|
||||
return this.commandBus.execute(
|
||||
new UploadDocumentCommand(
|
||||
propertyId,
|
||||
user.sub,
|
||||
dto.documentType,
|
||||
file,
|
||||
dto.description,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@ApiBearerAuth('JWT')
|
||||
@ApiOperation({ summary: 'List documents for a property' })
|
||||
@ApiParam({ name: 'propertyId', description: 'Property UUID' })
|
||||
@ApiResponse({ status: 200, description: 'Documents retrieved successfully' })
|
||||
@ApiResponse({ status: 401, description: 'Unauthorized – missing or invalid JWT' })
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get('properties/:propertyId/documents')
|
||||
async getPropertyDocuments(
|
||||
@Param('propertyId') propertyId: string,
|
||||
): Promise<PropertyDocumentDto[]> {
|
||||
return this.queryBus.execute(new GetPropertyDocumentsQuery(propertyId));
|
||||
}
|
||||
|
||||
// ── Admin endpoints ──
|
||||
|
||||
@ApiBearerAuth('JWT')
|
||||
@ApiOperation({ summary: 'Admin: get document verification queue' })
|
||||
@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: 'Document verification queue retrieved' })
|
||||
@ApiResponse({ status: 401, description: 'Unauthorized – missing or invalid JWT' })
|
||||
@ApiResponse({ status: 403, description: 'Forbidden – requires ADMIN role' })
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('ADMIN')
|
||||
@Get('admin/documents')
|
||||
async getPendingDocuments(
|
||||
@Query('page') page?: string,
|
||||
@Query('limit') limit?: string,
|
||||
): Promise<PendingDocumentsResult> {
|
||||
return this.queryBus.execute(
|
||||
new GetPendingDocumentsQuery(
|
||||
page ? parseInt(page, 10) : 1,
|
||||
limit ? parseInt(limit, 10) : 20,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@ApiBearerAuth('JWT')
|
||||
@ApiOperation({ summary: 'Admin: approve a document' })
|
||||
@ApiParam({ name: 'id', description: 'Document UUID' })
|
||||
@ApiResponse({ status: 201, description: 'Document approved successfully' })
|
||||
@ApiResponse({ status: 401, description: 'Unauthorized – missing or invalid JWT' })
|
||||
@ApiResponse({ status: 403, description: 'Forbidden – requires ADMIN role' })
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('ADMIN')
|
||||
@Post('admin/documents/:id/approve')
|
||||
async approveDocument(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: ApproveDocumentDto,
|
||||
@CurrentUser() user: JwtPayload,
|
||||
): Promise<ApproveDocumentResult> {
|
||||
return this.commandBus.execute(
|
||||
new ApproveDocumentCommand(id, user.sub, dto.notes),
|
||||
);
|
||||
}
|
||||
|
||||
@ApiBearerAuth('JWT')
|
||||
@ApiOperation({ summary: 'Admin: reject a document' })
|
||||
@ApiParam({ name: 'id', description: 'Document UUID' })
|
||||
@ApiResponse({ status: 201, description: 'Document rejected successfully' })
|
||||
@ApiResponse({ status: 401, description: 'Unauthorized – missing or invalid JWT' })
|
||||
@ApiResponse({ status: 403, description: 'Forbidden – requires ADMIN role' })
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('ADMIN')
|
||||
@Post('admin/documents/:id/reject')
|
||||
async rejectDocument(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: RejectDocumentDto,
|
||||
@CurrentUser() user: JwtPayload,
|
||||
): Promise<RejectDocumentResult> {
|
||||
return this.commandBus.execute(
|
||||
new RejectDocumentCommand(id, user.sub, dto.reason),
|
||||
);
|
||||
}
|
||||
}
|
||||
1
apps/api/src/modules/documents/presentation/dto/index.ts
Normal file
1
apps/api/src/modules/documents/presentation/dto/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { UploadDocumentDto, ApproveDocumentDto, RejectDocumentDto } from './upload-document.dto';
|
||||
@@ -0,0 +1,38 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsIn, IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
|
||||
|
||||
export class UploadDocumentDto {
|
||||
@ApiProperty({
|
||||
description: 'Loại giấy tờ pháp lý',
|
||||
enum: ['SO_DO', 'SO_HONG', 'GCNQSD', 'OTHER'],
|
||||
example: 'SO_DO',
|
||||
})
|
||||
@IsIn(['SO_DO', 'SO_HONG', 'GCNQSD', 'OTHER'])
|
||||
documentType!: 'SO_DO' | 'SO_HONG' | 'GCNQSD' | 'OTHER';
|
||||
|
||||
@ApiPropertyOptional({ description: 'Mô tả giấy tờ', example: 'Sổ đỏ chính chủ' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(1000)
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export class ApproveDocumentDto {
|
||||
@ApiPropertyOptional({ description: 'Ghi chú xác minh', example: 'Giấy tờ hợp lệ' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(2000)
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export class RejectDocumentDto {
|
||||
@ApiProperty({
|
||||
description: 'Lý do từ chối (tối thiểu 5 ký tự)',
|
||||
example: 'Giấy tờ không rõ ràng, ảnh mờ',
|
||||
minLength: 5,
|
||||
})
|
||||
@IsString()
|
||||
@MinLength(5)
|
||||
@MaxLength(2000)
|
||||
reason!: string;
|
||||
}
|
||||
@@ -48,7 +48,10 @@ export class EstimateIndustrialRentHandler
|
||||
// Calculate base rent based on property type
|
||||
const rentField = this.getRentField(propertyType);
|
||||
const rents = provinceParks
|
||||
.map((p) => p[rentField] as number | null)
|
||||
.map((p) => {
|
||||
const val = p[rentField];
|
||||
return val != null ? Number(val) : null;
|
||||
})
|
||||
.filter((r): r is number => r != null);
|
||||
|
||||
const provinceLow = rents.length > 0 ? Math.min(...rents) : null;
|
||||
@@ -58,7 +61,7 @@ export class EstimateIndustrialRentHandler
|
||||
// Determine base rent
|
||||
let baseRentUsdM2: number;
|
||||
if (specificPark && specificPark[rentField] != null) {
|
||||
baseRentUsdM2 = specificPark[rentField] as number;
|
||||
baseRentUsdM2 = Number(specificPark[rentField]);
|
||||
} else if (provinceAvg != null) {
|
||||
baseRentUsdM2 = provinceAvg;
|
||||
} else {
|
||||
@@ -126,9 +129,11 @@ export class EstimateIndustrialRentHandler
|
||||
const totalLeaseUsd = Math.round(totalMonthlyUsd * 12 * leaseDurationYears * 100) / 100;
|
||||
|
||||
// Management fee
|
||||
const managementFeeUsdM2 = specificPark?.managementFeeUsd ?? (provinceParks.length > 0
|
||||
? provinceParks.reduce((sum, p) => sum + (p.managementFeeUsd ?? 0), 0) / provinceParks.length || null
|
||||
: null);
|
||||
const managementFeeUsdM2 = specificPark?.managementFeeUsd != null
|
||||
? Number(specificPark.managementFeeUsd)
|
||||
: (provinceParks.length > 0
|
||||
? provinceParks.reduce((sum, p) => sum + (p.managementFeeUsd != null ? Number(p.managementFeeUsd) : 0), 0) / provinceParks.length || null
|
||||
: null);
|
||||
|
||||
return {
|
||||
estimated_rent_usd_m2: adjustedRent,
|
||||
|
||||
@@ -34,7 +34,8 @@ export interface IndustrialListingListItem {
|
||||
status: IndustrialListingStatus;
|
||||
title: string;
|
||||
areaM2: number;
|
||||
priceUsdM2: number | null;
|
||||
/** Decimal(18,4) serialised as string by PostgreSQL numeric — use parseFloat() for arithmetic. */
|
||||
priceUsdM2: string | null;
|
||||
pricingUnit: string | null;
|
||||
ceilingHeightM: number | null;
|
||||
hasMezzanine: boolean;
|
||||
@@ -64,10 +65,13 @@ export interface IndustrialListingDetailData {
|
||||
hasMezzanine: boolean;
|
||||
hasOfficeArea: boolean;
|
||||
officeAreaM2: number | null;
|
||||
priceUsdM2: number | null;
|
||||
/** Decimal(18,4) serialised as string — use parseFloat() for arithmetic. */
|
||||
priceUsdM2: string | null;
|
||||
pricingUnit: string | null;
|
||||
totalLeasePrice: number | null;
|
||||
managementFee: number | null;
|
||||
/** Decimal(18,4) serialised as string — use parseFloat() for arithmetic. */
|
||||
totalLeasePrice: string | null;
|
||||
/** Decimal(18,4) serialised as string — use parseFloat() for arithmetic. */
|
||||
managementFee: string | null;
|
||||
depositMonths: number | null;
|
||||
minLeaseYears: number | null;
|
||||
maxLeaseYears: number | null;
|
||||
|
||||
@@ -37,9 +37,12 @@ export interface IndustrialParkListItem {
|
||||
occupancyRate: number;
|
||||
remainingAreaHa: number;
|
||||
tenantCount: number;
|
||||
landRentUsdM2Year: number | null;
|
||||
rbfRentUsdM2Month: number | null;
|
||||
rbwRentUsdM2Month: number | null;
|
||||
/** Decimal(18,4) serialised as string — use parseFloat() for arithmetic. */
|
||||
landRentUsdM2Year: string | null;
|
||||
/** Decimal(18,4) serialised as string — use parseFloat() for arithmetic. */
|
||||
rbfRentUsdM2Month: string | null;
|
||||
/** Decimal(18,4) serialised as string — use parseFloat() for arithmetic. */
|
||||
rbwRentUsdM2Month: string | null;
|
||||
targetIndustries: string[];
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
@@ -66,10 +69,14 @@ export interface IndustrialParkDetailData {
|
||||
remainingAreaHa: number;
|
||||
tenantCount: number;
|
||||
establishedYear: number | null;
|
||||
landRentUsdM2Year: number | null;
|
||||
rbfRentUsdM2Month: number | null;
|
||||
rbwRentUsdM2Month: number | null;
|
||||
managementFeeUsd: number | null;
|
||||
/** Decimal(18,4) serialised as string — use parseFloat() for arithmetic. */
|
||||
landRentUsdM2Year: string | null;
|
||||
/** Decimal(18,4) serialised as string — use parseFloat() for arithmetic. */
|
||||
rbfRentUsdM2Month: string | null;
|
||||
/** Decimal(18,4) serialised as string — use parseFloat() for arithmetic. */
|
||||
rbwRentUsdM2Month: string | null;
|
||||
/** Decimal(18,4) serialised as string — use parseFloat() for arithmetic. */
|
||||
managementFeeUsd: string | null;
|
||||
infrastructure: Record<string, unknown> | null;
|
||||
connectivity: Record<string, unknown> | null;
|
||||
incentives: Record<string, unknown> | null;
|
||||
@@ -100,10 +107,12 @@ export interface IndustrialParkStatsData {
|
||||
export interface IndustrialMarketData {
|
||||
totalParks: number;
|
||||
avgOccupancyRate: number;
|
||||
avgLandRentUsdM2: number | null;
|
||||
avgRbfRentUsdM2: number | null;
|
||||
rentByRegion: { region: string; avgLandRent: number | null; avgRbfRent: number | null; parkCount: number }[];
|
||||
rentByProvince: { province: string; avgLandRent: number | null; avgRbfRent: number | null; parkCount: number }[];
|
||||
/** AVG(numeric) serialised as string by PostgreSQL. */
|
||||
avgLandRentUsdM2: string | null;
|
||||
/** AVG(numeric) serialised as string by PostgreSQL. */
|
||||
avgRbfRentUsdM2: string | null;
|
||||
rentByRegion: { region: string; avgLandRent: string | null; avgRbfRent: string | null; parkCount: number }[];
|
||||
rentByProvince: { province: string; avgLandRent: string | null; avgRbfRent: string | null; parkCount: number }[];
|
||||
}
|
||||
|
||||
export interface IIndustrialParkRepository {
|
||||
|
||||
@@ -196,10 +196,10 @@ export class PrismaIndustrialListingRepository implements IIndustrialListingRepo
|
||||
hasMezzanine: row.hasMezzanine,
|
||||
hasOfficeArea: row.hasOfficeArea,
|
||||
officeAreaM2: row.officeAreaM2,
|
||||
priceUsdM2: row.priceUsdM2,
|
||||
priceUsdM2: row.priceUsdM2 != null ? parseFloat(row.priceUsdM2 as unknown as string) : null,
|
||||
pricingUnit: row.pricingUnit,
|
||||
totalLeasePrice: row.totalLeasePrice,
|
||||
managementFee: row.managementFee,
|
||||
totalLeasePrice: row.totalLeasePrice != null ? parseFloat(row.totalLeasePrice as unknown as string) : null,
|
||||
managementFee: row.managementFee != null ? parseFloat(row.managementFee as unknown as string) : null,
|
||||
depositMonths: row.depositMonths,
|
||||
minLeaseYears: row.minLeaseYears,
|
||||
maxLeaseYears: row.maxLeaseYears,
|
||||
@@ -299,10 +299,10 @@ interface RawListing {
|
||||
hasMezzanine: boolean;
|
||||
hasOfficeArea: boolean;
|
||||
officeAreaM2: number | null;
|
||||
priceUsdM2: number | null;
|
||||
priceUsdM2: string | null;
|
||||
pricingUnit: string | null;
|
||||
totalLeasePrice: number | null;
|
||||
managementFee: number | null;
|
||||
totalLeasePrice: string | null;
|
||||
managementFee: string | null;
|
||||
depositMonths: number | null;
|
||||
minLeaseYears: number | null;
|
||||
maxLeaseYears: number | null;
|
||||
@@ -327,7 +327,7 @@ interface RawListingListItem {
|
||||
status: string;
|
||||
title: string;
|
||||
areaM2: number;
|
||||
priceUsdM2: number | null;
|
||||
priceUsdM2: string | null;
|
||||
pricingUnit: string | null;
|
||||
ceilingHeightM: number | null;
|
||||
hasMezzanine: boolean;
|
||||
|
||||
@@ -242,7 +242,7 @@ export class PrismaIndustrialParkRepository implements IIndustrialParkRepository
|
||||
}
|
||||
|
||||
async getMarketData(): Promise<IndustrialMarketData> {
|
||||
const [overall] = await this.prisma.$queryRaw<[{ totalParks: bigint; avgOccupancy: number; avgLandRent: number | null; avgRbfRent: number | null }]>`
|
||||
const [overall] = await this.prisma.$queryRaw<[{ totalParks: bigint; avgOccupancy: number; avgLandRent: string | null; avgRbfRent: string | null }]>`
|
||||
SELECT COUNT(*)::bigint as "totalParks",
|
||||
AVG("occupancyRate") as "avgOccupancy",
|
||||
AVG("landRentUsdM2Year") as "avgLandRent",
|
||||
@@ -250,14 +250,14 @@ export class PrismaIndustrialParkRepository implements IIndustrialParkRepository
|
||||
FROM "IndustrialPark" WHERE status = 'OPERATIONAL' OR status = 'FULL'
|
||||
`;
|
||||
|
||||
const rentByRegion = await this.prisma.$queryRaw<{ region: string; avgLandRent: number | null; avgRbfRent: number | null; parkCount: bigint }[]>`
|
||||
const rentByRegion = await this.prisma.$queryRaw<{ region: string; avgLandRent: string | null; avgRbfRent: string | null; parkCount: bigint }[]>`
|
||||
SELECT region::text, AVG("landRentUsdM2Year") as "avgLandRent",
|
||||
AVG("rbfRentUsdM2Month") as "avgRbfRent", COUNT(*)::bigint as "parkCount"
|
||||
FROM "IndustrialPark" WHERE status IN ('OPERATIONAL', 'FULL')
|
||||
GROUP BY region ORDER BY "avgLandRent" DESC NULLS LAST
|
||||
`;
|
||||
|
||||
const rentByProvince = await this.prisma.$queryRaw<{ province: string; avgLandRent: number | null; avgRbfRent: number | null; parkCount: bigint }[]>`
|
||||
const rentByProvince = await this.prisma.$queryRaw<{ province: string; avgLandRent: string | null; avgRbfRent: string | null; parkCount: bigint }[]>`
|
||||
SELECT province, AVG("landRentUsdM2Year") as "avgLandRent",
|
||||
AVG("rbfRentUsdM2Month") as "avgRbfRent", COUNT(*)::bigint as "parkCount"
|
||||
FROM "IndustrialPark" WHERE status IN ('OPERATIONAL', 'FULL')
|
||||
@@ -296,10 +296,10 @@ export class PrismaIndustrialParkRepository implements IIndustrialParkRepository
|
||||
remainingAreaHa: row.remainingAreaHa,
|
||||
tenantCount: row.tenantCount,
|
||||
establishedYear: row.establishedYear,
|
||||
landRentUsdM2Year: row.landRentUsdM2Year,
|
||||
rbfRentUsdM2Month: row.rbfRentUsdM2Month,
|
||||
rbwRentUsdM2Month: row.rbwRentUsdM2Month,
|
||||
managementFeeUsd: row.managementFeeUsd,
|
||||
landRentUsdM2Year: row.landRentUsdM2Year != null ? parseFloat(row.landRentUsdM2Year) : null,
|
||||
rbfRentUsdM2Month: row.rbfRentUsdM2Month != null ? parseFloat(row.rbfRentUsdM2Month) : null,
|
||||
rbwRentUsdM2Month: row.rbwRentUsdM2Month != null ? parseFloat(row.rbwRentUsdM2Month) : null,
|
||||
managementFeeUsd: row.managementFeeUsd != null ? parseFloat(row.managementFeeUsd) : null,
|
||||
infrastructure: row.infrastructure as Record<string, unknown> | null,
|
||||
connectivity: row.connectivity as Record<string, unknown> | null,
|
||||
incentives: row.incentives as Record<string, unknown> | null,
|
||||
@@ -407,10 +407,10 @@ interface RawPark {
|
||||
remainingAreaHa: number;
|
||||
tenantCount: number;
|
||||
establishedYear: number | null;
|
||||
landRentUsdM2Year: number | null;
|
||||
rbfRentUsdM2Month: number | null;
|
||||
rbwRentUsdM2Month: number | null;
|
||||
managementFeeUsd: number | null;
|
||||
landRentUsdM2Year: string | null;
|
||||
rbfRentUsdM2Month: string | null;
|
||||
rbwRentUsdM2Month: string | null;
|
||||
managementFeeUsd: string | null;
|
||||
infrastructure: Prisma.JsonValue;
|
||||
connectivity: Prisma.JsonValue;
|
||||
incentives: Prisma.JsonValue;
|
||||
|
||||
@@ -21,23 +21,26 @@ function createListing(
|
||||
describe('FeatureListingHandler', () => {
|
||||
let handler: FeatureListingHandler;
|
||||
let mockListingRepo: Pick<IListingRepository, 'findById'>;
|
||||
let mockCommandBus: { execute: ReturnType<typeof vi.fn> };
|
||||
let mockPaymentInitiator: { initiate: ReturnType<typeof vi.fn> };
|
||||
let mockEventBus: { publish: ReturnType<typeof vi.fn> };
|
||||
let mockLogger: { log: ReturnType<typeof vi.fn>; error: ReturnType<typeof vi.fn> };
|
||||
|
||||
beforeEach(() => {
|
||||
mockListingRepo = { findById: vi.fn() };
|
||||
mockCommandBus = {
|
||||
execute: vi.fn().mockResolvedValue({
|
||||
mockPaymentInitiator = {
|
||||
initiate: vi.fn().mockResolvedValue({
|
||||
paymentId: 'pay-1',
|
||||
paymentUrl: 'https://pay.example.com/checkout',
|
||||
providerTxId: 'tx-1',
|
||||
}),
|
||||
};
|
||||
mockEventBus = { publish: vi.fn() };
|
||||
mockLogger = { log: vi.fn(), error: vi.fn() };
|
||||
|
||||
handler = new FeatureListingHandler(
|
||||
mockListingRepo as any,
|
||||
mockCommandBus as any,
|
||||
mockPaymentInitiator as any,
|
||||
mockEventBus as any,
|
||||
mockLogger as any,
|
||||
);
|
||||
});
|
||||
@@ -56,7 +59,9 @@ describe('FeatureListingHandler', () => {
|
||||
expect(result.paymentUrl).toBe('https://pay.example.com/checkout');
|
||||
expect(result.package_).toBe('7_days');
|
||||
expect(result.priceVND).toBe('199000');
|
||||
expect(mockCommandBus.execute).toHaveBeenCalledTimes(1);
|
||||
expect(mockPaymentInitiator.initiate).toHaveBeenCalledTimes(1);
|
||||
expect(mockEventBus.publish).toHaveBeenCalledTimes(1);
|
||||
expect(mockEventBus.publish.mock.calls[0]?.[0]?.eventName).toBe('listing.featured-payment-requested');
|
||||
});
|
||||
|
||||
it('allows the assigned agent to feature the listing', async () => {
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
||||
import { CommandHandler, CommandBus, type ICommandHandler } from '@nestjs/cqrs';
|
||||
import { CreatePaymentCommand, type CreatePaymentResult } from '@modules/payments';
|
||||
import { CommandHandler, EventBus, type ICommandHandler } from '@nestjs/cqrs';
|
||||
import {
|
||||
DomainException,
|
||||
ForbiddenException,
|
||||
NotFoundException,
|
||||
ValidationException,
|
||||
type IPaymentInitiator,
|
||||
LoggerService,
|
||||
NotFoundException,
|
||||
PAYMENT_INITIATOR,
|
||||
ValidationException,
|
||||
} from '@modules/shared';
|
||||
import { FeaturedListingPaymentRequestedEvent } from '../../../domain/events/featured-listing-payment-requested.event';
|
||||
import { LISTING_REPOSITORY, type IListingRepository } from '../../../domain/repositories/listing.repository';
|
||||
import { type FeaturePackage, FeatureListingCommand } from './feature-listing.command';
|
||||
|
||||
@@ -29,7 +31,8 @@ export interface FeatureListingResult {
|
||||
export class FeatureListingHandler implements ICommandHandler<FeatureListingCommand> {
|
||||
constructor(
|
||||
@Inject(LISTING_REPOSITORY) private readonly listingRepo: IListingRepository,
|
||||
private readonly commandBus: CommandBus,
|
||||
@Inject(PAYMENT_INITIATOR) private readonly paymentInitiator: IPaymentInitiator,
|
||||
private readonly eventBus: EventBus,
|
||||
private readonly logger: LoggerService,
|
||||
) {}
|
||||
|
||||
@@ -55,20 +58,33 @@ export class FeatureListingHandler implements ICommandHandler<FeatureListingComm
|
||||
throw new ValidationException('Gói không hợp lệ', { package: command.package_ });
|
||||
}
|
||||
|
||||
const paymentResult: CreatePaymentResult = await this.commandBus.execute(
|
||||
new CreatePaymentCommand(
|
||||
command.userId,
|
||||
command.provider,
|
||||
'FEATURED_LISTING',
|
||||
price,
|
||||
`Đẩy tin nổi bật ${command.package_.replace('_', ' ')} - Listing ${command.listingId}`,
|
||||
command.returnUrl,
|
||||
command.ipAddress,
|
||||
// Emit domain event BEFORE payment initiation so audit/analytics listeners
|
||||
// see the request even if the downstream payment gateway fails.
|
||||
this.eventBus.publish(
|
||||
new FeaturedListingPaymentRequestedEvent(
|
||||
command.listingId,
|
||||
`feature_${command.listingId}_${Date.now()}`,
|
||||
command.userId,
|
||||
command.package_,
|
||||
price,
|
||||
command.provider,
|
||||
),
|
||||
);
|
||||
|
||||
// Cross-module call goes through the shared `IPaymentInitiator` port
|
||||
// (payments registers the adapter). No direct dependency on payments
|
||||
// application-layer commands — see A-10.
|
||||
const paymentResult = await this.paymentInitiator.initiate({
|
||||
userId: command.userId,
|
||||
provider: command.provider,
|
||||
type: 'FEATURED_LISTING',
|
||||
amountVND: price,
|
||||
description: `Đẩy tin nổi bật ${command.package_.replace('_', ' ')} - Listing ${command.listingId}`,
|
||||
returnUrl: command.returnUrl,
|
||||
ipAddress: command.ipAddress,
|
||||
transactionId: command.listingId,
|
||||
idempotencyKey: `feature_${command.listingId}_${Date.now()}`,
|
||||
});
|
||||
|
||||
this.logger.log(
|
||||
`Featured listing payment created: listing=${command.listingId}, package=${command.package_}, payment=${paymentResult.paymentId}`,
|
||||
'FeatureListingHandler',
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { type DomainEvent } from '@modules/shared';
|
||||
|
||||
/**
|
||||
* Emitted when a user requests to feature (boost) a listing. Carries enough
|
||||
* context for downstream listeners (analytics, audit, anti-fraud) to react
|
||||
* without coupling them to the listings module.
|
||||
*
|
||||
* The actual payment is initiated synchronously through the
|
||||
* `IPaymentInitiator` port — see A-10. This event is the audit trail.
|
||||
*/
|
||||
export class FeaturedListingPaymentRequestedEvent implements DomainEvent {
|
||||
readonly eventName = 'listing.featured-payment-requested';
|
||||
readonly occurredAt: Date;
|
||||
|
||||
constructor(
|
||||
/** The listing being featured (used as `aggregateId`). */
|
||||
public readonly aggregateId: string,
|
||||
public readonly userId: string,
|
||||
public readonly package_: '3_days' | '7_days' | '30_days',
|
||||
public readonly priceVND: bigint,
|
||||
public readonly provider: string,
|
||||
) {
|
||||
this.occurredAt = new Date();
|
||||
}
|
||||
}
|
||||
@@ -3,4 +3,6 @@ export { ListingApprovedEvent } from './listing-approved.event';
|
||||
export { ListingPriceChangedEvent } from './listing-price-changed.event';
|
||||
export { ListingSoldEvent } from './listing-sold.event';
|
||||
export { ListingFeaturedExpiredEvent } from './listing-featured-expired.event';
|
||||
export { ListingExpiringEvent } from './listing-expiring.event';
|
||||
export { ListingOwnershipTransferredEvent } from './listing-ownership-transferred.event';
|
||||
export { FeaturedListingPaymentRequestedEvent } from './featured-listing-payment-requested.event';
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { type DomainEvent } from '@modules/shared';
|
||||
|
||||
/**
|
||||
* Fired by the daily expiry-warning cron for each listing that expires
|
||||
* within the next 3 days and has not yet received a warning notification.
|
||||
*/
|
||||
export class ListingExpiringEvent implements DomainEvent {
|
||||
readonly eventName = 'listing.expiring';
|
||||
readonly occurredAt = new Date();
|
||||
|
||||
constructor(
|
||||
/** Listing ID */
|
||||
public readonly aggregateId: string,
|
||||
/** ID of the seller who owns the listing */
|
||||
public readonly sellerId: string,
|
||||
/** When the listing expires */
|
||||
public readonly expiresAt: Date,
|
||||
) {}
|
||||
}
|
||||
@@ -21,4 +21,5 @@ export { ListingSoldEvent } from './domain/events/listing-sold.event';
|
||||
export { ListingStatusChangedEvent } from './domain/events/listing-status-changed.event';
|
||||
export { ListingOwnershipTransferredEvent } from './domain/events/listing-ownership-transferred.event';
|
||||
export { ListingFeaturedExpiredEvent } from './domain/events/listing-featured-expired.event';
|
||||
export { ListingExpiringEvent } from './domain/events/listing-expiring.event';
|
||||
export { Price } from './domain/value-objects/price.vo';
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { EventBus } from '@nestjs/cqrs';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { ListingStatus } from '@prisma/client';
|
||||
import { PrismaService, LoggerService } from '@modules/shared';
|
||||
import { ListingExpiringEvent } from '../../domain/events/listing-expiring.event';
|
||||
|
||||
/**
|
||||
* Daily cron that fires a 3-day expiry warning for active listings.
|
||||
*
|
||||
* Design notes:
|
||||
* - Runs once per day at 08:00 (Vietnam time, UTC+7 → 01:00 UTC).
|
||||
* - Queries listings whose `expiresAt` falls within the next 1–3 days
|
||||
* AND whose `expiryNotifiedAt` is still NULL (idempotent guard).
|
||||
* - Uses a single atomic SQL UPDATE … RETURNING so concurrent instances
|
||||
* cannot double-fire: only the first writer will satisfy the NULL predicate.
|
||||
* - Publishes `ListingExpiringEvent` per affected listing so the
|
||||
* notifications module can dispatch Zalo OA / email messages.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ListingExpiryCronService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly eventBus: EventBus,
|
||||
private readonly logger: LoggerService,
|
||||
) {}
|
||||
|
||||
@Cron('0 1 * * *', { name: 'listing-expiry-warning', timeZone: 'UTC' })
|
||||
async notifyExpiringListings(): Promise<void> {
|
||||
const now = new Date();
|
||||
const in3Days = new Date(now.getTime() + 3 * 24 * 60 * 60 * 1000);
|
||||
|
||||
try {
|
||||
// Atomically claim rows: set expiryNotifiedAt so concurrent instances skip them.
|
||||
const expiring = await this.prisma.$queryRaw<
|
||||
Array<{ id: string; sellerId: string; expiresAt: Date }>
|
||||
>`
|
||||
UPDATE "Listing"
|
||||
SET "expiryNotifiedAt" = NOW(),
|
||||
"updatedAt" = NOW()
|
||||
WHERE status = ${ListingStatus.ACTIVE}::"ListingStatus"
|
||||
AND "expiresAt" IS NOT NULL
|
||||
AND "expiresAt" > ${now}
|
||||
AND "expiresAt" <= ${in3Days}
|
||||
AND "expiryNotifiedAt" IS NULL
|
||||
RETURNING id, "sellerId", "expiresAt"
|
||||
`;
|
||||
|
||||
if (expiring.length === 0) {
|
||||
this.logger.debug(
|
||||
'No listings expiring in the next 3 days — nothing to notify',
|
||||
'ListingExpiryCronService',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const row of expiring) {
|
||||
this.eventBus.publish(
|
||||
new ListingExpiringEvent(row.id, row.sellerId, row.expiresAt),
|
||||
);
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`Sent expiry-warning events for ${expiring.length} listing(s) at ${now.toISOString()}`,
|
||||
'ListingExpiryCronService',
|
||||
);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Failed to send listing expiry warnings: ${(err as Error).message}`,
|
||||
(err as Error).stack,
|
||||
'ListingExpiryCronService',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { DeleteListingHandler } from './application/commands/delete-listing/dele
|
||||
import { FeatureListingHandler } from './application/commands/feature-listing/feature-listing.handler';
|
||||
import { ModerateListingHandler } from './application/commands/moderate-listing/moderate-listing.handler';
|
||||
import { PromoteFeaturedListingHandler } from './application/commands/promote-featured-listing/promote-featured-listing.handler';
|
||||
import { ReportListingHandler } from './application/commands/report-listing/report-listing.handler';
|
||||
import { UpdateListingHandler } from './application/commands/update-listing/update-listing.handler';
|
||||
import { UpdateListingStatusHandler } from './application/commands/update-listing-status/update-listing-status.handler';
|
||||
import { UploadMediaHandler } from './application/commands/upload-media/upload-media.handler';
|
||||
@@ -27,6 +28,7 @@ import { DUPLICATE_DETECTOR } from './domain/services/duplicate-detector';
|
||||
import { ModerationService } from './domain/services/moderation.service';
|
||||
import { PRICE_VALIDATOR } from './domain/services/price-validator';
|
||||
import { FeaturedListingExpiryCronService } from './infrastructure/cron/featured-listing-expiry-cron.service';
|
||||
import { ListingExpiryCronService } from './infrastructure/cron/listing-expiry-cron.service';
|
||||
import { PrismaListingRepository } from './infrastructure/repositories/prisma-listing.repository';
|
||||
import { PrismaPropertyRepository } from './infrastructure/repositories/prisma-property.repository';
|
||||
import { MEDIA_STORAGE_SERVICE, MinioMediaStorageService } from './infrastructure/services/media-storage.service';
|
||||
@@ -45,6 +47,7 @@ const CommandHandlers = [
|
||||
ModerateListingHandler,
|
||||
DeleteListingHandler,
|
||||
BulkUpdateListingsHandler,
|
||||
ReportListingHandler,
|
||||
];
|
||||
|
||||
const QueryHandlers = [
|
||||
@@ -88,6 +91,7 @@ const EventHandlers = [
|
||||
|
||||
// Cron
|
||||
FeaturedListingExpiryCronService,
|
||||
ListingExpiryCronService,
|
||||
|
||||
// Guards (per-route)
|
||||
FeatureListingThrottlerGuard,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { CommandBus } from '@nestjs/cqrs';
|
||||
import { OnEvent } from '@nestjs/event-emitter';
|
||||
import { LoggerService, PrismaService } from '@modules/shared';
|
||||
import { ListingExpiringEvent } from '@modules/listings';
|
||||
import { SendNotificationCommand } from '../commands/send-notification/send-notification.command';
|
||||
|
||||
/**
|
||||
* Handles `listing.expiring` events published by the daily expiry-warning cron.
|
||||
*
|
||||
* Sends both an email and a Zalo OA notification to the seller so they can
|
||||
* renew or extend their listing before it expires.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ListingExpiringListener {
|
||||
constructor(
|
||||
private readonly commandBus: CommandBus,
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly logger: LoggerService,
|
||||
) {}
|
||||
|
||||
@OnEvent('listing.expiring', { async: true })
|
||||
async handle(event: ListingExpiringEvent): Promise<void> {
|
||||
this.logger.log(
|
||||
`Handling listing.expiring for listing ${event.aggregateId}`,
|
||||
'ListingExpiringListener',
|
||||
);
|
||||
|
||||
const listing = await this.prisma.listing.findUnique({
|
||||
where: { id: event.aggregateId },
|
||||
include: {
|
||||
property: { select: { title: true } },
|
||||
seller: { select: { id: true, email: true, phone: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!listing) return;
|
||||
|
||||
const templateData = {
|
||||
listingTitle: listing.property.title,
|
||||
expiresAt: event.expiresAt.toISOString(),
|
||||
daysRemaining: Math.ceil(
|
||||
(event.expiresAt.getTime() - Date.now()) / (1000 * 60 * 60 * 24),
|
||||
),
|
||||
};
|
||||
|
||||
const notifications: Promise<unknown>[] = [];
|
||||
|
||||
// Email notification
|
||||
if (listing.seller.email) {
|
||||
notifications.push(
|
||||
this.commandBus.execute(
|
||||
new SendNotificationCommand(
|
||||
listing.seller.id,
|
||||
'EMAIL',
|
||||
'listing.expiring',
|
||||
templateData,
|
||||
listing.seller.email,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Zalo OA notification (phone as recipient address)
|
||||
if (listing.seller.phone) {
|
||||
notifications.push(
|
||||
this.commandBus.execute(
|
||||
new SendNotificationCommand(
|
||||
listing.seller.id,
|
||||
'ZALO_OA',
|
||||
'listing.expiring',
|
||||
templateData,
|
||||
listing.seller.phone,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
await Promise.allSettled(notifications);
|
||||
}
|
||||
}
|
||||
@@ -81,14 +81,15 @@ describe('TemplateService', () => {
|
||||
expect(result.body).toContain('/listings/2');
|
||||
});
|
||||
|
||||
it('getTemplateKeys returns all 17 template keys', () => {
|
||||
it('getTemplateKeys returns all 19 template keys', () => {
|
||||
const keys = service.getTemplateKeys();
|
||||
|
||||
expect(keys).toHaveLength(17);
|
||||
expect(keys).toHaveLength(19);
|
||||
expect(keys).toContain('user.registered');
|
||||
expect(keys).toContain('agent.verified');
|
||||
expect(keys).toContain('listing.approved');
|
||||
expect(keys).toContain('listing.rejected');
|
||||
expect(keys).toContain('listing.expiring');
|
||||
expect(keys).toContain('inquiry.received');
|
||||
expect(keys).toContain('quota.exceeded');
|
||||
expect(keys).toContain('password.reset');
|
||||
@@ -98,6 +99,7 @@ describe('TemplateService', () => {
|
||||
expect(keys).toContain('saved_search_digest');
|
||||
expect(keys).toContain('user.email_change_otp');
|
||||
expect(keys).toContain('user.phone_change_otp');
|
||||
expect(keys).toContain('user.phone_login_otp');
|
||||
expect(keys).toContain('inquiry.reply');
|
||||
expect(keys).toContain('listing.price_drop');
|
||||
expect(keys).toContain('subscription.renewal');
|
||||
|
||||
@@ -30,6 +30,13 @@ const TEMPLATES: Record<string, TemplateDefinition> = {
|
||||
subject: 'Tin đăng đã được duyệt',
|
||||
body: `<h1>Tin đăng được phê duyệt!</h1>
|
||||
<p>Tin đăng <strong>{{listingTitle}}</strong> của bạn đã được duyệt và hiển thị trên GoodGo.</p>
|
||||
<p>Trân trọng,<br/>Đội ngũ GoodGo</p>`,
|
||||
},
|
||||
'listing.expiring': {
|
||||
subject: 'Tin đăng sắp hết hạn: {{listingTitle}}',
|
||||
body: `<h1>Tin đăng sắp hết hạn</h1>
|
||||
<p>Tin đăng <strong>{{listingTitle}}</strong> của bạn sẽ hết hạn trong <strong>{{daysRemaining}} ngày</strong> ({{expiresAt}}).</p>
|
||||
<p>Vui lòng gia hạn tin đăng để tiếp tục hiển thị trên GoodGo.</p>
|
||||
<p>Trân trọng,<br/>Đội ngũ GoodGo</p>`,
|
||||
},
|
||||
'inquiry.received': {
|
||||
@@ -90,6 +97,10 @@ const TEMPLATES: Record<string, TemplateDefinition> = {
|
||||
subject: 'Xác nhận thay đổi số điện thoại — GoodGo',
|
||||
body: `Mã xác nhận thay đổi số điện thoại GoodGo: {{otpCode}}. Mã có hiệu lực trong 10 phút. Nếu bạn không yêu cầu, hãy bỏ qua tin nhắn này.`,
|
||||
},
|
||||
'user.phone_login_otp': {
|
||||
subject: 'Mã đăng nhập GoodGo',
|
||||
body: `Mã đăng nhập GoodGo: {{otpCode}}. Mã có hiệu lực trong 10 phút. Tuyệt đối không chia sẻ mã này với bất kỳ ai.`,
|
||||
},
|
||||
'saved_search_alert': {
|
||||
subject: 'Tin mới phù hợp tìm kiếm "{{searchName}}"',
|
||||
body: `<h1>Xin chào {{userName}}!</h1>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { AgentVerifiedListener } from './application/listeners/agent-verified.li
|
||||
import { EmailChangeRequestedListener } from './application/listeners/email-change-requested.listener';
|
||||
import { InquiryReceivedListener } from './application/listeners/inquiry-received.listener';
|
||||
import { ListingApprovedListener } from './application/listeners/listing-approved.listener';
|
||||
import { ListingExpiringListener } from './application/listeners/listing-expiring.listener';
|
||||
import { ListingRejectedListener } from './application/listeners/listing-rejected.listener';
|
||||
import { ListingSoldListener } from './application/listeners/listing-sold.listener';
|
||||
import { PasswordResetRequestedListener } from './application/listeners/password-reset-requested.listener';
|
||||
@@ -14,6 +15,7 @@ import { PaymentCompletedListener } from './application/listeners/payment-comple
|
||||
import { PaymentFailedListener } from './application/listeners/payment-failed.listener';
|
||||
import { PaymentRefundedListener } from './application/listeners/payment-refunded.listener';
|
||||
import { PhoneChangeRequestedListener } from './application/listeners/phone-change-requested.listener';
|
||||
import { PhoneLoginOtpRequestedListener } from './application/listeners/phone-login-otp-requested.listener';
|
||||
import { QuotaExceededListener } from './application/listeners/quota-exceeded.listener';
|
||||
import {
|
||||
ResidentialInquiryReplyListener,
|
||||
@@ -48,6 +50,7 @@ const EventListeners = [
|
||||
AgentVerifiedListener,
|
||||
QuotaExceededListener,
|
||||
ListingApprovedListener,
|
||||
ListingExpiringListener,
|
||||
ListingRejectedListener,
|
||||
PaymentCompletedListener,
|
||||
PaymentFailedListener,
|
||||
@@ -60,6 +63,7 @@ const EventListeners = [
|
||||
UserKycUpdatedListener,
|
||||
EmailChangeRequestedListener,
|
||||
PhoneChangeRequestedListener,
|
||||
PhoneLoginOtpRequestedListener,
|
||||
PasswordResetRequestedListener,
|
||||
ResidentialPriceDropListener,
|
||||
ResidentialNewListingInProjectListener,
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- NestJS DI requires value imports
|
||||
import { CommandBus } from '@nestjs/cqrs';
|
||||
import {
|
||||
type IPaymentInitiator,
|
||||
type InitiatePaymentInput,
|
||||
type InitiatePaymentResult,
|
||||
} from '@modules/shared';
|
||||
import { CreatePaymentCommand } from '../../application/commands/create-payment/create-payment.command';
|
||||
import { type CreatePaymentResult } from '../../application/commands/create-payment/create-payment.handler';
|
||||
|
||||
/**
|
||||
* Adapter exposing the payments module through the shared `IPaymentInitiator`
|
||||
* port. Other modules (e.g. listings) depend on the port instead of importing
|
||||
* application-layer commands from payments — see A-10.
|
||||
*/
|
||||
@Injectable()
|
||||
export class CommandBusPaymentInitiator implements IPaymentInitiator {
|
||||
constructor(private readonly commandBus: CommandBus) {}
|
||||
|
||||
async initiate(input: InitiatePaymentInput): Promise<InitiatePaymentResult> {
|
||||
const result: CreatePaymentResult = await this.commandBus.execute(
|
||||
new CreatePaymentCommand(
|
||||
input.userId,
|
||||
input.provider,
|
||||
input.type,
|
||||
input.amountVND,
|
||||
input.description,
|
||||
input.returnUrl,
|
||||
input.ipAddress,
|
||||
input.transactionId,
|
||||
input.idempotencyKey,
|
||||
),
|
||||
);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { CqrsModule } from '@nestjs/cqrs';
|
||||
import { PAYMENT_INITIATOR } from '@modules/shared';
|
||||
import { CancelOrderHandler } from './application/commands/cancel-order/cancel-order.handler';
|
||||
import { ConfirmBankTransferHandler } from './application/commands/confirm-bank-transfer/confirm-bank-transfer.handler';
|
||||
import { CreateOrderHandler } from './application/commands/create-order/create-order.handler';
|
||||
@@ -17,6 +18,7 @@ import { PAYMENT_REPOSITORY } from './domain/repositories/payment.repository';
|
||||
import { PrismaEscrowRepository } from './infrastructure/repositories/prisma-escrow.repository';
|
||||
import { PrismaOrderRepository } from './infrastructure/repositories/prisma-order.repository';
|
||||
import { PrismaPaymentRepository } from './infrastructure/repositories/prisma-payment.repository';
|
||||
import { CommandBusPaymentInitiator } from './infrastructure/adapters/command-bus-payment-initiator.adapter';
|
||||
import { BankTransferService } from './infrastructure/services/bank-transfer.service';
|
||||
import { MomoService } from './infrastructure/services/momo.service';
|
||||
import { PaymentGatewayFactory } from './infrastructure/services/payment-gateway.factory';
|
||||
@@ -62,7 +64,10 @@ const QueryHandlers = [
|
||||
// CQRS
|
||||
...CommandHandlers,
|
||||
...QueryHandlers,
|
||||
|
||||
// Cross-module port adapter
|
||||
{ provide: PAYMENT_INITIATOR, useClass: CommandBusPaymentInitiator },
|
||||
],
|
||||
exports: [ESCROW_REPOSITORY, ORDER_REPOSITORY, PAYMENT_REPOSITORY, PAYMENT_GATEWAY_FACTORY],
|
||||
exports: [ESCROW_REPOSITORY, ORDER_REPOSITORY, PAYMENT_REPOSITORY, PAYMENT_GATEWAY_FACTORY, PAYMENT_INITIATOR],
|
||||
})
|
||||
export class PaymentsModule {}
|
||||
|
||||
@@ -4,9 +4,8 @@ import { CreateSavedSearchHandler } from '../commands/create-saved-search/create
|
||||
describe('CreateSavedSearchHandler', () => {
|
||||
let handler: CreateSavedSearchHandler;
|
||||
let mockPrisma: any;
|
||||
let mockQueryBus: { execute: ReturnType<typeof vi.fn> };
|
||||
let mockCommandBus: { execute: ReturnType<typeof vi.fn> };
|
||||
let mockLogger: { log: ReturnType<typeof vi.fn>; warn: ReturnType<typeof vi.fn> };
|
||||
let mockEventBus: { publish: ReturnType<typeof vi.fn> };
|
||||
let mockLogger: { log: ReturnType<typeof vi.fn>; warn: ReturnType<typeof vi.fn>; error: ReturnType<typeof vi.fn> };
|
||||
|
||||
beforeEach(() => {
|
||||
mockPrisma = {
|
||||
@@ -16,27 +15,17 @@ describe('CreateSavedSearchHandler', () => {
|
||||
count: vi.fn(),
|
||||
},
|
||||
};
|
||||
mockQueryBus = { execute: vi.fn() };
|
||||
mockCommandBus = { execute: vi.fn() };
|
||||
mockLogger = { log: vi.fn(), warn: vi.fn() };
|
||||
mockEventBus = { publish: vi.fn() };
|
||||
mockLogger = { log: vi.fn(), warn: vi.fn(), error: vi.fn() };
|
||||
|
||||
handler = new CreateSavedSearchHandler(
|
||||
mockPrisma,
|
||||
mockQueryBus as any,
|
||||
mockCommandBus as any,
|
||||
mockEventBus as any,
|
||||
mockLogger as any,
|
||||
);
|
||||
});
|
||||
|
||||
it('creates a saved search successfully', async () => {
|
||||
mockQueryBus.execute.mockResolvedValue({
|
||||
metric: 'searches_saved',
|
||||
limit: 10,
|
||||
used: 2,
|
||||
remaining: 8,
|
||||
allowed: true,
|
||||
});
|
||||
|
||||
it('creates a saved search successfully and publishes domain event', async () => {
|
||||
const now = new Date();
|
||||
mockPrisma.savedSearch.create.mockResolvedValue({
|
||||
id: 'saved-1',
|
||||
@@ -48,8 +37,6 @@ describe('CreateSavedSearchHandler', () => {
|
||||
createdAt: now,
|
||||
});
|
||||
|
||||
mockCommandBus.execute.mockResolvedValue({ usageRecordId: 'usage-1' });
|
||||
|
||||
const command = new CreateSavedSearchCommand(
|
||||
'user-1',
|
||||
'Chung cư Q7',
|
||||
@@ -61,7 +48,9 @@ describe('CreateSavedSearchHandler', () => {
|
||||
expect(result.name).toBe('Chung cư Q7');
|
||||
expect(result.alertEnabled).toBe(true);
|
||||
expect(mockPrisma.savedSearch.create).toHaveBeenCalledTimes(1);
|
||||
expect(mockCommandBus.execute).toHaveBeenCalledTimes(1); // Usage metering
|
||||
expect(mockEventBus.publish).toHaveBeenCalledTimes(1);
|
||||
expect(mockEventBus.publish.mock.calls[0]?.[0]?.eventName).toBe('saved-search.created');
|
||||
expect(mockEventBus.publish.mock.calls[0]?.[0]?.userId).toBe('user-1');
|
||||
});
|
||||
|
||||
it('throws when name is empty', async () => {
|
||||
@@ -74,49 +63,4 @@ describe('CreateSavedSearchHandler', () => {
|
||||
const command = new CreateSavedSearchCommand('user-1', longName, {}, true);
|
||||
await expect(handler.execute(command)).rejects.toThrow('Tên tìm kiếm không được vượt quá 100 ký tự');
|
||||
});
|
||||
|
||||
it('throws when quota is exceeded', async () => {
|
||||
mockQueryBus.execute.mockResolvedValue({
|
||||
metric: 'searches_saved',
|
||||
limit: 5,
|
||||
used: 5,
|
||||
remaining: 0,
|
||||
allowed: false,
|
||||
});
|
||||
|
||||
const command = new CreateSavedSearchCommand('user-1', 'Test', {}, true);
|
||||
await expect(handler.execute(command)).rejects.toThrow('giới hạn');
|
||||
});
|
||||
|
||||
it('continues even when usage metering fails', async () => {
|
||||
mockQueryBus.execute.mockResolvedValue({
|
||||
metric: 'searches_saved',
|
||||
limit: 10,
|
||||
used: 2,
|
||||
remaining: 8,
|
||||
allowed: true,
|
||||
});
|
||||
|
||||
const now = new Date();
|
||||
mockPrisma.savedSearch.create.mockResolvedValue({
|
||||
id: 'saved-1',
|
||||
userId: 'user-1',
|
||||
name: 'Test',
|
||||
filters: {},
|
||||
alertEnabled: true,
|
||||
lastAlertAt: null,
|
||||
createdAt: now,
|
||||
});
|
||||
|
||||
mockCommandBus.execute.mockRejectedValue(new Error('Metering failed'));
|
||||
|
||||
const command = new CreateSavedSearchCommand('user-1', 'Test', {}, true);
|
||||
const result = await handler.execute(command);
|
||||
|
||||
expect(result.id).toBe('saved-1');
|
||||
expect(mockLogger.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Usage metering failed'),
|
||||
'CreateSavedSearchHandler',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { InternalServerErrorException } from '@nestjs/common';
|
||||
import { CommandHandler, CommandBus, type ICommandHandler, QueryBus } from '@nestjs/cqrs';
|
||||
import { CommandHandler, EventBus, type ICommandHandler } from '@nestjs/cqrs';
|
||||
import { createId } from '@paralleldrive/cuid2';
|
||||
import { type SavedSearch, type Prisma } from '@prisma/client';
|
||||
import { DomainException, ValidationException, PrismaService, LoggerService } from '@modules/shared';
|
||||
import { CheckQuotaQuery, type QuotaCheckResult, MeterUsageCommand } from '@modules/subscriptions';
|
||||
import { SavedSearchCreatedEvent } from '../../../domain/events/saved-search-created.event';
|
||||
import { CreateSavedSearchCommand } from './create-saved-search.command';
|
||||
|
||||
export interface CreateSavedSearchResult {
|
||||
@@ -14,12 +14,18 @@ export interface CreateSavedSearchResult {
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Note: quota enforcement (`searches_saved` metric) lives at the controller
|
||||
* via `@RequireQuota('searches_saved')` + `QuotaGuard`. Usage metering
|
||||
* happens in subscriptions via the `SavedSearchCreatedEvent` listener.
|
||||
* This handler must NOT call `CheckQuotaQuery` or `MeterUsageCommand`
|
||||
* directly — see A-11.
|
||||
*/
|
||||
@CommandHandler(CreateSavedSearchCommand)
|
||||
export class CreateSavedSearchHandler implements ICommandHandler<CreateSavedSearchCommand> {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly queryBus: QueryBus,
|
||||
private readonly commandBus: CommandBus,
|
||||
private readonly eventBus: EventBus,
|
||||
private readonly logger: LoggerService,
|
||||
) {}
|
||||
|
||||
@@ -34,17 +40,6 @@ export class CreateSavedSearchHandler implements ICommandHandler<CreateSavedSear
|
||||
throw new ValidationException('Tên tìm kiếm không được vượt quá 100 ký tự');
|
||||
}
|
||||
|
||||
// Check quota
|
||||
const quotaResult: QuotaCheckResult = await this.queryBus.execute(
|
||||
new CheckQuotaQuery(command.userId, 'searches_saved'),
|
||||
);
|
||||
|
||||
if (!quotaResult.allowed) {
|
||||
throw new ValidationException(
|
||||
`Bạn đã đạt giới hạn ${quotaResult.limit} tìm kiếm đã lưu. Vui lòng nâng cấp gói để tiếp tục.`,
|
||||
);
|
||||
}
|
||||
|
||||
const id = createId();
|
||||
const savedSearch: SavedSearch = await this.prisma.savedSearch.create({
|
||||
data: {
|
||||
@@ -56,17 +51,8 @@ export class CreateSavedSearchHandler implements ICommandHandler<CreateSavedSear
|
||||
},
|
||||
});
|
||||
|
||||
// Best-effort usage metering
|
||||
try {
|
||||
await this.commandBus.execute(
|
||||
new MeterUsageCommand(command.userId, 'searches_saved', 1),
|
||||
);
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Usage metering failed for saved search: ${err instanceof Error ? err.message : String(err)}`,
|
||||
'CreateSavedSearchHandler',
|
||||
);
|
||||
}
|
||||
// Publish domain event so subscriptions can meter usage out-of-band.
|
||||
this.eventBus.publish(new SavedSearchCreatedEvent(id, command.userId));
|
||||
|
||||
this.logger.log(`Saved search created: id=${id}, user=${command.userId}`, 'CreateSavedSearchHandler');
|
||||
|
||||
|
||||
1
apps/api/src/modules/search/domain/events/index.ts
Normal file
1
apps/api/src/modules/search/domain/events/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { SavedSearchCreatedEvent } from './saved-search-created.event';
|
||||
@@ -0,0 +1,19 @@
|
||||
import { type DomainEvent } from '@modules/shared';
|
||||
|
||||
/**
|
||||
* Emitted when a user successfully creates a saved search. Drives downstream
|
||||
* usage metering (subscriptions module) without coupling search → subscriptions
|
||||
* at the application layer (A-11).
|
||||
*/
|
||||
export class SavedSearchCreatedEvent implements DomainEvent {
|
||||
readonly eventName = 'saved-search.created';
|
||||
readonly occurredAt: Date;
|
||||
|
||||
constructor(
|
||||
/** Saved search id (used as `aggregateId`). */
|
||||
public readonly aggregateId: string,
|
||||
public readonly userId: string,
|
||||
) {
|
||||
this.occurredAt = new Date();
|
||||
}
|
||||
}
|
||||
@@ -27,8 +27,18 @@ export interface ListingDocument {
|
||||
viewCount: number;
|
||||
saveCount: number;
|
||||
projectName: string | null;
|
||||
legalStatus: string | null;
|
||||
amenities: string[];
|
||||
isFeatured: number; // 1 if featuredUntil > now, 0 otherwise
|
||||
|
||||
// Vietnamese diacritic-normalized fields for accent-insensitive search
|
||||
titleNormalized: string;
|
||||
descriptionNormalized: string;
|
||||
addressNormalized: string;
|
||||
wardNormalized: string;
|
||||
districtNormalized: string;
|
||||
cityNormalized: string;
|
||||
projectNameNormalized: string | null;
|
||||
}
|
||||
|
||||
export interface SearchResult {
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export { SearchModule } from './search.module';
|
||||
export { TypesenseClientService } from './infrastructure/services/typesense-client.service';
|
||||
export { SavedSearchCreatedEvent } from './domain/events/saved-search-created.event';
|
||||
|
||||
@@ -26,6 +26,7 @@ const mockListing = {
|
||||
district: 'District 1',
|
||||
city: 'HCMC',
|
||||
projectName: null,
|
||||
legalStatus: null,
|
||||
amenities: ['parking'],
|
||||
},
|
||||
};
|
||||
@@ -159,5 +160,42 @@ describe('ListingIndexerService', () => {
|
||||
expect(result!.priceVND).toBe(5000000000);
|
||||
expect(result!.location).toEqual([10.776, 106.700]);
|
||||
expect(result!.amenities).toEqual(['parking']);
|
||||
|
||||
// Verify normalized fields are populated
|
||||
expect(result!.titleNormalized).toBe('test');
|
||||
expect(result!.descriptionNormalized).toBe('desc');
|
||||
expect(result!.addressNormalized).toBe('123 street');
|
||||
expect(result!.wardNormalized).toBe('ward 1');
|
||||
expect(result!.districtNormalized).toBe('district 1');
|
||||
expect(result!.cityNormalized).toBe('hcmc');
|
||||
expect(result!.projectNameNormalized).toBeNull();
|
||||
});
|
||||
|
||||
it('normalizes Vietnamese diacritics in indexed fields', async () => {
|
||||
const vietnameseListing = {
|
||||
...mockListing,
|
||||
property: {
|
||||
...mockListing.property,
|
||||
title: 'Căn hộ cao cấp',
|
||||
description: 'Biệt thự đẹp',
|
||||
address: '123 Đường Nguyễn Huệ',
|
||||
ward: 'Phường Bến Nghé',
|
||||
district: 'Quận 1',
|
||||
city: 'Hồ Chí Minh',
|
||||
projectName: 'Vinhomes Bason',
|
||||
},
|
||||
};
|
||||
mockPrisma.listing.findUnique.mockResolvedValue(vietnameseListing);
|
||||
mockPrisma.$queryRaw.mockResolvedValue([{ lat: 10.776, lng: 106.700 }]);
|
||||
|
||||
const result = await service.fetchListingDocumentById('listing-1');
|
||||
|
||||
expect(result!.titleNormalized).toBe('can ho cao cap');
|
||||
expect(result!.descriptionNormalized).toBe('biet thu dep');
|
||||
expect(result!.addressNormalized).toBe('123 duong nguyen hue');
|
||||
expect(result!.wardNormalized).toBe('phuong ben nghe');
|
||||
expect(result!.districtNormalized).toBe('quan 1');
|
||||
expect(result!.cityNormalized).toBe('ho chi minh');
|
||||
expect(result!.projectNameNormalized).toBe('vinhomes bason');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -29,7 +29,15 @@ function makeDocument(overrides?: Partial<ListingDocument>): ListingDocument {
|
||||
viewCount: 10,
|
||||
saveCount: 5,
|
||||
projectName: null,
|
||||
legalStatus: null,
|
||||
amenities: ['parking'],
|
||||
titleNormalized: 'test apartment',
|
||||
descriptionNormalized: 'a great place',
|
||||
addressNormalized: '123 street',
|
||||
wardNormalized: 'ward 1',
|
||||
districtNormalized: 'district 1',
|
||||
cityNormalized: 'hcmc',
|
||||
projectNameNormalized: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -43,6 +51,7 @@ describe('TypesenseSearchRepository', () => {
|
||||
retrieve: ReturnType<typeof vi.fn>;
|
||||
delete: ReturnType<typeof vi.fn>;
|
||||
documents: ReturnType<typeof vi.fn>;
|
||||
synonyms: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
let documentOps: {
|
||||
upsert: ReturnType<typeof vi.fn>;
|
||||
@@ -69,6 +78,7 @@ describe('TypesenseSearchRepository', () => {
|
||||
retrieve: vi.fn(),
|
||||
delete: vi.fn().mockResolvedValue({}),
|
||||
documents: vi.fn().mockReturnValue(documentOps),
|
||||
synonyms: vi.fn().mockReturnValue({ upsert: vi.fn().mockResolvedValue({}) }),
|
||||
};
|
||||
createFn = vi.fn().mockResolvedValue({});
|
||||
mockClient = {
|
||||
@@ -192,4 +202,33 @@ describe('TypesenseSearchRepository', () => {
|
||||
expect(searchCall.filter_by).toContain('location:(10.776, 106.7, 5 km)');
|
||||
expect(searchCall.sort_by).toContain('location(10.776, 106.7):asc');
|
||||
});
|
||||
|
||||
it('search queries both original and normalized fields', async () => {
|
||||
documentOps.search.mockResolvedValue({ hits: [], found: 0, search_time_ms: 1 });
|
||||
|
||||
const params: SearchParams = { query: 'căn hộ', page: 1, perPage: 20 };
|
||||
await repo.search(params);
|
||||
|
||||
const searchCall = documentOps.search.mock.calls[0]![0];
|
||||
expect(searchCall.query_by).toContain('titleNormalized');
|
||||
expect(searchCall.query_by).toContain('addressNormalized');
|
||||
expect(searchCall.num_typos).toBe('2');
|
||||
// Query should include both original Vietnamese and normalized ASCII
|
||||
expect(searchCall.q).toContain('căn hộ');
|
||||
expect(searchCall.q).toContain('can ho');
|
||||
});
|
||||
|
||||
it('ensureCollection upserts Vietnamese synonyms', async () => {
|
||||
collectionOps.retrieve.mockResolvedValue({ name: 'listings' });
|
||||
const upsertSpy = vi.fn().mockResolvedValue({});
|
||||
collectionOps.synonyms.mockReturnValue({ upsert: upsertSpy });
|
||||
|
||||
await repo.ensureCollection();
|
||||
|
||||
expect(upsertSpy).toHaveBeenCalled();
|
||||
// Verify at least the HCM synonym was upserted
|
||||
expect(upsertSpy).toHaveBeenCalledWith('hcm', expect.objectContaining({
|
||||
synonyms: expect.arrayContaining(['hcm', 'ho chi minh']),
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { LoggerService, PrismaService } from '@modules/shared';
|
||||
import { Address } from '@modules/listings/domain/value-objects/address.vo';
|
||||
import {
|
||||
SEARCH_REPOSITORY,
|
||||
type ISearchRepository,
|
||||
@@ -119,10 +120,20 @@ 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)
|
||||
: 0,
|
||||
|
||||
// Vietnamese diacritic-normalized fields
|
||||
titleNormalized: Address.normalize(p.title),
|
||||
descriptionNormalized: Address.normalize(p.description),
|
||||
addressNormalized: Address.normalize(p.address),
|
||||
wardNormalized: Address.normalize(p.ward),
|
||||
districtNormalized: Address.normalize(p.district),
|
||||
cityNormalized: Address.normalize(p.city),
|
||||
projectNameNormalized: p.projectName ? Address.normalize(p.projectName) : null,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -170,10 +181,20 @@ 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)
|
||||
: 0,
|
||||
|
||||
// Vietnamese diacritic-normalized fields
|
||||
titleNormalized: Address.normalize(p.title),
|
||||
descriptionNormalized: Address.normalize(p.description),
|
||||
addressNormalized: Address.normalize(p.address),
|
||||
wardNormalized: Address.normalize(p.ward),
|
||||
districtNormalized: Address.normalize(p.district),
|
||||
cityNormalized: Address.normalize(p.city),
|
||||
projectNameNormalized: p.projectName ? Address.normalize(p.projectName) : 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}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { type ListingDocument } from '../../domain/repositories/search.repository';
|
||||
import { Address } from '@modules/listings/domain/value-objects/address.vo';
|
||||
|
||||
export interface RawListingRow {
|
||||
listingId: string;
|
||||
@@ -27,6 +28,7 @@ export interface RawListingRow {
|
||||
viewCount: number;
|
||||
saveCount: number;
|
||||
projectName: string | null;
|
||||
legalStatus?: string | null;
|
||||
amenities: unknown;
|
||||
featuredUntil?: Date | string | null;
|
||||
}
|
||||
@@ -60,7 +62,17 @@ 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,
|
||||
|
||||
// Vietnamese diacritic-normalized fields
|
||||
titleNormalized: Address.normalize(row.title),
|
||||
descriptionNormalized: Address.normalize(row.description),
|
||||
addressNormalized: Address.normalize(row.address),
|
||||
wardNormalized: Address.normalize(row.ward),
|
||||
districtNormalized: Address.normalize(row.district),
|
||||
cityNormalized: Address.normalize(row.city),
|
||||
projectNameNormalized: row.projectName ? Address.normalize(row.projectName) : null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common';
|
||||
import { type Client as TypesenseClient } from 'typesense';
|
||||
import { type CollectionCreateSchema } from 'typesense/lib/Typesense/Collections';
|
||||
import { LoggerService } from '@modules/shared';
|
||||
import { Address } from '@modules/listings/domain/value-objects/address.vo';
|
||||
import {
|
||||
type ISearchRepository,
|
||||
type ListingDocument,
|
||||
@@ -12,6 +13,41 @@ import { TypesenseClientService } from './typesense-client.service';
|
||||
|
||||
const COLLECTION_NAME = 'listings';
|
||||
|
||||
/**
|
||||
* Vietnamese district abbreviation synonyms — maps common shortened forms
|
||||
* to their full diacritic variants so users can search either way.
|
||||
*/
|
||||
const VIETNAMESE_SYNONYMS: Array<{ id: string; synonyms: string[] }> = [
|
||||
{ id: 'q1', synonyms: ['q1', 'quan 1', 'quận 1', 'q.1'] },
|
||||
{ id: 'q2', synonyms: ['q2', 'quan 2', 'quận 2', 'q.2', 'thu duc', 'thủ đức'] },
|
||||
{ id: 'q3', synonyms: ['q3', 'quan 3', 'quận 3', 'q.3'] },
|
||||
{ id: 'q4', synonyms: ['q4', 'quan 4', 'quận 4', 'q.4'] },
|
||||
{ id: 'q5', synonyms: ['q5', 'quan 5', 'quận 5', 'q.5'] },
|
||||
{ id: 'q6', synonyms: ['q6', 'quan 6', 'quận 6', 'q.6'] },
|
||||
{ id: 'q7', synonyms: ['q7', 'quan 7', 'quận 7', 'q.7'] },
|
||||
{ id: 'q8', synonyms: ['q8', 'quan 8', 'quận 8', 'q.8'] },
|
||||
{ id: 'q9', synonyms: ['q9', 'quan 9', 'quận 9', 'q.9'] },
|
||||
{ id: 'q10', synonyms: ['q10', 'quan 10', 'quận 10', 'q.10'] },
|
||||
{ id: 'q11', synonyms: ['q11', 'quan 11', 'quận 11', 'q.11'] },
|
||||
{ id: 'q12', synonyms: ['q12', 'quan 12', 'quận 12', 'q.12'] },
|
||||
{ id: 'binh-thanh', synonyms: ['binh thanh', 'bình thạnh', 'bt'] },
|
||||
{ id: 'tan-binh', synonyms: ['tan binh', 'tân bình', 'tb'] },
|
||||
{ id: 'tan-phu', synonyms: ['tan phu', 'tân phú', 'tp'] },
|
||||
{ id: 'phu-nhuan', synonyms: ['phu nhuan', 'phú nhuận', 'pn'] },
|
||||
{ id: 'go-vap', synonyms: ['go vap', 'gò vấp', 'gv'] },
|
||||
{ id: 'binh-tan', synonyms: ['binh tan', 'bình tân'] },
|
||||
{ id: 'nha-be', synonyms: ['nha be', 'nhà bè'] },
|
||||
{ id: 'can-gio', synonyms: ['can gio', 'cần giờ'] },
|
||||
{ id: 'cu-chi', synonyms: ['cu chi', 'củ chi'] },
|
||||
{ id: 'hoc-mon', synonyms: ['hoc mon', 'hóc môn'] },
|
||||
{ id: 'binh-chanh', synonyms: ['binh chanh', 'bình chánh'] },
|
||||
{ id: 'can-ho', synonyms: ['can ho', 'căn hộ', 'chung cu', 'chung cư'] },
|
||||
{ id: 'nha-pho', synonyms: ['nha pho', 'nhà phố'] },
|
||||
{ id: 'biet-thu', synonyms: ['biet thu', 'biệt thự'] },
|
||||
{ id: 'dat-nen', synonyms: ['dat nen', 'đất nền'] },
|
||||
{ id: 'hcm', synonyms: ['hcm', 'ho chi minh', 'hồ chí minh', 'tp hcm', 'tphcm', 'sai gon', 'sài gòn'] },
|
||||
];
|
||||
|
||||
const LISTING_SCHEMA: CollectionCreateSchema = {
|
||||
name: COLLECTION_NAME,
|
||||
fields: [
|
||||
@@ -40,8 +76,18 @@ 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 },
|
||||
|
||||
// Vietnamese diacritic-normalized fields (ASCII-only, for accent-insensitive search)
|
||||
{ name: 'titleNormalized', type: 'string', facet: false },
|
||||
{ name: 'descriptionNormalized', type: 'string', facet: false },
|
||||
{ name: 'addressNormalized', type: 'string', facet: false },
|
||||
{ name: 'wardNormalized', type: 'string', facet: false },
|
||||
{ name: 'districtNormalized', type: 'string', facet: false },
|
||||
{ name: 'cityNormalized', type: 'string', facet: false },
|
||||
{ name: 'projectNameNormalized', type: 'string', facet: false, optional: true },
|
||||
],
|
||||
token_separators: ['-', '_'],
|
||||
enable_nested_fields: false,
|
||||
@@ -66,6 +112,31 @@ export class TypesenseSearchRepository implements ISearchRepository {
|
||||
await this.client.collections().create(LISTING_SCHEMA);
|
||||
this.logger.log(`Collection "${COLLECTION_NAME}" created`, 'TypesenseSearch');
|
||||
}
|
||||
await this.ensureSynonyms();
|
||||
}
|
||||
|
||||
/**
|
||||
* Upsert Vietnamese district/property-type synonyms into the collection.
|
||||
* Idempotent — safe to call on every startup.
|
||||
*/
|
||||
async ensureSynonyms(): Promise<void> {
|
||||
try {
|
||||
for (const syn of VIETNAMESE_SYNONYMS) {
|
||||
await this.client
|
||||
.collections(COLLECTION_NAME)
|
||||
.synonyms()
|
||||
.upsert(syn.id, { synonyms: syn.synonyms });
|
||||
}
|
||||
this.logger.log(
|
||||
`Upserted ${VIETNAMESE_SYNONYMS.length} Vietnamese synonym rules`,
|
||||
'TypesenseSearch',
|
||||
);
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Failed to upsert synonyms: ${err instanceof Error ? err.message : String(err)}`,
|
||||
'TypesenseSearch',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async dropCollection(): Promise<void> {
|
||||
@@ -120,14 +191,23 @@ export class TypesenseSearchRepository implements ISearchRepository {
|
||||
filterBy = filterBy ? `${filterBy} && ${geoFilter}` : geoFilter;
|
||||
}
|
||||
|
||||
const rawQuery = params.query || '*';
|
||||
// For non-wildcard queries, also search the normalized (ASCII) form
|
||||
// so "can ho" matches "căn hộ" via the normalized fields.
|
||||
const normalizedQuery = rawQuery !== '*' ? Address.normalize(rawQuery) : rawQuery;
|
||||
const effectiveQuery = rawQuery !== '*' && normalizedQuery !== rawQuery
|
||||
? `${rawQuery} ${normalizedQuery}`
|
||||
: rawQuery;
|
||||
|
||||
const searchParams = {
|
||||
q: params.query || '*',
|
||||
query_by: 'title,description,address,district,city,projectName',
|
||||
query_by_weights: '5,3,2,2,1,2',
|
||||
q: effectiveQuery,
|
||||
query_by: 'title,description,address,district,city,projectName,titleNormalized,descriptionNormalized,addressNormalized,districtNormalized,cityNormalized,projectNameNormalized',
|
||||
query_by_weights: '5,3,2,2,1,2,5,3,2,2,1,2',
|
||||
filter_by: filterBy,
|
||||
sort_by: this.buildSortBy(params),
|
||||
page,
|
||||
per_page: perPage,
|
||||
num_typos: '2',
|
||||
highlight_full_fields: 'title,description',
|
||||
highlight_start_tag: '<mark>',
|
||||
highlight_end_tag: '</mark>',
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
ApiParam,
|
||||
} from '@nestjs/swagger';
|
||||
import { type JwtPayload, CurrentUser, JwtAuthGuard } from '@modules/auth';
|
||||
import { QuotaGuard, RequireQuota } from '@modules/subscriptions';
|
||||
import { CreateSavedSearchCommand } from '../../application/commands/create-saved-search/create-saved-search.command';
|
||||
import { type CreateSavedSearchResult } from '../../application/commands/create-saved-search/create-saved-search.handler';
|
||||
import { DeleteSavedSearchCommand } from '../../application/commands/delete-saved-search/delete-saved-search.command';
|
||||
@@ -40,6 +41,8 @@ export class SavedSearchController {
|
||||
) {}
|
||||
|
||||
@Post()
|
||||
@UseGuards(QuotaGuard)
|
||||
@RequireQuota('searches_saved')
|
||||
@ApiOperation({ summary: 'Lưu tìm kiếm', description: 'Lưu bộ lọc tìm kiếm để nhận thông báo khi có kết quả mới' })
|
||||
@ApiResponse({ status: 201, description: 'Tìm kiếm đã được lưu' })
|
||||
@ApiResponse({ status: 400, description: 'Dữ liệu không hợp lệ' })
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
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';
|
||||
import { ReindexAllHandler } from './application/commands/reindex-all/reindex-all.handler';
|
||||
@@ -20,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';
|
||||
@@ -29,11 +29,10 @@ const CommandHandlers = [SyncListingHandler, ReindexAllHandler, CreateSavedSearc
|
||||
const QueryHandlers = [SearchPropertiesHandler, GeoSearchHandler, GetSavedSearchesHandler, GetSavedSearchHandler];
|
||||
|
||||
@Module({
|
||||
imports: [CqrsModule],
|
||||
imports: [CqrsModule, SubscriptionsModule],
|
||||
controllers: [SearchController, SavedSearchController],
|
||||
providers: [
|
||||
// Infrastructure
|
||||
TypesenseClientService,
|
||||
TypesenseSearchRepository,
|
||||
PostgresSearchRepository,
|
||||
ResilientSearchRepository,
|
||||
@@ -60,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,
|
||||
) {}
|
||||
|
||||
@@ -11,5 +11,15 @@ export {
|
||||
ConflictException,
|
||||
UnauthorizedException,
|
||||
ForbiddenException,
|
||||
TooManyRequestsException,
|
||||
} from './domain-exception';
|
||||
export type { ErrorResponseBody } from './domain-exception';
|
||||
export {
|
||||
AI_CONFIG_PROVIDER,
|
||||
type IAIConfigProvider,
|
||||
type AiRuntimeConfig,
|
||||
PAYMENT_INITIATOR,
|
||||
type IPaymentInitiator,
|
||||
type InitiatePaymentInput,
|
||||
type InitiatePaymentResult,
|
||||
} from './ports';
|
||||
|
||||
22
apps/api/src/modules/shared/domain/ports/ai-config.port.ts
Normal file
22
apps/api/src/modules/shared/domain/ports/ai-config.port.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Runtime AI configuration read by any module that needs to call an LLM.
|
||||
* This is the shared port — concrete implementations live in the owning
|
||||
* module (currently `admin`) so we avoid cross-module application-layer
|
||||
* coupling (A-09).
|
||||
*/
|
||||
export interface AiRuntimeConfig {
|
||||
apiUrl: string;
|
||||
apiKey: string | null;
|
||||
model: string;
|
||||
}
|
||||
|
||||
export interface IAIConfigProvider {
|
||||
/**
|
||||
* Return the currently configured runtime AI settings. Implementations
|
||||
* should resolve secrets server-side and MUST never expose the raw key
|
||||
* over HTTP — this port is intended for backend runtime use only.
|
||||
*/
|
||||
getAiConfig(): Promise<AiRuntimeConfig>;
|
||||
}
|
||||
|
||||
export const AI_CONFIG_PROVIDER = Symbol('AI_CONFIG_PROVIDER');
|
||||
11
apps/api/src/modules/shared/domain/ports/index.ts
Normal file
11
apps/api/src/modules/shared/domain/ports/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export {
|
||||
AI_CONFIG_PROVIDER,
|
||||
type IAIConfigProvider,
|
||||
type AiRuntimeConfig,
|
||||
} from './ai-config.port';
|
||||
export {
|
||||
PAYMENT_INITIATOR,
|
||||
type IPaymentInitiator,
|
||||
type InitiatePaymentInput,
|
||||
type InitiatePaymentResult,
|
||||
} from './payment-initiator.port';
|
||||
@@ -0,0 +1,34 @@
|
||||
import { type PaymentProvider, type PaymentType } from '@prisma/client';
|
||||
|
||||
/**
|
||||
* Minimal cross-module contract used by non-payment modules (e.g. listings)
|
||||
* to initiate a payment without importing payments application-layer commands.
|
||||
*
|
||||
* The concrete implementation lives in `payments` and is registered under the
|
||||
* `PAYMENT_INITIATOR` symbol. This keeps the dependency direction
|
||||
* listings → shared ← payments, matching our module-boundary rules (A-10).
|
||||
*/
|
||||
export interface InitiatePaymentInput {
|
||||
userId: string;
|
||||
provider: PaymentProvider;
|
||||
type: PaymentType;
|
||||
amountVND: bigint;
|
||||
description: string;
|
||||
returnUrl: string;
|
||||
ipAddress: string;
|
||||
/** Associated business-object id (e.g. listingId) when relevant. */
|
||||
transactionId?: string;
|
||||
idempotencyKey?: string;
|
||||
}
|
||||
|
||||
export interface InitiatePaymentResult {
|
||||
paymentId: string;
|
||||
paymentUrl: string;
|
||||
providerTxId: string;
|
||||
}
|
||||
|
||||
export interface IPaymentInitiator {
|
||||
initiate(input: InitiatePaymentInput): Promise<InitiatePaymentResult>;
|
||||
}
|
||||
|
||||
export const PAYMENT_INITIATOR = Symbol('PAYMENT_INITIATOR');
|
||||
@@ -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()}`,
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { CommandBus } from '@nestjs/cqrs';
|
||||
import { OnEvent } from '@nestjs/event-emitter';
|
||||
import { type SavedSearchCreatedEvent } from '@modules/search';
|
||||
import { LoggerService } from '@modules/shared';
|
||||
import { MeterUsageCommand } from '../../application/commands/meter-usage/meter-usage.command';
|
||||
|
||||
@Injectable()
|
||||
export class SavedSearchCreatedUsageHandler {
|
||||
constructor(
|
||||
private readonly commandBus: CommandBus,
|
||||
private readonly logger: LoggerService,
|
||||
) {}
|
||||
|
||||
@OnEvent('saved-search.created', { async: true })
|
||||
async handle(event: SavedSearchCreatedEvent): Promise<void> {
|
||||
this.logger.log(
|
||||
`Metering searches_saved usage for user=${event.userId}`,
|
||||
'SavedSearchCreatedUsageHandler',
|
||||
);
|
||||
|
||||
try {
|
||||
await this.commandBus.execute(
|
||||
new MeterUsageCommand(event.userId, 'searches_saved', 1),
|
||||
);
|
||||
} catch (error) {
|
||||
// Log but don't fail — usage metering is best-effort (quota already enforced by guard)
|
||||
this.logger.warn(
|
||||
`Failed to meter usage for user=${event.userId}: ${(error as Error).message}`,
|
||||
'SavedSearchCreatedUsageHandler',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,9 @@ 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';
|
||||
import { SubscriptionsController } from './presentation/controllers/subscriptions.controller';
|
||||
import { QuotaGuard } from './presentation/guards/quota.guard';
|
||||
@@ -38,6 +40,8 @@ const QueryHandlers = [
|
||||
|
||||
// Event Listeners
|
||||
ListingCreatedUsageHandler,
|
||||
SavedSearchCreatedUsageHandler,
|
||||
BankTransferSubscriptionActivationHandler,
|
||||
|
||||
// CQRS
|
||||
...CommandHandlers,
|
||||
|
||||
@@ -15,9 +15,9 @@ interface ListingCardProps {
|
||||
|
||||
export function IndustrialListingCard({ listing }: ListingCardProps) {
|
||||
const priceText = listing.priceUsdM2
|
||||
? `$${listing.priceUsdM2}/${listing.pricingUnit ?? 'm²/tháng'}`
|
||||
? `$${parseFloat(listing.priceUsdM2)}/${listing.pricingUnit ?? 'm²/tháng'}`
|
||||
: listing.totalLeasePrice
|
||||
? `$${listing.totalLeasePrice.toLocaleString()}`
|
||||
? `$${parseFloat(listing.totalLeasePrice).toLocaleString()}`
|
||||
: 'Liên hệ';
|
||||
|
||||
const leaseTermText =
|
||||
|
||||
@@ -45,7 +45,7 @@ function normalizeScore(park: IndustrialParkDetail, metric: string): number {
|
||||
case 'area':
|
||||
return Math.min((park.totalAreaHa / 1000) * 100, 100);
|
||||
case 'rent': {
|
||||
const rent = park.landRentUsdM2Year ?? 0;
|
||||
const rent = park.landRentUsdM2Year != null ? parseFloat(park.landRentUsdM2Year) : 0;
|
||||
return rent > 0 ? Math.min((rent / 150) * 100, 100) : 0;
|
||||
}
|
||||
case 'infrastructure': {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -23,9 +23,12 @@ export interface IndustrialParkListItem {
|
||||
occupancyRate: number;
|
||||
remainingAreaHa: number;
|
||||
tenantCount: number;
|
||||
landRentUsdM2Year: number | null;
|
||||
rbfRentUsdM2Month: number | null;
|
||||
rbwRentUsdM2Month: number | null;
|
||||
/** Decimal(18,4) serialised as string. Use parseFloat() for arithmetic. */
|
||||
landRentUsdM2Year: string | null;
|
||||
/** Decimal(18,4) serialised as string. Use parseFloat() for arithmetic. */
|
||||
rbfRentUsdM2Month: string | null;
|
||||
/** Decimal(18,4) serialised as string. Use parseFloat() for arithmetic. */
|
||||
rbwRentUsdM2Month: string | null;
|
||||
targetIndustries: string[];
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
@@ -51,10 +54,14 @@ export interface IndustrialParkDetail {
|
||||
remainingAreaHa: number;
|
||||
tenantCount: number;
|
||||
establishedYear: number | null;
|
||||
landRentUsdM2Year: number | null;
|
||||
rbfRentUsdM2Month: number | null;
|
||||
rbwRentUsdM2Month: number | null;
|
||||
managementFeeUsd: number | null;
|
||||
/** Decimal(18,4) serialised as string. Use parseFloat() for arithmetic. */
|
||||
landRentUsdM2Year: string | null;
|
||||
/** Decimal(18,4) serialised as string. Use parseFloat() for arithmetic. */
|
||||
rbfRentUsdM2Month: string | null;
|
||||
/** Decimal(18,4) serialised as string. Use parseFloat() for arithmetic. */
|
||||
rbwRentUsdM2Month: string | null;
|
||||
/** Decimal(18,4) serialised as string. Use parseFloat() for arithmetic. */
|
||||
managementFeeUsd: string | null;
|
||||
infrastructure: Record<string, string> | null;
|
||||
connectivity: Record<string, { name: string; distanceKm: number }> | null;
|
||||
incentives: Record<string, unknown> | null;
|
||||
@@ -84,10 +91,12 @@ export interface IndustrialParkStats {
|
||||
export interface IndustrialMarketData {
|
||||
totalParks: number;
|
||||
avgOccupancyRate: number;
|
||||
avgLandRentUsdM2: number | null;
|
||||
avgRbfRentUsdM2: number | null;
|
||||
rentByRegion: { region: string; avgLandRent: number | null; avgRbfRent: number | null; parkCount: number }[];
|
||||
rentByProvince: { province: string; avgLandRent: number | null; avgRbfRent: number | null; parkCount: number }[];
|
||||
/** AVG(numeric) serialised as string by PostgreSQL. */
|
||||
avgLandRentUsdM2: string | null;
|
||||
/** AVG(numeric) serialised as string by PostgreSQL. */
|
||||
avgRbfRentUsdM2: string | null;
|
||||
rentByRegion: { region: string; avgLandRent: string | null; avgRbfRent: string | null; parkCount: number }[];
|
||||
rentByProvince: { province: string; avgLandRent: string | null; avgRbfRent: string | null; parkCount: number }[];
|
||||
}
|
||||
|
||||
// ─── Industrial Listing Types ───────────────────────────
|
||||
@@ -125,9 +134,11 @@ export interface IndustrialListingItem {
|
||||
description: string | null;
|
||||
areaM2: number;
|
||||
ceilingHeightM: number | null;
|
||||
priceUsdM2: number | null;
|
||||
/** Decimal(18,4) serialised as string. Use parseFloat() for arithmetic. */
|
||||
priceUsdM2: string | null;
|
||||
pricingUnit: string | null;
|
||||
totalLeasePrice: number | null;
|
||||
/** Decimal(18,4) serialised as string. Use parseFloat() for arithmetic. */
|
||||
totalLeasePrice: string | null;
|
||||
minLeaseYears: number | null;
|
||||
maxLeaseYears: number | null;
|
||||
availableFrom: string | null;
|
||||
|
||||
@@ -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,16 @@
|
||||
-- Migrate IndustrialPark and IndustrialListing USD money fields from
|
||||
-- double precision (Float) to numeric(18, 4) (Decimal) to preserve exact
|
||||
-- precision for money. USING casts keep existing data intact.
|
||||
|
||||
-- IndustrialPark
|
||||
ALTER TABLE "IndustrialPark"
|
||||
ALTER COLUMN "landRentUsdM2Year" TYPE numeric(18, 4) USING "landRentUsdM2Year"::numeric(18, 4),
|
||||
ALTER COLUMN "rbfRentUsdM2Month" TYPE numeric(18, 4) USING "rbfRentUsdM2Month"::numeric(18, 4),
|
||||
ALTER COLUMN "rbwRentUsdM2Month" TYPE numeric(18, 4) USING "rbwRentUsdM2Month"::numeric(18, 4),
|
||||
ALTER COLUMN "managementFeeUsd" TYPE numeric(18, 4) USING "managementFeeUsd"::numeric(18, 4);
|
||||
|
||||
-- IndustrialListing
|
||||
ALTER TABLE "IndustrialListing"
|
||||
ALTER COLUMN "priceUsdM2" TYPE numeric(18, 4) USING "priceUsdM2"::numeric(18, 4),
|
||||
ALTER COLUMN "totalLeasePrice" TYPE numeric(18, 4) USING "totalLeasePrice"::numeric(18, 4),
|
||||
ALTER COLUMN "managementFee" TYPE numeric(18, 4) USING "managementFee"::numeric(18, 4);
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AddColumn: track when the 3-day expiry warning was sent to avoid duplicate notifications
|
||||
ALTER TABLE "Listing" ADD COLUMN "expiryNotifiedAt" TIMESTAMP(3);
|
||||
@@ -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;
|
||||
@@ -82,6 +82,9 @@ model User {
|
||||
/// KCN do user này vận hành (role=PARK_OPERATOR).
|
||||
ownedIndustrialParks IndustrialPark[] @relation("IndustrialParkOwner")
|
||||
zaloAccountLink ZaloAccountLink?
|
||||
notificationLogs NotificationLog[]
|
||||
industrialListingsSelling IndustrialListing[] @relation("IndustrialListingSeller")
|
||||
listingFlagsReported ListingFlag[] @relation("listingFlagsReported")
|
||||
|
||||
@@index([role])
|
||||
@@index([kycStatus])
|
||||
@@ -187,6 +190,7 @@ model Agent {
|
||||
|
||||
listings Listing[]
|
||||
leads Lead[]
|
||||
industrialListings IndustrialListing[] @relation("IndustrialListingAgent")
|
||||
|
||||
@@index([qualityScore])
|
||||
@@index([isVerified])
|
||||
@@ -310,6 +314,15 @@ enum PropertyCondition {
|
||||
USED
|
||||
}
|
||||
|
||||
enum LegalStatus {
|
||||
SO_DO
|
||||
SO_HONG
|
||||
LAND_USE_RIGHT
|
||||
JOINT_USE_RIGHT
|
||||
AWAITING
|
||||
NO_CERTIFICATE
|
||||
}
|
||||
|
||||
model Property {
|
||||
id String @id @default(cuid())
|
||||
propertyType PropertyType
|
||||
@@ -333,7 +346,8 @@ model Property {
|
||||
totalFloors Int?
|
||||
direction Direction?
|
||||
yearBuilt Int?
|
||||
legalStatus String?
|
||||
legalStatus LegalStatus?
|
||||
certificateVerified Boolean @default(false)
|
||||
amenities Json?
|
||||
nearbyPOIs Json?
|
||||
metroDistanceM Float?
|
||||
@@ -411,8 +425,9 @@ model Listing {
|
||||
inquiryCount Int @default(0)
|
||||
featuredUntil DateTime?
|
||||
featuredPackage String? /// "3_days" | "7_days" | "30_days"
|
||||
expiresAt DateTime?
|
||||
publishedAt DateTime?
|
||||
expiresAt DateTime?
|
||||
expiryNotifiedAt DateTime?
|
||||
publishedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@ -421,6 +436,8 @@ model Listing {
|
||||
orders Order[]
|
||||
priceHistories PriceHistory[]
|
||||
savedByUsers SavedListing[]
|
||||
conversations Conversation[]
|
||||
flags ListingFlag[]
|
||||
|
||||
// --- Single-column indexes ---
|
||||
@@index([status])
|
||||
@@ -456,6 +473,45 @@ model PriceHistory {
|
||||
@@index([listingId, changedAt(sort: Desc)])
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// LISTING FLAGS (user-submitted abuse/scam reports)
|
||||
// =============================================================================
|
||||
|
||||
enum FlagReason {
|
||||
SCAM
|
||||
DUPLICATE
|
||||
WRONG_INFO
|
||||
ALREADY_SOLD
|
||||
INAPPROPRIATE
|
||||
}
|
||||
|
||||
enum FlagStatus {
|
||||
PENDING
|
||||
REVIEWED
|
||||
DISMISSED
|
||||
}
|
||||
|
||||
model ListingFlag {
|
||||
id String @id @default(cuid())
|
||||
listingId String
|
||||
listing Listing @relation(fields: [listingId], references: [id], onDelete: Cascade)
|
||||
reporterId String
|
||||
reporter User @relation("listingFlagsReported", fields: [reporterId], references: [id], onDelete: Restrict)
|
||||
reason FlagReason
|
||||
description String? /// Mô tả chi tiết (tuỳ chọn)
|
||||
status FlagStatus @default(PENDING)
|
||||
reviewedBy String?
|
||||
reviewedAt DateTime?
|
||||
reviewNotes String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@unique([listingId, reporterId]) // one report per user per listing
|
||||
@@index([listingId])
|
||||
@@index([status, createdAt(sort: Desc)])
|
||||
@@index([reporterId])
|
||||
@@map("listing_flags")
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// SEARCH
|
||||
// =============================================================================
|
||||
@@ -824,6 +880,7 @@ enum NotificationStatus {
|
||||
model NotificationLog {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
channel NotificationChannel
|
||||
templateKey String
|
||||
subject String?
|
||||
@@ -1110,7 +1167,9 @@ model IndustrialListing {
|
||||
parkId String
|
||||
park IndustrialPark @relation(fields: [parkId], references: [id], onDelete: Cascade)
|
||||
agentId String?
|
||||
agent Agent? @relation("IndustrialListingAgent", fields: [agentId], references: [id], onDelete: SetNull)
|
||||
sellerId String
|
||||
seller User @relation("IndustrialListingSeller", fields: [sellerId], references: [id], onDelete: Restrict)
|
||||
propertyType IndustrialPropertyType
|
||||
leaseType IndustrialLeaseType
|
||||
status IndustrialListingStatus @default(DRAFT)
|
||||
@@ -1170,6 +1229,7 @@ enum ConversationStatus {
|
||||
model Conversation {
|
||||
id String @id @default(cuid())
|
||||
listingId String?
|
||||
listing Listing? @relation(fields: [listingId], references: [id], onDelete: SetNull)
|
||||
subject String?
|
||||
status ConversationStatus @default(ACTIVE)
|
||||
lastMessage String? @db.Text
|
||||
@@ -1438,3 +1498,72 @@ model SystemSetting {
|
||||
updatedAt DateTime @updatedAt
|
||||
updatedBy String?
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// VIETNAM ADMINISTRATIVE REFERENCE (ĐVHCVN)
|
||||
// =============================================================================
|
||||
// Authoritative 3-level administrative hierarchy sourced from GSO
|
||||
// (danhmuchanhchinhvn.gso.gov.vn): 63 provinces / ~705 districts / ~10.6K wards.
|
||||
// Seeded from `prisma/data/vn-admin/` snapshot via `prisma/seed-vn-admin.ts`.
|
||||
// [GOO-21]
|
||||
|
||||
model VnProvince {
|
||||
code String @id // GSO province code, zero-padded (e.g. "01", "79")
|
||||
name String // Canonical Vietnamese name, e.g. "Thành phố Hồ Chí Minh"
|
||||
nameEn String?
|
||||
type String // "Thành phố Trung ương" | "Tỉnh"
|
||||
codename String // slug, e.g. "thanh_pho_ho_chi_minh"
|
||||
phoneCode Int?
|
||||
districts VnDistrict[]
|
||||
|
||||
@@index([codename])
|
||||
@@map("vn_provinces")
|
||||
}
|
||||
|
||||
model VnDistrict {
|
||||
code String @id // GSO district code
|
||||
provinceCode String
|
||||
name String // e.g. "Quận 1", "Huyện Củ Chi", "Thành phố Thủ Đức"
|
||||
nameEn String?
|
||||
type String // "Quận" | "Huyện" | "Thị xã" | "Thành phố thuộc tỉnh"
|
||||
codename String
|
||||
province VnProvince @relation(fields: [provinceCode], references: [code], onDelete: Restrict)
|
||||
wards VnWard[]
|
||||
|
||||
@@index([provinceCode])
|
||||
@@index([codename])
|
||||
@@map("vn_districts")
|
||||
}
|
||||
|
||||
model VnWard {
|
||||
code String @id
|
||||
districtCode String
|
||||
name String
|
||||
nameEn String?
|
||||
type String // "Phường" | "Xã" | "Thị trấn"
|
||||
codename String
|
||||
district VnDistrict @relation(fields: [districtCode], references: [code], onDelete: Restrict)
|
||||
|
||||
@@index([districtCode])
|
||||
@@index([codename])
|
||||
@@map("vn_wards")
|
||||
}
|
||||
|
||||
/// Historical name/code changes so legacy data (e.g. Quận 2, Quận 9) and post-2025
|
||||
/// merges can still resolve to the current district/ward.
|
||||
model VnAdministrativeAlias {
|
||||
id String @id @default(cuid())
|
||||
oldCode String? // GSO code pre-change, when known
|
||||
oldName String // human-readable legacy name, e.g. "Quận 2"
|
||||
level String // "province" | "district" | "ward"
|
||||
newDistrictCode String?
|
||||
newWardCode String?
|
||||
reason String // e.g. "merged_into_thu_duc_2021", "2025_redistrict"
|
||||
mergedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([oldName])
|
||||
@@index([newDistrictCode])
|
||||
@@index([newWardCode])
|
||||
@@map("vn_administrative_aliases")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user