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:
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>;
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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
106
e2e/api/inquiries.spec.ts
Normal 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
495
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user