diff --git a/apps/api/package.json b/apps/api/package.json index adf7b52..a507840 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -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", diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index f223921..35e1940 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -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, diff --git a/apps/api/src/modules/analytics/analytics.module.ts b/apps/api/src/modules/analytics/analytics.module.ts index 5fba928..36f62cd 100644 --- a/apps/api/src/modules/analytics/analytics.module.ts +++ b/apps/api/src/modules/analytics/analytics.module.ts @@ -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 {} diff --git a/apps/api/src/modules/analytics/application/__tests__/listing-created-moderation.handler.spec.ts b/apps/api/src/modules/analytics/application/__tests__/listing-created-moderation.handler.spec.ts new file mode 100644 index 0000000..3894d7e --- /dev/null +++ b/apps/api/src/modules/analytics/application/__tests__/listing-created-moderation.handler.spec.ts @@ -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 } }; + + 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).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).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).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).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).mockRejectedValue( + new Error('ECONNREFUSED'), + ); + + await expect(handler.handle(event)).resolves.not.toThrow(); + expect(mockCommandBus.execute).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/modules/analytics/application/event-handlers/listing-created-moderation.handler.ts b/apps/api/src/modules/analytics/application/event-handlers/listing-created-moderation.handler.ts new file mode 100644 index 0000000..19de18f --- /dev/null +++ b/apps/api/src/modules/analytics/application/event-handlers/listing-created-moderation.handler.ts @@ -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 { + 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 { + 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 { + 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}`, + ), + ); + } +} diff --git a/apps/api/src/modules/analytics/infrastructure/__tests__/ai-service.client.spec.ts b/apps/api/src/modules/analytics/infrastructure/__tests__/ai-service.client.spec.ts new file mode 100644 index 0000000..e15b71a --- /dev/null +++ b/apps/api/src/modules/analytics/infrastructure/__tests__/ai-service.client.spec.ts @@ -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); + }); + }); +}); diff --git a/apps/api/src/modules/analytics/infrastructure/__tests__/http-avm.service.spec.ts b/apps/api/src/modules/analytics/infrastructure/__tests__/http-avm.service.spec.ts new file mode 100644 index 0000000..42db0e8 --- /dev/null +++ b/apps/api/src/modules/analytics/infrastructure/__tests__/http-avm.service.spec.ts @@ -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 } }; + + 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).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).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).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).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).mockResolvedValue(comparables); + + const result = await service.getComparables('prop-1', 2000); + + expect(result).toEqual(comparables); + expect(mockFallback.getComparables).toHaveBeenCalledWith('prop-1', 2000); + }); + }); +}); diff --git a/apps/api/src/modules/analytics/infrastructure/services/ai-service.client.ts b/apps/api/src/modules/analytics/infrastructure/services/ai-service.client.ts new file mode 100644 index 0000000..8c25290 --- /dev/null +++ b/apps/api/src/modules/analytics/infrastructure/services/ai-service.client.ts @@ -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; + moderate(req: AiModerationRequest): Promise; + isAvailable(): Promise; +} + +@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 { + return this.post('/avm/predict', req); + } + + async moderate(req: AiModerationRequest): Promise { + return this.post('/moderation/check', req); + } + + async isAvailable(): Promise { + try { + const response = await fetch(`${this.baseUrl}/health`, { + method: 'GET', + signal: AbortSignal.timeout(2000), + }); + return response.ok; + } catch { + return false; + } + } + + private async post(path: string, body: unknown): Promise { + const url = `${this.baseUrl}${path}`; + const headers: Record = { + '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; + } +} diff --git a/apps/api/src/modules/analytics/infrastructure/services/http-avm.service.ts b/apps/api/src/modules/analytics/infrastructure/services/http-avm.service.ts new file mode 100644 index 0000000..6f83b6a --- /dev/null +++ b/apps/api/src/modules/analytics/infrastructure/services/http-avm.service.ts @@ -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 { + 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 { + return this.fallback.getComparables(propertyId, radiusMeters); + } + + private async estimateViaAi(params: AVMParams): Promise { + 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', + }; + } +} diff --git a/apps/api/src/modules/analytics/infrastructure/services/index.ts b/apps/api/src/modules/analytics/infrastructure/services/index.ts index e1184ed..e565e64 100644 --- a/apps/api/src/modules/analytics/infrastructure/services/index.ts +++ b/apps/api/src/modules/analytics/infrastructure/services/index.ts @@ -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'; diff --git a/apps/api/src/modules/analytics/infrastructure/services/market-index-cron.service.ts b/apps/api/src/modules/analytics/infrastructure/services/market-index-cron.service.ts new file mode 100644 index 0000000..1bfc134 --- /dev/null +++ b/apps/api/src/modules/analytics/infrastructure/services/market-index-cron.service.ts @@ -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 { + 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 { + 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}`; + } +} diff --git a/e2e/api/inquiries.spec.ts b/e2e/api/inquiries.spec.ts new file mode 100644 index 0000000..45be89a --- /dev/null +++ b/e2e/api/inquiries.spec.ts @@ -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); + } + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0f87029..03d6245 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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