feat(analytics): integrate AI/ML services — AVM endpoint, moderation pipeline, market index cron

- Add AiServiceClient HTTP client for Python FastAPI AI service with timeout and fallback
- Add HttpAVMService that calls Python AVM endpoint, falls back to PrismaAVMService on failure
- Add ListingCreatedModerationHandler: auto-flags suspicious listings via AI moderation on create
- Add MarketIndexCronService: daily cron job aggregating market stats per district/city/type
- Wire ScheduleModule and new providers into AnalyticsModule and AppModule
- Add unit tests for AiServiceClient, HttpAVMService, and moderation handler (all passing)

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-09 10:13:06 +07:00
parent d64bbe97e2
commit 35feccb529
13 changed files with 1436 additions and 8 deletions

View File

@@ -23,6 +23,7 @@
"@nestjs/jwt": "^11.0.2",
"@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^11.0.0",
"@nestjs/schedule": "^6.1.1",
"@nestjs/swagger": "^11.2.6",
"@nestjs/terminus": "^11.1.1",
"@nestjs/throttler": "^6.5.0",

View File

@@ -1,6 +1,7 @@
import { type MiddlewareConsumer, Module, type NestModule } from '@nestjs/common';
import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
import { CqrsModule } from '@nestjs/cqrs';
import { ScheduleModule } from '@nestjs/schedule';
import { ThrottlerModule } from '@nestjs/throttler';
import { SentryGlobalFilter, SentryModule } from '@sentry/nestjs/setup';
import { AdminModule } from '@modules/admin';
@@ -28,6 +29,7 @@ import { AppController } from './app.controller';
imports: [
SentryModule.forRoot(),
CqrsModule.forRoot(),
ScheduleModule.forRoot(),
SharedModule,
HealthModule,
AuthModule,

View File

@@ -3,6 +3,7 @@ import { CqrsModule } from '@nestjs/cqrs';
import { GenerateReportHandler } from './application/commands/generate-report/generate-report.handler';
import { TrackEventHandler } from './application/commands/track-event/track-event.handler';
import { UpdateMarketIndexHandler } from './application/commands/update-market-index/update-market-index.handler';
import { ListingCreatedModerationHandler } from './application/event-handlers/listing-created-moderation.handler';
import { GetDistrictStatsHandler } from './application/queries/get-district-stats/get-district-stats.handler';
import { GetHeatmapHandler } from './application/queries/get-heatmap/get-heatmap.handler';
import { GetMarketReportHandler } from './application/queries/get-market-report/get-market-report.handler';
@@ -13,6 +14,9 @@ import { VALUATION_REPOSITORY } from './domain/repositories/valuation.repository
import { AVM_SERVICE } from './domain/services/avm-service';
import { PrismaMarketIndexRepository } from './infrastructure/repositories/prisma-market-index.repository';
import { PrismaValuationRepository } from './infrastructure/repositories/prisma-valuation.repository';
import { AI_SERVICE_CLIENT, AiServiceClient } from './infrastructure/services/ai-service.client';
import { HttpAVMService } from './infrastructure/services/http-avm.service';
import { MarketIndexCronService } from './infrastructure/services/market-index-cron.service';
import { PrismaAVMService } from './infrastructure/services/prisma-avm.service';
import { AnalyticsController } from './presentation/controllers/analytics.controller';
@@ -30,19 +34,33 @@ const QueryHandlers = [
GetValuationHandler,
];
const EventHandlers = [
ListingCreatedModerationHandler,
];
@Module({
imports: [CqrsModule],
controllers: [AnalyticsController],
providers: [
// AI service client
{ provide: AI_SERVICE_CLIENT, useClass: AiServiceClient },
// Repositories
{ provide: MARKET_INDEX_REPOSITORY, useClass: PrismaMarketIndexRepository },
{ provide: VALUATION_REPOSITORY, useClass: PrismaValuationRepository },
{ provide: AVM_SERVICE, useClass: PrismaAVMService },
// AVM: HttpAVMService calls Python AI first, falls back to PrismaAVMService
PrismaAVMService,
{ provide: AVM_SERVICE, useClass: HttpAVMService },
// Cron
MarketIndexCronService,
// CQRS
...CommandHandlers,
...QueryHandlers,
...EventHandlers,
],
exports: [MARKET_INDEX_REPOSITORY, VALUATION_REPOSITORY, AVM_SERVICE],
exports: [MARKET_INDEX_REPOSITORY, VALUATION_REPOSITORY, AVM_SERVICE, AI_SERVICE_CLIENT],
})
export class AnalyticsModule {}

View File

@@ -0,0 +1,117 @@
import type { CommandBus } from '@nestjs/cqrs';
import type { ModerateListingCommand } from '@modules/listings/application/commands/moderate-listing/moderate-listing.command';
import { ListingCreatedEvent } from '@modules/listings/domain/events/listing-created.event';
import { type IAiServiceClient } from '../../infrastructure/services/ai-service.client';
import { ListingCreatedModerationHandler } from '../event-handlers/listing-created-moderation.handler';
describe('ListingCreatedModerationHandler', () => {
let handler: ListingCreatedModerationHandler;
let mockAiClient: IAiServiceClient;
let mockCommandBus: CommandBus;
let mockPrisma: { property: { findUnique: ReturnType<typeof vi.fn> } };
const event = new ListingCreatedEvent('listing-1', 'property-1', 'seller-1', 'SALE');
beforeEach(() => {
mockAiClient = {
predict: vi.fn(),
moderate: vi.fn(),
isAvailable: vi.fn(),
};
mockCommandBus = {
execute: vi.fn().mockResolvedValue({ status: 'DRAFT' }),
} as unknown as CommandBus;
mockPrisma = {
property: {
findUnique: vi.fn().mockResolvedValue({
title: 'Bán căn hộ Quận 1',
description: 'Căn hộ 80m2 view sông Sài Gòn',
}),
},
};
handler = new ListingCreatedModerationHandler(
mockAiClient,
mockCommandBus,
mockPrisma as never,
);
});
it('skips moderation when property not found', async () => {
mockPrisma.property.findUnique.mockResolvedValue(null);
await handler.handle(event);
expect(mockAiClient.moderate).not.toHaveBeenCalled();
});
it('does not dispatch command when text is clean', async () => {
(mockAiClient.moderate as ReturnType<typeof vi.fn>).mockResolvedValue({
is_flagged: false,
score: 0,
flags: [],
cleaned_text: 'Bán căn hộ Quận 1\nCăn hộ 80m2 view sông Sài Gòn',
});
await handler.handle(event);
expect(mockCommandBus.execute).not.toHaveBeenCalled();
});
it('dispatches approve command for low-score flagged listing', async () => {
(mockAiClient.moderate as ReturnType<typeof vi.fn>).mockResolvedValue({
is_flagged: true,
score: 0.5,
flags: [
{ category: 'contact_info', severity: 'medium', matched_text: '0901234567', reason: 'Contact info detected' },
],
cleaned_text: 'Bán căn hộ [REDACTED]',
});
await handler.handle(event);
expect(mockCommandBus.execute).toHaveBeenCalledWith(
expect.objectContaining({
listingId: 'listing-1',
action: 'approve',
moderationScore: 0.5,
}),
);
});
it('dispatches reject command for high-score flagged listing', async () => {
(mockAiClient.moderate as ReturnType<typeof vi.fn>).mockResolvedValue({
is_flagged: true,
score: 0.9,
flags: [
{ category: 'profanity', severity: 'high', matched_text: 'lừa đảo', reason: 'Harmful language' },
{ category: 'prohibited_content', severity: 'high', matched_text: 'đất tranh chấp', reason: 'Prohibited property' },
],
cleaned_text: '[REDACTED] [REDACTED]',
});
await handler.handle(event);
expect(mockCommandBus.execute).toHaveBeenCalledWith(
expect.objectContaining({
listingId: 'listing-1',
action: 'reject',
moderationScore: 0.9,
}),
);
const cmd = (mockCommandBus.execute as ReturnType<typeof vi.fn>).mock.calls[0][0] as ModerateListingCommand;
expect(cmd.notes).toContain('[AI Auto-Moderation]');
expect(cmd.moderatorId).toBe('system:ai-moderation');
});
it('silently handles AI service errors', async () => {
(mockAiClient.moderate as ReturnType<typeof vi.fn>).mockRejectedValue(
new Error('ECONNREFUSED'),
);
await expect(handler.handle(event)).resolves.not.toThrow();
expect(mockCommandBus.execute).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,76 @@
import { Inject, Logger } from '@nestjs/common';
import { EventsHandler, type IEventHandler, type CommandBus } from '@nestjs/cqrs';
import { ListingCreatedEvent, ModerateListingCommand } from '@modules/listings';
import { type PrismaService } from '@modules/shared';
import {
AI_SERVICE_CLIENT,
type IAiServiceClient,
} from '../../infrastructure/services/ai-service.client';
const AUTO_REJECT_THRESHOLD = 0.8;
const AI_MODERATOR_ID = 'system:ai-moderation';
@EventsHandler(ListingCreatedEvent)
export class ListingCreatedModerationHandler implements IEventHandler<ListingCreatedEvent> {
private readonly logger = new Logger(ListingCreatedModerationHandler.name);
constructor(
@Inject(AI_SERVICE_CLIENT) private readonly aiClient: IAiServiceClient,
private readonly commandBus: CommandBus,
private readonly prisma: PrismaService,
) {}
async handle(event: ListingCreatedEvent): Promise<void> {
try {
await this.moderateListing(event);
} catch (err) {
this.logger.warn(
`AI moderation skipped for listing ${event.aggregateId}: ${(err as Error).message}`,
);
}
}
private async moderateListing(event: ListingCreatedEvent): Promise<void> {
const property = await this.prisma.property.findUnique({
where: { id: event.propertyId },
select: { title: true, description: true },
});
if (!property) return;
const textToModerate = `${property.title}\n${property.description}`;
const result = await this.aiClient.moderate({
text: textToModerate,
context: 'listing',
});
if (!result.is_flagged) {
this.logger.debug(
`Listing ${event.aggregateId} passed AI moderation (score: ${result.score})`,
);
return;
}
this.logger.log(
`Listing ${event.aggregateId} flagged by AI moderation (score: ${result.score}, ` +
`flags: ${result.flags.map((f) => f.category).join(', ')})`,
);
const flagNotes = result.flags
.map((f) => `[${f.severity}] ${f.category}: ${f.reason}`)
.join('; ');
const action = result.score >= AUTO_REJECT_THRESHOLD ? 'reject' : 'approve';
await this.commandBus.execute(
new ModerateListingCommand(
event.aggregateId,
AI_MODERATOR_ID,
action,
result.score,
`[AI Auto-Moderation] ${flagNotes}`,
),
);
}
}

View File

@@ -0,0 +1,150 @@
import { AiServiceClient } from '../services/ai-service.client';
describe('AiServiceClient', () => {
let client: AiServiceClient;
const originalEnv = process.env;
beforeEach(() => {
process.env = { ...originalEnv, AI_SERVICE_URL: 'http://localhost:8000' };
client = new AiServiceClient();
});
afterEach(() => {
process.env = originalEnv;
vi.restoreAllMocks();
});
describe('predict', () => {
it('sends predict request to AI service', async () => {
const mockResponse = {
estimated_price_vnd: 5_000_000_000,
confidence: 0.82,
price_per_m2: 70_000_000,
price_range_low: 4_250_000_000,
price_range_high: 5_750_000_000,
};
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response(JSON.stringify(mockResponse), { status: 200 }),
);
const result = await client.predict({
area: 80,
district: 'Quận 1',
city: 'Hồ Chí Minh',
property_type: 'apartment',
});
expect(result.estimated_price_vnd).toBe(5_000_000_000);
expect(result.confidence).toBe(0.82);
expect(fetch).toHaveBeenCalledWith(
'http://localhost:8000/avm/predict',
expect.objectContaining({ method: 'POST' }),
);
});
it('throws on non-OK response', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response('Service unavailable', { status: 503 }),
);
await expect(
client.predict({
area: 80,
district: 'Quận 1',
city: 'Hồ Chí Minh',
property_type: 'apartment',
}),
).rejects.toThrow('AI service /avm/predict returned 503');
});
it('throws on network error', async () => {
vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('ECONNREFUSED'));
await expect(
client.predict({
area: 80,
district: 'Quận 1',
city: 'Hồ Chí Minh',
property_type: 'apartment',
}),
).rejects.toThrow('ECONNREFUSED');
});
});
describe('moderate', () => {
it('sends moderation request and returns result', async () => {
const mockResponse = {
is_flagged: true,
score: 0.7,
flags: [
{
category: 'contact_info',
severity: 'medium',
matched_text: '0901234567',
reason: 'Contact information detected',
},
],
cleaned_text: 'Bán nhà [REDACTED]',
};
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response(JSON.stringify(mockResponse), { status: 200 }),
);
const result = await client.moderate({
text: 'Bán nhà liên hệ 0901234567',
context: 'listing',
});
expect(result.is_flagged).toBe(true);
expect(result.score).toBe(0.7);
expect(result.flags).toHaveLength(1);
expect(result.flags[0].category).toBe('contact_info');
});
it('returns clean result for safe text', async () => {
const mockResponse = {
is_flagged: false,
score: 0,
flags: [],
cleaned_text: 'Bán căn hộ 80m2 Quận 1',
};
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response(JSON.stringify(mockResponse), { status: 200 }),
);
const result = await client.moderate({
text: 'Bán căn hộ 80m2 Quận 1',
});
expect(result.is_flagged).toBe(false);
expect(result.score).toBe(0);
});
});
describe('isAvailable', () => {
it('returns true when service is healthy', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response('{"status":"ok"}', { status: 200 }),
);
expect(await client.isAvailable()).toBe(true);
});
it('returns false when service is down', async () => {
vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('ECONNREFUSED'));
expect(await client.isAvailable()).toBe(false);
});
it('returns false when service returns error', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response('error', { status: 500 }),
);
expect(await client.isAvailable()).toBe(false);
});
});
});

View File

@@ -0,0 +1,128 @@
import type { IAiServiceClient } from '../services/ai-service.client';
import { HttpAVMService } from '../services/http-avm.service';
import type { PrismaAVMService } from '../services/prisma-avm.service';
describe('HttpAVMService', () => {
let service: HttpAVMService;
let mockAiClient: IAiServiceClient;
let mockFallback: PrismaAVMService;
let mockPrisma: { property: { findUnique: ReturnType<typeof vi.fn> } };
beforeEach(() => {
mockAiClient = {
predict: vi.fn(),
moderate: vi.fn(),
isAvailable: vi.fn(),
};
mockFallback = {
estimateValue: vi.fn(),
getComparables: vi.fn().mockResolvedValue([]),
} as unknown as PrismaAVMService;
mockPrisma = {
property: { findUnique: vi.fn() },
};
service = new HttpAVMService(
mockAiClient,
mockFallback,
mockPrisma as never,
);
});
describe('estimateValue', () => {
it('uses AI service when available', async () => {
mockPrisma.property.findUnique.mockResolvedValue({
areaM2: 80,
district: 'Quận 1',
city: 'Hồ Chí Minh',
propertyType: 'APARTMENT',
bedrooms: 2,
bathrooms: 2,
floors: null,
yearBuilt: 2020,
legalStatus: 'SO_DO',
});
(mockAiClient.predict as ReturnType<typeof vi.fn>).mockResolvedValue({
estimated_price_vnd: 5_000_000_000,
confidence: 0.82,
price_per_m2: 62_500_000,
price_range_low: 4_250_000_000,
price_range_high: 5_750_000_000,
});
const result = await service.estimateValue({ propertyId: 'prop-1' });
expect(result.estimatedPrice).toBe('5000000000');
expect(result.confidence).toBe(0.82);
expect(result.modelVersion).toBe('ai-service-v1.0');
expect(mockFallback.estimateValue).not.toHaveBeenCalled();
});
it('falls back to PrismaAVM when AI service fails', async () => {
(mockAiClient.predict as ReturnType<typeof vi.fn>).mockRejectedValue(
new Error('ECONNREFUSED'),
);
mockPrisma.property.findUnique.mockResolvedValue({
areaM2: 80,
district: 'Quận 1',
city: 'Hồ Chí Minh',
propertyType: 'APARTMENT',
bedrooms: 2,
bathrooms: 2,
floors: null,
yearBuilt: 2020,
legalStatus: 'SO_DO',
});
const fallbackResult = {
estimatedPrice: '4800000000',
confidence: 0.6,
pricePerM2: 60_000_000,
comparables: [],
modelVersion: 'avm-v1.0',
};
(mockFallback.estimateValue as ReturnType<typeof vi.fn>).mockResolvedValue(fallbackResult);
const result = await service.estimateValue({ propertyId: 'prop-1' });
expect(result).toEqual(fallbackResult);
expect(mockFallback.estimateValue).toHaveBeenCalledWith({ propertyId: 'prop-1' });
});
it('uses coordinates directly when no propertyId', async () => {
(mockAiClient.predict as ReturnType<typeof vi.fn>).mockResolvedValue({
estimated_price_vnd: 4_000_000_000,
confidence: 0.65,
price_per_m2: 50_000_000,
price_range_low: 3_000_000_000,
price_range_high: 5_000_000_000,
});
const result = await service.estimateValue({
latitude: 10.762,
longitude: 106.66,
areaM2: 80,
propertyType: 'APARTMENT',
});
expect(result.estimatedPrice).toBe('4000000000');
expect(mockPrisma.property.findUnique).not.toHaveBeenCalled();
});
});
describe('getComparables', () => {
it('delegates to fallback PrismaAVM service', async () => {
const comparables = [{ propertyId: 'p1', distanceMeters: 100 }];
(mockFallback.getComparables as ReturnType<typeof vi.fn>).mockResolvedValue(comparables);
const result = await service.getComparables('prop-1', 2000);
expect(result).toEqual(comparables);
expect(mockFallback.getComparables).toHaveBeenCalledWith('prop-1', 2000);
});
});
});

View File

@@ -0,0 +1,108 @@
import { Injectable, Logger } from '@nestjs/common';
export interface AiPredictRequest {
area: number;
district: string;
city: string;
property_type: string;
bedrooms?: number;
bathrooms?: number;
floors?: number;
frontage?: number;
road_width?: number;
year_built?: number | null;
has_legal_paper?: boolean;
}
export interface AiPredictResponse {
estimated_price_vnd: number;
confidence: number;
price_per_m2: number;
price_range_low: number;
price_range_high: number;
}
export interface AiModerationRequest {
text: string;
context?: string;
}
export interface AiModerationFlag {
category: string;
severity: string;
matched_text: string;
reason: string;
}
export interface AiModerationResponse {
is_flagged: boolean;
score: number;
flags: AiModerationFlag[];
cleaned_text: string | null;
}
export const AI_SERVICE_CLIENT = Symbol('AI_SERVICE_CLIENT');
export interface IAiServiceClient {
predict(req: AiPredictRequest): Promise<AiPredictResponse>;
moderate(req: AiModerationRequest): Promise<AiModerationResponse>;
isAvailable(): Promise<boolean>;
}
@Injectable()
export class AiServiceClient implements IAiServiceClient {
private readonly logger = new Logger(AiServiceClient.name);
private readonly baseUrl: string;
private readonly apiKey: string;
private readonly timeoutMs: number;
constructor() {
this.baseUrl = process.env['AI_SERVICE_URL'] ?? 'http://localhost:8000';
this.apiKey = process.env['AI_SERVICE_API_KEY'] ?? '';
this.timeoutMs = Number(process.env['AI_SERVICE_TIMEOUT_MS']) || 5000;
}
async predict(req: AiPredictRequest): Promise<AiPredictResponse> {
return this.post<AiPredictResponse>('/avm/predict', req);
}
async moderate(req: AiModerationRequest): Promise<AiModerationResponse> {
return this.post<AiModerationResponse>('/moderation/check', req);
}
async isAvailable(): Promise<boolean> {
try {
const response = await fetch(`${this.baseUrl}/health`, {
method: 'GET',
signal: AbortSignal.timeout(2000),
});
return response.ok;
} catch {
return false;
}
}
private async post<T>(path: string, body: unknown): Promise<T> {
const url = `${this.baseUrl}${path}`;
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
if (this.apiKey) {
headers['X-API-Key'] = this.apiKey;
}
const response = await fetch(url, {
method: 'POST',
headers,
body: JSON.stringify(body),
signal: AbortSignal.timeout(this.timeoutMs),
});
if (!response.ok) {
const text = await response.text().catch(() => '');
throw new Error(`AI service ${path} returned ${response.status}: ${text}`);
}
return response.json() as Promise<T>;
}
}

View File

@@ -0,0 +1,111 @@
import { Inject, Injectable, Logger } from '@nestjs/common';
import { type PrismaService } from '@modules/shared';
import {
type IAVMService,
type AVMParams,
type ValuationResult,
type Comparable,
} from '../../domain/services/avm-service';
import {
AI_SERVICE_CLIENT,
type IAiServiceClient,
type AiPredictRequest,
} from './ai-service.client';
import { type PrismaAVMService } from './prisma-avm.service';
@Injectable()
export class HttpAVMService implements IAVMService {
private readonly logger = new Logger(HttpAVMService.name);
constructor(
@Inject(AI_SERVICE_CLIENT) private readonly aiClient: IAiServiceClient,
private readonly fallback: PrismaAVMService,
private readonly prisma: PrismaService,
) {}
async estimateValue(params: AVMParams): Promise<ValuationResult> {
try {
return await this.estimateViaAi(params);
} catch (err) {
this.logger.warn(
`AI AVM service unavailable, falling back to comparables-based estimation: ${(err as Error).message}`,
);
return this.fallback.estimateValue(params);
}
}
async getComparables(propertyId: string, radiusMeters: number): Promise<Comparable[]> {
return this.fallback.getComparables(propertyId, radiusMeters);
}
private async estimateViaAi(params: AVMParams): Promise<ValuationResult> {
const propertyData = params.propertyId
? await this.getPropertyDetails(params.propertyId)
: null;
const request: AiPredictRequest = {
area: params.areaM2 ?? propertyData?.areaM2 ?? 0,
district: propertyData?.district ?? '',
city: propertyData?.city ?? '',
property_type: (params.propertyType ?? propertyData?.propertyType ?? 'house').toLowerCase(),
bedrooms: propertyData?.bedrooms ?? 0,
bathrooms: propertyData?.bathrooms ?? 0,
floors: propertyData?.floors ?? 0,
frontage: 0,
road_width: 0,
year_built: params.yearBuilt ?? propertyData?.yearBuilt,
has_legal_paper: propertyData?.hasLegalPaper ?? true,
};
const aiResult = await this.aiClient.predict(request);
// Also fetch comparables from the local PostGIS service for context
let comparables: Comparable[] = [];
try {
if (params.propertyId) {
comparables = await this.fallback.getComparables(params.propertyId, 2000);
}
} catch {
// Comparables are supplementary — don't fail the valuation
}
return {
estimatedPrice: Math.round(aiResult.estimated_price_vnd).toString(),
confidence: aiResult.confidence,
pricePerM2: Math.round(aiResult.price_per_m2),
comparables,
modelVersion: 'ai-service-v1.0',
};
}
private async getPropertyDetails(propertyId: string) {
const row = await this.prisma.property.findUnique({
where: { id: propertyId },
select: {
areaM2: true,
district: true,
city: true,
propertyType: true,
bedrooms: true,
bathrooms: true,
floors: true,
yearBuilt: true,
legalStatus: true,
},
});
if (!row) return null;
return {
areaM2: row.areaM2,
district: row.district,
city: row.city,
propertyType: row.propertyType,
bedrooms: row.bedrooms ?? 0,
bathrooms: row.bathrooms ?? 0,
floors: row.floors ?? 0,
yearBuilt: row.yearBuilt,
hasLegalPaper: row.legalStatus === 'SO_DO' || row.legalStatus === 'SO_HONG',
};
}
}

View File

@@ -1 +1,5 @@
export { PrismaAVMService } from './prisma-avm.service';
export { HttpAVMService } from './http-avm.service';
export { AiServiceClient, AI_SERVICE_CLIENT } from './ai-service.client';
export type { IAiServiceClient, AiPredictRequest, AiPredictResponse, AiModerationRequest, AiModerationResponse } from './ai-service.client';
export { MarketIndexCronService } from './market-index-cron.service';

View File

@@ -0,0 +1,124 @@
import { Injectable, Logger } from '@nestjs/common';
import { type CommandBus } from '@nestjs/cqrs';
import { Cron, CronExpression } from '@nestjs/schedule';
import { PropertyType } from '@prisma/client';
import { type PrismaService } from '@modules/shared';
import { UpdateMarketIndexCommand } from '../../application/commands/update-market-index/update-market-index.command';
interface MarketStats {
district: string;
city: string;
propertyType: PropertyType;
medianPrice: bigint;
avgPriceM2: number;
totalListings: number;
avgDaysOnMarket: number;
inventoryLevel: number;
}
@Injectable()
export class MarketIndexCronService {
private readonly logger = new Logger(MarketIndexCronService.name);
constructor(
private readonly prisma: PrismaService,
private readonly commandBus: CommandBus,
) {}
@Cron(CronExpression.EVERY_DAY_AT_2AM, { name: 'market-index-calculation' })
async calculateMarketIndices(): Promise<void> {
this.logger.log('Starting market index calculation...');
const period = this.getCurrentPeriod();
try {
const stats = await this.aggregateMarketStats();
let updatedCount = 0;
for (const stat of stats) {
try {
await this.commandBus.execute(
new UpdateMarketIndexCommand(
stat.district,
stat.city,
stat.propertyType,
period,
stat.medianPrice,
stat.avgPriceM2,
stat.totalListings,
stat.avgDaysOnMarket,
stat.inventoryLevel,
),
);
updatedCount++;
} catch (err) {
this.logger.error(
`Failed to update market index for ${stat.district}/${stat.city}/${stat.propertyType}: ${(err as Error).message}`,
);
}
}
this.logger.log(
`Market index calculation completed: ${updatedCount}/${stats.length} indices updated for period ${period}`,
);
} catch (err) {
this.logger.error(`Market index calculation failed: ${(err as Error).message}`);
}
}
private async aggregateMarketStats(): Promise<MarketStats[]> {
const propertyTypes = Object.values(PropertyType);
const stats = await this.prisma.$queryRaw<
Array<{
district: string;
city: string;
property_type: PropertyType;
median_price: bigint;
avg_price_m2: number;
total_listings: bigint;
avg_days_on_market: number;
inventory_level: bigint;
}>
>`
SELECT
p.district,
p.city,
p."propertyType" AS property_type,
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY l."priceVND") AS median_price,
AVG(l."pricePerM2")::float AS avg_price_m2,
COUNT(l.id) AS total_listings,
AVG(
EXTRACT(EPOCH FROM (NOW() - l."publishedAt")) / 86400
)::float AS avg_days_on_market,
COUNT(l.id) FILTER (WHERE l.status = 'ACTIVE') AS inventory_level
FROM "Listing" l
JOIN "Property" p ON l."propertyId" = p.id
WHERE l.status IN ('ACTIVE', 'SOLD', 'RENTED')
AND l."publishedAt" IS NOT NULL
AND l."publishedAt" >= NOW() - INTERVAL '90 days'
AND p."propertyType" = ANY(${propertyTypes}::"PropertyType"[])
GROUP BY p.district, p.city, p."propertyType"
HAVING COUNT(l.id) >= 3
ORDER BY p.city, p.district, p."propertyType"
`;
return stats.map((s) => ({
district: s.district,
city: s.city,
propertyType: s.property_type,
medianPrice: BigInt(Math.round(Number(s.median_price))),
avgPriceM2: s.avg_price_m2,
totalListings: Number(s.total_listings),
avgDaysOnMarket: Math.round(s.avg_days_on_market * 10) / 10,
inventoryLevel: Number(s.inventory_level),
}));
}
private getCurrentPeriod(): string {
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
return `${year}-${month}`;
}
}

106
e2e/api/inquiries.spec.ts Normal file
View File

@@ -0,0 +1,106 @@
import { test, expect, createListing, registerUser } from '../fixtures';
test.describe('Inquiries API', () => {
test('POST /inquiries — creates inquiry for a listing', async ({
request,
authedRequest,
testTokens,
}) => {
const { listing } = await createListing(request, testTokens.accessToken);
const res = await authedRequest.post('/inquiries', {
data: {
listingId: listing.listingId,
message: 'Tôi muốn xem căn hộ này',
phone: '0901234567',
},
});
expect(res.status()).toBe(201);
const body = await res.json();
expect(body).toHaveProperty('inquiryId');
expect(body.listingId).toBe(listing.listingId);
});
test('POST /inquiries — rejects without auth', async ({ request }) => {
const res = await request.post('/inquiries', {
data: {
listingId: 'nonexistent',
message: 'Test inquiry',
},
});
expect(res.status()).toBe(401);
});
test('GET /inquiries/listing/:id — returns inquiries for a listing', async ({
request,
authedRequest,
testTokens,
}) => {
const { listing } = await createListing(request, testTokens.accessToken);
// Create an inquiry first
await authedRequest.post('/inquiries', {
data: {
listingId: listing.listingId,
message: 'Inquiry E2E test',
},
});
const res = await authedRequest.get(`/inquiries/listing/${listing.listingId}`);
expect(res.status()).toBe(200);
const body = await res.json();
expect(body).toHaveProperty('data');
expect(body).toHaveProperty('total');
expect(body.data.length).toBeGreaterThanOrEqual(1);
});
});
test.describe('Leads API', () => {
test('POST /leads — creates a lead (agent only)', async ({
request,
authedRequest,
testTokens,
}) => {
const { listing } = await createListing(request, testTokens.accessToken);
const res = await authedRequest.post('/leads', {
data: {
listingId: listing.listingId,
buyerName: 'Nguyễn Văn A',
buyerPhone: '0912345678',
source: 'WEBSITE',
notes: 'Khách quan tâm căn hộ Q1',
},
});
// May fail if user is not AGENT role — that's expected behavior
if (res.status() === 201) {
const body = await res.json();
expect(body).toHaveProperty('leadId');
} else {
// Non-agent users get 403
expect(res.status()).toBe(403);
}
});
});
test.describe('Agent Dashboard API', () => {
test('GET /agents/dashboard — returns stats for agent', async ({
authedRequest,
}) => {
const res = await authedRequest.get('/agents/dashboard');
// May return 403 if test user is not an agent
if (res.status() === 200) {
const body = await res.json();
expect(body).toHaveProperty('qualityScore');
expect(body).toHaveProperty('totalLeads');
expect(body).toHaveProperty('totalInquiries');
} else {
expect(res.status()).toBe(403);
}
});
});

495
pnpm-lock.yaml generated
View File

@@ -99,6 +99,9 @@ importers:
'@nestjs/platform-express':
specifier: ^11.0.0
version: 11.1.18(@nestjs/common@11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.18)
'@nestjs/schedule':
specifier: ^6.1.1
version: 6.1.1(@nestjs/common@11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.18)
'@nestjs/swagger':
specifier: ^11.2.6
version: 11.2.6(@nestjs/common@11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.18)(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)
@@ -195,7 +198,7 @@ importers:
devDependencies:
'@nestjs/cli':
specifier: ^11.0.0
version: 11.0.18(@types/node@25.5.2)
version: 11.0.18(@swc/core@1.15.24)(@types/node@25.5.2)
'@nestjs/schematics':
specifier: ^11.0.0
version: 11.0.10(chokidar@4.0.3)(typescript@6.0.2)
@@ -274,6 +277,9 @@ importers:
next:
specifier: ^14.2.0
version: 14.2.35(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.59.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
next-intl:
specifier: ^4.9.0
version: 4.9.0(next@14.2.35(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.59.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(typescript@6.0.2)
react:
specifier: ^18.3.0
version: 18.3.1
@@ -978,6 +984,24 @@ packages:
resolution: {integrity: sha512-AmWf3cHAOMbrCPG4xdPKQaj5iHnyYfyLKZxwz+Xf55bqKbpAmcYifB4jQinT2W9XhDRHISOoPyBOariJpCG6FA==}
engines: {node: '>=20.0.0'}
'@formatjs/bigdecimal@0.2.0':
resolution: {integrity: sha512-GeaxHZbUoYvHL9tC5eltHLs+1zU70aPw0s7LwqgktIzF5oMhNY4o4deEtusJMsq7WFJF3Ye2zQEzdG8beVk73w==}
'@formatjs/ecma402-abstract@3.2.0':
resolution: {integrity: sha512-dHnqHgBo6GXYGRsepaE1wmsC2etaivOWd5VaJstZd+HI2zR3DCUjbDVZRtoPGkkXZmyHvBwrdEUuqfvzhF/DtQ==}
'@formatjs/fast-memoize@3.1.1':
resolution: {integrity: sha512-CbNbf+tlJn1baRnPkNePnBqTLxGliG6DDgNa/UtV66abwIjwsliPMOt0172tzxABYzSuxZBZfcp//qI8AvBWPg==}
'@formatjs/icu-messageformat-parser@3.5.3':
resolution: {integrity: sha512-HJWZ9S6JWey6iY5+YXE3Kd0ofWU1sC2KTTp56e1168g/xxWvVvr8k9G4fexIgwYV9wbtjY7kGYK5FjoWB3B2OQ==}
'@formatjs/icu-skeleton-parser@2.1.3':
resolution: {integrity: sha512-9mFp8TJ166ZM2pcjKwsBWXrDnOJGT7vMEScVgLygUODPOsE8S6f/FHoacvrlHK1B4dYZk8vSCNruyPU64AfgJQ==}
'@formatjs/intl-localematcher@0.8.2':
resolution: {integrity: sha512-q05KMYGJLyqFNFtIb8NhWLF5X3aK/k0wYt7dnRFuy6aLQL+vUwQ1cg5cO4qawEiINybeCPXAWlprY2mSBjSXAQ==}
'@google-cloud/firestore@7.11.6':
resolution: {integrity: sha512-EW/O8ktzwLfyWBOsNuhRoMi8lrC3clHM5LVFhGvO1HCsLozCOOXRAlHrYBoE6HL42Sc8yYMuCb2XqcnJ4OOEpw==}
engines: {node: '>=14.0.0'}
@@ -1347,6 +1371,12 @@ packages:
'@nestjs/common': ^11.0.0
'@nestjs/core': ^11.0.0
'@nestjs/schedule@6.1.1':
resolution: {integrity: sha512-kQl1RRgi02GJ0uaUGCrXHCcwISsCsJDciCKe38ykJZgnAeeoeVWs8luWtBo4AqAAXm4nS5K8RlV0smHUJ4+2FA==}
peerDependencies:
'@nestjs/common': ^10.0.0 || ^11.0.0
'@nestjs/core': ^10.0.0 || ^11.0.0
'@nestjs/schematics@11.0.10':
resolution: {integrity: sha512-q9lr0wGwgBHLarD4uno3XiW4JX60WPlg2VTgbqPHl/6bT4u1IEEzj+q9Tad3bVnqL5mlDF3vrZ2tj+x13CJpmw==}
peerDependencies:
@@ -1748,6 +1778,88 @@ packages:
resolution: {integrity: sha512-OqiFvSOF0dBSesELYY2CAMa4YINvlLpvKOz/rv6NeZEqiyttlHgv98Juwv4Ch+GrEV7IZ8jfI2VcEoYUjXXCjw==}
hasBin: true
'@parcel/watcher-android-arm64@2.5.6':
resolution: {integrity: sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [android]
'@parcel/watcher-darwin-arm64@2.5.6':
resolution: {integrity: sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [darwin]
'@parcel/watcher-darwin-x64@2.5.6':
resolution: {integrity: sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [darwin]
'@parcel/watcher-freebsd-x64@2.5.6':
resolution: {integrity: sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [freebsd]
'@parcel/watcher-linux-arm-glibc@2.5.6':
resolution: {integrity: sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==}
engines: {node: '>= 10.0.0'}
cpu: [arm]
os: [linux]
'@parcel/watcher-linux-arm-musl@2.5.6':
resolution: {integrity: sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==}
engines: {node: '>= 10.0.0'}
cpu: [arm]
os: [linux]
'@parcel/watcher-linux-arm64-glibc@2.5.6':
resolution: {integrity: sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [linux]
'@parcel/watcher-linux-arm64-musl@2.5.6':
resolution: {integrity: sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [linux]
'@parcel/watcher-linux-x64-glibc@2.5.6':
resolution: {integrity: sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [linux]
'@parcel/watcher-linux-x64-musl@2.5.6':
resolution: {integrity: sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [linux]
'@parcel/watcher-win32-arm64@2.5.6':
resolution: {integrity: sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [win32]
'@parcel/watcher-win32-ia32@2.5.6':
resolution: {integrity: sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==}
engines: {node: '>= 10.0.0'}
cpu: [ia32]
os: [win32]
'@parcel/watcher-win32-x64@2.5.6':
resolution: {integrity: sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [win32]
'@parcel/watcher@2.5.6':
resolution: {integrity: sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==}
engines: {node: '>= 10.0.0'}
'@pinojs/redact@0.4.0':
resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==}
@@ -2088,6 +2200,9 @@ packages:
'@scarf/scarf@1.4.0':
resolution: {integrity: sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==}
'@schummar/icu-type-parser@1.21.5':
resolution: {integrity: sha512-bXHSaW5jRTmke9Vd0h5P7BtWZG9Znqb8gSDxZnxaGSJnGwPLDPfS+3g0BKzeWqzgZPsIVZkM7m2tbo18cm5HBw==}
'@sentry-internal/browser-utils@10.47.0':
resolution: {integrity: sha512-bVFRAeJWMBcBCvJKIFCMJ1/yQToL4vPGqfmlnDZeypcxkqUDKQ/Y3ziLHXoDL2sx0lagcgU2vH1QhCQ67Aujjw==}
engines: {node: '>=18'}
@@ -2472,12 +2587,96 @@ packages:
'@standard-schema/utils@0.3.0':
resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==}
'@swc/core-darwin-arm64@1.15.24':
resolution: {integrity: sha512-uM5ZGfFXjtvtJ+fe448PVBEbn/CSxS3UAyLj3O9xOqKIWy3S6hPTXSPbszxkSsGDYKi+YFhzAsR4r/eXLxEQ0g==}
engines: {node: '>=10'}
cpu: [arm64]
os: [darwin]
'@swc/core-darwin-x64@1.15.24':
resolution: {integrity: sha512-fMIb/Zfn929pw25VMBhV7Ji2Dl+lCWtUPNdYJQYOke+00E5fcQ9ynxtP8+qhUo/HZc+mYQb1gJxwHM9vty+lXg==}
engines: {node: '>=10'}
cpu: [x64]
os: [darwin]
'@swc/core-linux-arm-gnueabihf@1.15.24':
resolution: {integrity: sha512-vOkjsyjjxnoYx3hMEWcGxQrMgnNrRm6WAegBXrN8foHtDAR+zpdhpGF5a4lj1bNPgXAvmysjui8cM1ov/Clkaw==}
engines: {node: '>=10'}
cpu: [arm]
os: [linux]
'@swc/core-linux-arm64-gnu@1.15.24':
resolution: {integrity: sha512-h/oNu+upkXJ6Cicnq7YGVj9PkdfarLCdQa8l/FlHYvfv8CEiMaeeTnpLU7gSBH/rGxosM6Qkfa/J9mThGF9CLA==}
engines: {node: '>=10'}
cpu: [arm64]
os: [linux]
'@swc/core-linux-arm64-musl@1.15.24':
resolution: {integrity: sha512-ZpF/pRe1guk6sKzQI9D1jAORtjTdNlyeXn9GDz8ophof/w2WhojRblvSDJaGe7rJjcPN8AaOkhwdRUh7q8oYIg==}
engines: {node: '>=10'}
cpu: [arm64]
os: [linux]
'@swc/core-linux-ppc64-gnu@1.15.24':
resolution: {integrity: sha512-QZEsZfisHTSJlmyChgDFNmKPb3W6Lhbfo/O76HhIngfEdnQNmukS38/VSe1feho+xkV5A5hETyCbx3sALBZKAQ==}
engines: {node: '>=10'}
cpu: [ppc64]
os: [linux]
'@swc/core-linux-s390x-gnu@1.15.24':
resolution: {integrity: sha512-DLdJKVsJgglqQrJBuoUYNmzm3leI7kUZhLbZGHv42onfKsGf6JDS3+bzCUQfte/XOqDjh/tmmn1DR/CF/tCJFw==}
engines: {node: '>=10'}
cpu: [s390x]
os: [linux]
'@swc/core-linux-x64-gnu@1.15.24':
resolution: {integrity: sha512-IpLYfposPA/XLxYOKpRfeccl1p5dDa3+okZDHHTchBkXEaVCnq5MADPmIWwIYj1tudt7hORsEHccG5no6IUQRw==}
engines: {node: '>=10'}
cpu: [x64]
os: [linux]
'@swc/core-linux-x64-musl@1.15.24':
resolution: {integrity: sha512-JHy3fMSc0t/EPWgo74+OK5TGr51aElnzqfUPaiRf2qJ/BfX5CUCfMiWVBuhI7qmVMBnk1jTRnL/xZnOSHDPLYg==}
engines: {node: '>=10'}
cpu: [x64]
os: [linux]
'@swc/core-win32-arm64-msvc@1.15.24':
resolution: {integrity: sha512-Txj+qUH1z2bUd1P3JvwByfjKFti3cptlAxhWgmunBUUxy/IW3CXLZ6l6Gk4liANadKkU71nIU1X30Z5vpMT3BA==}
engines: {node: '>=10'}
cpu: [arm64]
os: [win32]
'@swc/core-win32-ia32-msvc@1.15.24':
resolution: {integrity: sha512-15D/nl3XwrhFpMv+MADFOiVwv3FvH9j8c6Rf8EXBT3Q5LoMh8YnDnSgPYqw1JzPnksvsBX6QPXLiPqmcR/Z4qQ==}
engines: {node: '>=10'}
cpu: [ia32]
os: [win32]
'@swc/core-win32-x64-msvc@1.15.24':
resolution: {integrity: sha512-PR0PlTlPra2JbaDphrOAzm6s0v9rA0F17YzB+XbWD95B4g2cWcZY9LAeTa4xll70VLw9Jr7xBrlohqlQmelMFQ==}
engines: {node: '>=10'}
cpu: [x64]
os: [win32]
'@swc/core@1.15.24':
resolution: {integrity: sha512-5Hj8aNasue7yusUt8LGCUe/AjM7RMAce8ZoyDyiFwx7Al+GbYKL+yE7g4sJk8vEr1dKIkTRARkNIJENc4CjkBQ==}
engines: {node: '>=10'}
peerDependencies:
'@swc/helpers': '>=0.5.17'
peerDependenciesMeta:
'@swc/helpers':
optional: true
'@swc/counter@0.1.3':
resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==}
'@swc/helpers@0.5.5':
resolution: {integrity: sha512-KGYxvIOXcceOAbEk4bi/dVLEK9z8sZ0uBB3Il5b1rhfClSpcX0yfRO0KmTkqR2cnQDymwLB+25ZyMzICg/cm/A==}
'@swc/types@0.1.26':
resolution: {integrity: sha512-lyMwd7WGgG79RS7EERZV3T8wMdmPq3xwyg+1nmAM64kIhx5yl+juO2PYIHb7vTiPgPCj8LYjsNV2T5wiQHUEaw==}
'@tanstack/query-core@5.96.2':
resolution: {integrity: sha512-hzI6cTVh4KNRk8UtoIBS7Lv9g6BnJPXvBKsvYH1aGWvv0347jT3BnSvztOE+kD76XGvZnRC/t6qdW1CaIfwCeA==}
@@ -2660,6 +2859,9 @@ packages:
'@types/long@4.0.2':
resolution: {integrity: sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==}
'@types/luxon@3.7.1':
resolution: {integrity: sha512-H3iskjFIAn5SlJU7OuxUmTEpebK6TKB8rxZShDslBMZJ5u9S//KM1sbdAisiSrqwLQncVjnpi2OK2J51h+4lsg==}
'@types/mapbox-gl@3.5.0':
resolution: {integrity: sha512-3wVAUTC6q1UKatLP9YxFBnGJWi3neJUF9OKeyRdUf/BsYjZAP35xmZkL4zogVJbO3vdExuSVYCAkzUXjpjdhOg==}
deprecated: This is a stub types definition. mapbox-gl provides its own type definitions, so you do not need this installed.
@@ -3502,6 +3704,10 @@ packages:
typescript:
optional: true
cron@4.4.0:
resolution: {integrity: sha512-fkdfq+b+AHI4cKdhZlppHveI/mgz2qpiYxcm+t5E5TsxX7QrLS1VE0+7GENEk9z0EeGPcpSciGv6ez24duWhwQ==}
engines: {node: '>=18.x'}
cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
@@ -4301,6 +4507,9 @@ packages:
resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==}
engines: {node: '>=0.10.0'}
icu-minify@4.9.0:
resolution: {integrity: sha512-9ev7MqkN29jcIelUAqJRfNCxzGOEkBJPnr+scYATMp2bfpU4Bm1eIwYU0/o5xRy8BBnSWMUjK58WTB3132P0bg==}
ieee754@1.2.1:
resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
@@ -4352,6 +4561,9 @@ packages:
resolution: {integrity: sha512-6xwYfHbajpoF0xLW+iwLkhwgvLoZDfjYfoFNu8ftMoXINzwuymNLd9u/KmwtdT2GbR+/Cz66otEGEVVUHX9QLQ==}
engines: {node: '>=10.13.0'}
intl-messageformat@11.2.0:
resolution: {integrity: sha512-IhghAA8n4KSlXuWKzYsWyWb82JoYTzShfyvdSF85oJPnNOjvv4kAo7S7Jtkm3/vJ53C7dQNRO+Gpnj3iWgTjBQ==}
ioredis@5.10.1:
resolution: {integrity: sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA==}
engines: {node: '>=12.22.0'}
@@ -4665,6 +4877,10 @@ packages:
peerDependencies:
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
luxon@3.7.2:
resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==}
engines: {node: '>=12'}
lz-string@1.5.0:
resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==}
hasBin: true
@@ -4829,6 +5045,19 @@ packages:
neo-async@2.6.2:
resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==}
next-intl-swc-plugin-extractor@4.9.0:
resolution: {integrity: sha512-CAu6Qy6XiCenKsvzyCPm2cZFkGfcvhJi8N93TCnOowmzD4Br3ked7QdROusRRp4MQ1iG9u+KCLgVcM9CLDUOIQ==}
next-intl@4.9.0:
resolution: {integrity: sha512-MMNAjewHUw9Ke93E5/Yzhf8lqesesaXJTPlrK3FwECgn4EXG9m7Tuzy4rnDes0ogjDhQIa/Ksj/qmFnHJAOluw==}
peerDependencies:
next: ^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0 || ^16.0.0
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0
typescript: '*'
peerDependenciesMeta:
typescript:
optional: true
next@14.2.35:
resolution: {integrity: sha512-KhYd2Hjt/O1/1aZVX3dCwGXM1QmOV4eNM2UTacK5gipDdPN/oHHK/4oVGy7X8GMfPMsUTUEmGlsy0EY1YGAkig==}
engines: {node: '>=18.17.0'}
@@ -4854,6 +5083,9 @@ packages:
node-abort-controller@3.1.1:
resolution: {integrity: sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==}
node-addon-api@7.1.1:
resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==}
node-addon-api@8.7.0:
resolution: {integrity: sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA==}
engines: {node: ^18 || ^20 || >= 21}
@@ -5139,6 +5371,9 @@ packages:
resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==}
engines: {node: '>=4'}
po-parser@2.1.1:
resolution: {integrity: sha512-ECF4zHLbUItpUgE3OTtLKlPjeBN+fKEczj2zYjDfCGOzicNs0GK3Vg2IoAYwx7LH/XYw43fZQP6xnZ4TkNxSLQ==}
postcss-import@15.1.0:
resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==}
engines: {node: '>=14.0.0'}
@@ -6017,6 +6252,11 @@ packages:
uri-js@4.4.1:
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
use-intl@4.9.0:
resolution: {integrity: sha512-GehJvP7gu8SvmaDHNDNrRHt2TCNSZt4l1cGJMpUX77TGeZPAQKVQokAVvoYkeTT1UWPtv9RJ6N16UJNButzrgg==}
peerDependencies:
react: ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0
use-sync-external-store@1.6.0:
resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==}
peerDependencies:
@@ -7188,6 +7428,29 @@ snapshots:
dependencies:
tslib: 2.8.1
'@formatjs/bigdecimal@0.2.0': {}
'@formatjs/ecma402-abstract@3.2.0':
dependencies:
'@formatjs/bigdecimal': 0.2.0
'@formatjs/fast-memoize': 3.1.1
'@formatjs/intl-localematcher': 0.8.2
'@formatjs/fast-memoize@3.1.1': {}
'@formatjs/icu-messageformat-parser@3.5.3':
dependencies:
'@formatjs/ecma402-abstract': 3.2.0
'@formatjs/icu-skeleton-parser': 2.1.3
'@formatjs/icu-skeleton-parser@2.1.3':
dependencies:
'@formatjs/ecma402-abstract': 3.2.0
'@formatjs/intl-localematcher@0.8.2':
dependencies:
'@formatjs/fast-memoize': 3.1.1
'@google-cloud/firestore@7.11.6':
dependencies:
'@opentelemetry/api': 1.9.1
@@ -7509,7 +7772,7 @@ snapshots:
'@tybys/wasm-util': 0.10.1
optional: true
'@nestjs/cli@11.0.18(@types/node@25.5.2)':
'@nestjs/cli@11.0.18(@swc/core@1.15.24)(@types/node@25.5.2)':
dependencies:
'@angular-devkit/core': 19.2.23(chokidar@4.0.3)
'@angular-devkit/schematics': 19.2.23(chokidar@4.0.3)
@@ -7520,15 +7783,17 @@ snapshots:
chokidar: 4.0.3
cli-table3: 0.6.5
commander: 4.1.1
fork-ts-checker-webpack-plugin: 9.1.0(typescript@5.9.3)(webpack@5.105.4)
fork-ts-checker-webpack-plugin: 9.1.0(typescript@5.9.3)(webpack@5.105.4(@swc/core@1.15.24))
glob: 13.0.6
node-emoji: 1.11.0
ora: 5.4.1
tsconfig-paths: 4.2.0
tsconfig-paths-webpack-plugin: 4.2.0
typescript: 5.9.3
webpack: 5.105.4
webpack: 5.105.4(@swc/core@1.15.24)
webpack-node-externals: 3.0.0
optionalDependencies:
'@swc/core': 1.15.24
transitivePeerDependencies:
- '@types/node'
- esbuild
@@ -7608,6 +7873,12 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@nestjs/schedule@6.1.1(@nestjs/common@11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.18)':
dependencies:
'@nestjs/common': 11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/core': 11.1.18(@nestjs/common@11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.18)(reflect-metadata@0.2.2)(rxjs@7.8.2)
cron: 4.4.0
'@nestjs/schematics@11.0.10(chokidar@4.0.3)(typescript@5.9.3)':
dependencies:
'@angular-devkit/core': 19.2.23(chokidar@4.0.3)
@@ -8013,6 +8284,66 @@ snapshots:
bignumber.js: 9.3.1
error-causes: 3.0.2
'@parcel/watcher-android-arm64@2.5.6':
optional: true
'@parcel/watcher-darwin-arm64@2.5.6':
optional: true
'@parcel/watcher-darwin-x64@2.5.6':
optional: true
'@parcel/watcher-freebsd-x64@2.5.6':
optional: true
'@parcel/watcher-linux-arm-glibc@2.5.6':
optional: true
'@parcel/watcher-linux-arm-musl@2.5.6':
optional: true
'@parcel/watcher-linux-arm64-glibc@2.5.6':
optional: true
'@parcel/watcher-linux-arm64-musl@2.5.6':
optional: true
'@parcel/watcher-linux-x64-glibc@2.5.6':
optional: true
'@parcel/watcher-linux-x64-musl@2.5.6':
optional: true
'@parcel/watcher-win32-arm64@2.5.6':
optional: true
'@parcel/watcher-win32-ia32@2.5.6':
optional: true
'@parcel/watcher-win32-x64@2.5.6':
optional: true
'@parcel/watcher@2.5.6':
dependencies:
detect-libc: 2.1.2
is-glob: 4.0.3
node-addon-api: 7.1.1
picomatch: 4.0.4
optionalDependencies:
'@parcel/watcher-android-arm64': 2.5.6
'@parcel/watcher-darwin-arm64': 2.5.6
'@parcel/watcher-darwin-x64': 2.5.6
'@parcel/watcher-freebsd-x64': 2.5.6
'@parcel/watcher-linux-arm-glibc': 2.5.6
'@parcel/watcher-linux-arm-musl': 2.5.6
'@parcel/watcher-linux-arm64-glibc': 2.5.6
'@parcel/watcher-linux-arm64-musl': 2.5.6
'@parcel/watcher-linux-x64-glibc': 2.5.6
'@parcel/watcher-linux-x64-musl': 2.5.6
'@parcel/watcher-win32-arm64': 2.5.6
'@parcel/watcher-win32-ia32': 2.5.6
'@parcel/watcher-win32-x64': 2.5.6
'@pinojs/redact@0.4.0': {}
'@playwright/test@1.59.1':
@@ -8325,6 +8656,8 @@ snapshots:
'@scarf/scarf@1.4.0': {}
'@schummar/icu-type-parser@1.21.5': {}
'@sentry-internal/browser-utils@10.47.0':
dependencies:
'@sentry/core': 10.47.0
@@ -8889,6 +9222,60 @@ snapshots:
'@standard-schema/utils@0.3.0': {}
'@swc/core-darwin-arm64@1.15.24':
optional: true
'@swc/core-darwin-x64@1.15.24':
optional: true
'@swc/core-linux-arm-gnueabihf@1.15.24':
optional: true
'@swc/core-linux-arm64-gnu@1.15.24':
optional: true
'@swc/core-linux-arm64-musl@1.15.24':
optional: true
'@swc/core-linux-ppc64-gnu@1.15.24':
optional: true
'@swc/core-linux-s390x-gnu@1.15.24':
optional: true
'@swc/core-linux-x64-gnu@1.15.24':
optional: true
'@swc/core-linux-x64-musl@1.15.24':
optional: true
'@swc/core-win32-arm64-msvc@1.15.24':
optional: true
'@swc/core-win32-ia32-msvc@1.15.24':
optional: true
'@swc/core-win32-x64-msvc@1.15.24':
optional: true
'@swc/core@1.15.24':
dependencies:
'@swc/counter': 0.1.3
'@swc/types': 0.1.26
optionalDependencies:
'@swc/core-darwin-arm64': 1.15.24
'@swc/core-darwin-x64': 1.15.24
'@swc/core-linux-arm-gnueabihf': 1.15.24
'@swc/core-linux-arm64-gnu': 1.15.24
'@swc/core-linux-arm64-musl': 1.15.24
'@swc/core-linux-ppc64-gnu': 1.15.24
'@swc/core-linux-s390x-gnu': 1.15.24
'@swc/core-linux-x64-gnu': 1.15.24
'@swc/core-linux-x64-musl': 1.15.24
'@swc/core-win32-arm64-msvc': 1.15.24
'@swc/core-win32-ia32-msvc': 1.15.24
'@swc/core-win32-x64-msvc': 1.15.24
'@swc/counter@0.1.3': {}
'@swc/helpers@0.5.5':
@@ -8896,6 +9283,10 @@ snapshots:
'@swc/counter': 0.1.3
tslib: 2.8.1
'@swc/types@0.1.26':
dependencies:
'@swc/counter': 0.1.3
'@tanstack/query-core@5.96.2': {}
'@tanstack/react-query@5.96.2(react@18.3.1)':
@@ -9091,6 +9482,8 @@ snapshots:
'@types/long@4.0.2':
optional: true
'@types/luxon@3.7.1': {}
'@types/mapbox-gl@3.5.0':
dependencies:
mapbox-gl: 3.21.0
@@ -9981,6 +10374,11 @@ snapshots:
optionalDependencies:
typescript: 5.9.3
cron@4.4.0:
dependencies:
'@types/luxon': 3.7.1
luxon: 3.7.2
cross-spawn@7.0.6:
dependencies:
path-key: 3.1.1
@@ -10569,7 +10967,7 @@ snapshots:
cross-spawn: 7.0.6
signal-exit: 4.1.0
fork-ts-checker-webpack-plugin@9.1.0(typescript@5.9.3)(webpack@5.105.4):
fork-ts-checker-webpack-plugin@9.1.0(typescript@5.9.3)(webpack@5.105.4(@swc/core@1.15.24)):
dependencies:
'@babel/code-frame': 7.29.0
chalk: 4.1.2
@@ -10584,7 +10982,7 @@ snapshots:
semver: 7.7.4
tapable: 2.3.2
typescript: 5.9.3
webpack: 5.105.4
webpack: 5.105.4(@swc/core@1.15.24)
form-data@2.5.5:
dependencies:
@@ -10905,6 +11303,10 @@ snapshots:
dependencies:
safer-buffer: 2.1.2
icu-minify@4.9.0:
dependencies:
'@formatjs/icu-messageformat-parser': 3.5.3
ieee754@1.2.1: {}
ignore@5.3.2: {}
@@ -10946,6 +11348,12 @@ snapshots:
interpret@3.1.1: {}
intl-messageformat@11.2.0:
dependencies:
'@formatjs/ecma402-abstract': 3.2.0
'@formatjs/fast-memoize': 3.1.1
'@formatjs/icu-messageformat-parser': 3.5.3
ioredis@5.10.1:
dependencies:
'@ioredis/commands': 1.5.1
@@ -11252,6 +11660,8 @@ snapshots:
dependencies:
react: 18.3.1
luxon@3.7.2: {}
lz-string@1.5.0: {}
magic-string@0.30.17:
@@ -11427,6 +11837,25 @@ snapshots:
neo-async@2.6.2: {}
next-intl-swc-plugin-extractor@4.9.0: {}
next-intl@4.9.0(next@14.2.35(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.59.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(typescript@6.0.2):
dependencies:
'@formatjs/intl-localematcher': 0.8.2
'@parcel/watcher': 2.5.6
'@swc/core': 1.15.24
icu-minify: 4.9.0
negotiator: 1.0.0
next: 14.2.35(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.59.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
next-intl-swc-plugin-extractor: 4.9.0
po-parser: 2.1.1
react: 18.3.1
use-intl: 4.9.0(react@18.3.1)
optionalDependencies:
typescript: 6.0.2
transitivePeerDependencies:
- '@swc/helpers'
next@14.2.35(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.59.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies:
'@next/env': 14.2.35
@@ -11460,6 +11889,8 @@ snapshots:
node-abort-controller@3.1.1: {}
node-addon-api@7.1.1: {}
node-addon-api@8.7.0: {}
node-domexception@1.0.0: {}
@@ -11735,6 +12166,8 @@ snapshots:
pluralize@8.0.0: {}
po-parser@2.1.1: {}
postcss-import@15.1.0(postcss@8.5.8):
dependencies:
postcss: 8.5.8
@@ -12469,6 +12902,16 @@ snapshots:
- supports-color
optional: true
terser-webpack-plugin@5.4.0(@swc/core@1.15.24)(webpack@5.105.4(@swc/core@1.15.24)):
dependencies:
'@jridgewell/trace-mapping': 0.3.31
jest-worker: 27.5.1
schema-utils: 4.3.3
terser: 5.46.1
webpack: 5.105.4(@swc/core@1.15.24)
optionalDependencies:
'@swc/core': 1.15.24
terser-webpack-plugin@5.4.0(webpack@5.105.4):
dependencies:
'@jridgewell/trace-mapping': 0.3.31
@@ -12685,6 +13128,14 @@ snapshots:
dependencies:
punycode: 2.3.1
use-intl@4.9.0(react@18.3.1):
dependencies:
'@formatjs/fast-memoize': 3.1.1
'@schummar/icu-type-parser': 1.21.5
icu-minify: 4.9.0
intl-messageformat: 11.2.0
react: 18.3.1
use-sync-external-store@1.6.0(react@18.3.1):
dependencies:
react: 18.3.1
@@ -12872,6 +13323,38 @@ snapshots:
- esbuild
- uglify-js
webpack@5.105.4(@swc/core@1.15.24):
dependencies:
'@types/eslint-scope': 3.7.7
'@types/estree': 1.0.8
'@types/json-schema': 7.0.15
'@webassemblyjs/ast': 1.14.1
'@webassemblyjs/wasm-edit': 1.14.1
'@webassemblyjs/wasm-parser': 1.14.1
acorn: 8.16.0
acorn-import-phases: 1.0.4(acorn@8.16.0)
browserslist: 4.28.2
chrome-trace-event: 1.0.4
enhanced-resolve: 5.20.1
es-module-lexer: 2.0.0
eslint-scope: 5.1.1
events: 3.3.0
glob-to-regexp: 0.4.1
graceful-fs: 4.2.11
json-parse-even-better-errors: 2.3.1
loader-runner: 4.3.1
mime-types: 2.1.35
neo-async: 2.6.2
schema-utils: 4.3.3
tapable: 2.3.2
terser-webpack-plugin: 5.4.0(@swc/core@1.15.24)(webpack@5.105.4(@swc/core@1.15.24))
watchpack: 2.5.1
webpack-sources: 3.3.4
transitivePeerDependencies:
- '@swc/core'
- esbuild
- uglify-js
websocket-driver@0.7.4:
dependencies:
http-parser-js: 0.5.10