Compare commits

..

5 Commits

Author SHA1 Message Date
Ho Ngoc Hai
9e70074403 refactor(api): migrate all raw Error throws to DomainException (GOO-53)
Replace 14 raw `throw new Error(...)` instances in shared/, analytics/,
payments/ and listings/ with typed DomainException subclasses:

- env-validation.ts: INTERNAL_ERROR (3 startup config guards)
- field-encryption.ts: INTERNAL_ERROR / VALIDATION_FAILED (2 instances)
- result.ts: INTERNAL_ERROR (unwrapErr misuse guard)
- ai-service.client.ts: AI_PROVIDER_ERROR (HTTP error from AI service)
- prisma-avm.service.ts: VALIDATION_FAILED + NotFoundException (2 instances)
- avm-retrain-cron.service.ts: AI_PROVIDER_ERROR (2 upload/retrain failures)
- zalopay.service.ts: PAYMENT_FAILED (gateway failure)
- momo.service.ts: PAYMENT_FAILED (gateway failure)
- media-storage.service.ts: INTERNAL_ERROR (missing env var)

Update prisma-avm spec to match new NotFoundException message format.
All 6 PrismaAVMService tests and 31 shared domain tests pass.
Pre-existing failures in admin/industrial/mcp modules are unrelated.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-23 20:12:48 +07:00
Ho Ngoc Hai
54c97daa5b fix(auth): migrate raw Error throws to DomainException (GOO-52)
Replace 9 raw `throw new Error(...)` instances in the auth module with
typed DomainException subclasses so the GlobalExceptionFilter produces
structured error responses with proper HTTP status codes and error codes.

- zalo-oauth.strategy.ts (4): AUTH_OAUTH_PROVIDER_ERROR / 502
- jwt.strategy.ts (1): AUTH_CONFIG_MISSING / 500
- auth.module.ts (1): AUTH_CONFIG_MISSING / 500
- oauth.service.ts (3): AUTH_ACCOUNT_DISABLED / 403
- Add AUTH_OAUTH_PROVIDER_ERROR, AUTH_CONFIG_MISSING, AUTH_ACCOUNT_DISABLED
  to ErrorCode enum
- Update jwt.strategy.spec.ts mock to export DomainException + ErrorCode

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-23 19:58:31 +07:00
Ho Ngoc Hai
cc851d7e5f Sync PROJECT_TRACKER.md với Paperclip issue statuses
Updated docs/PROJECT_TRACKER.md to reflect current state from Paperclip API:
- GOO-2 section now shows 42 done issues (was 1/32)
- Added comprehensive audit phase tracking (GOO-47 through GOO-65)
- Updated progress: 42/65 done (65%), 16 in_progress, 5 todo, 2 blocked
- Preserved all legacy phases (TEC-0 through Wave 14 of Giai Đoạn 7)
- Added detailed breakdown by audit category (A1-A11 unit tests, B1-B8 infrastructure)

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-23 19:52:54 +07:00
Ho Ngoc Hai
e706f4ddf2 test(reference): add unit tests for VnAdmin service and controller (GOO-59)
Add 29 unit tests covering VnAdminService (listProvinces, listDistricts,
listWards, resolveDistrict, isKnownDistrictName) and VnAdminController
(all endpoints + public access verification). 100% coverage on all metrics.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-23 10:49:30 +07:00
Ho Ngoc Hai
0924f0cb9b test(web): add middleware + i18n + messages test suites (GOO-60)
Cover frontend middleware auth/locale logic, i18n config/routing/request/navigation,
and vi/en message parity — 91 new tests across 6 files.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 10:47:30 +07:00
34 changed files with 2078 additions and 338 deletions

View File

@@ -64,8 +64,24 @@ jobs:
- name: Typecheck - name: Typecheck
run: pnpm typecheck run: pnpm typecheck
- name: Test - name: Test with coverage
run: pnpm test run: pnpm test:coverage
- name: Upload API coverage report
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v4
with:
name: coverage-api
path: apps/api/coverage/
retention-days: 14
- name: Upload Web coverage report
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v4
with:
name: coverage-web
path: apps/web/coverage/
retention-days: 14
- name: Build - name: Build
run: pnpm build run: pnpm build
@@ -145,6 +161,29 @@ jobs:
print("OpenAPI paths OK:", sorted(paths.keys())) print("OpenAPI paths OK:", sorted(paths.keys()))
PY PY
- name: Setup Node.js for contract drift check
uses: actions/setup-node@v4
with:
node-version: 22
- name: Install pnpm for contract drift check
uses: pnpm/action-setup@v4
with:
version: 10
- name: Install JS deps for contract drift check
working-directory: .
run: pnpm install --frozen-lockfile
- name: AI sidecar OpenAPI contract drift check
env:
AI_CORS_ORIGINS: http://localhost:3000
# Tell the drift script which Python interpreter to use; libs/ai-services
# has no .venv in CI, so we rely on the system python where deps are installed.
PYTHON: python
working-directory: .
run: node libs/ai-contract/scripts/check-drift.mjs
e2e: e2e:
name: E2E Tests name: E2E Tests
needs: ci needs: ci

View File

@@ -9,6 +9,7 @@
"start:prod": "node dist/main", "start:prod": "node dist/main",
"lint": "eslint src/", "lint": "eslint src/",
"test": "vitest run", "test": "vitest run",
"test:coverage": "vitest run --coverage",
"test:integration": "vitest run --config vitest.integration.config.ts", "test:integration": "vitest run --config vitest.integration.config.ts",
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },
@@ -16,6 +17,7 @@
"@anthropic-ai/sdk": "^0.89.0", "@anthropic-ai/sdk": "^0.89.0",
"@aws-sdk/client-s3": "^3.1026.0", "@aws-sdk/client-s3": "^3.1026.0",
"@aws-sdk/s3-request-presigner": "^3.1026.0", "@aws-sdk/s3-request-presigner": "^3.1026.0",
"@goodgo/ai-contract": "workspace:*",
"@goodgo/mcp-servers": "workspace:*", "@goodgo/mcp-servers": "workspace:*",
"@nest-lab/throttler-storage-redis": "^1.2.0", "@nest-lab/throttler-storage-redis": "^1.2.0",
"@nestjs/bullmq": "^11.0.4", "@nestjs/bullmq": "^11.0.4",
@@ -84,6 +86,7 @@
"@types/qrcode": "^1.5.6", "@types/qrcode": "^1.5.6",
"@types/sanitize-html": "^2.16.1", "@types/sanitize-html": "^2.16.1",
"@types/supertest": "^7.2.0", "@types/supertest": "^7.2.0",
"@vitest/coverage-v8": "^4.1.3",
"prisma": "^7.7.0", "prisma": "^7.7.0",
"supertest": "^7.2.2", "supertest": "^7.2.2",
"typescript": "^6.0.2", "typescript": "^6.0.2",

View File

@@ -24,7 +24,7 @@ describe('PrismaAVMService', () => {
mockPrisma.$queryRaw.mockResolvedValue([]); mockPrisma.$queryRaw.mockResolvedValue([]);
await expect(service.estimateValue({ propertyId: 'non-existent' })).rejects.toThrow( await expect(service.estimateValue({ propertyId: 'non-existent' })).rejects.toThrow(
'Property not found: non-existent', "Property with id 'non-existent' not found",
); );
}); });

View File

@@ -1,194 +1,67 @@
import {
AiRoutes,
type AVMPredictRequest,
type AVMPredictResponse,
type AVMv2PredictRequest,
type AVMv2PredictResponse,
type AVMv2Comparable,
type AVMv2FeatureImportance,
type IndustrialAVMRequest,
type IndustrialAVMResponse,
type IndustrialComparable,
type IndustrialFeatureImportance,
type ModerationRequest,
type ModerationResponse,
type ModerationFlag,
type NeighborhoodPOICounts,
type NeighborhoodScoreRequest,
type NeighborhoodScoreResponse,
} from '@goodgo/ai-contract';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- NestJS DI requires the runtime import so emitDecoratorMetadata can see the class (see project MEMORY.md)
import { LoggerService } from '@modules/shared'; import { LoggerService } from '@modules/shared';
import { DomainException } from '@modules/shared/domain/domain-exception';
import { ErrorCode } from '@modules/shared/domain/error-codes';
export interface AiPredictRequest { /*
area: number; * The DTO names below are aliases over `@goodgo/ai-contract` (auto-generated
district: string; * from the FastAPI OpenAPI schema at libs/ai-services). They are kept to
city: string; * preserve the existing public surface for callers under apps/api; the
property_type: string; * underlying shapes come from the contract package so a schema change in
bedrooms?: number; * Python surfaces as a TypeScript compile error here.
bathrooms?: number; *
floors?: number; * Do not hand-edit these aliases to diverge from the generated types. If
frontage?: number; * the FastAPI schema changes, refresh the contract:
road_width?: number; *
year_built?: number | null; * pnpm --filter @goodgo/ai-contract export:openapi
has_legal_paper?: boolean; * pnpm --filter @goodgo/ai-contract generate
}
export interface AiPredictResponse {
estimated_price_vnd: number;
confidence: number;
price_per_m2: number;
price_range_low: number;
price_range_high: number;
}
/**
* AVM v2 request — extended feature set for residential ensemble.
* Matches `AVMv2PredictRequest` in libs/ai-services/app/models/avm_v2.py.
*/ */
export interface AiPredictV2Request {
district: string;
city: string;
property_type: string;
area_m2: number;
distance_to_cbd_km?: number;
distance_to_metro_km?: number;
distance_to_school_km?: number;
distance_to_hospital_km?: number;
distance_to_park_km?: number;
distance_to_mall_km?: number;
flood_zone_risk?: number;
neighborhood_score?: number;
rooms?: number;
floor_level?: number;
total_floors?: number;
direction?: string;
floor_ratio?: number;
building_age_years?: number;
has_elevator?: boolean;
has_parking?: boolean;
has_pool?: boolean;
has_legal_paper?: boolean;
developer_reputation?: number;
avg_price_district_3m_vnd_m2?: number;
listing_density?: number;
absorption_rate?: number;
dom_avg?: number;
price_momentum_30d?: number;
yoy_change?: number;
renovation_score?: number;
view_quality?: number;
interior_quality?: number;
noise_level?: number;
natural_light?: number;
month?: number;
quarter?: number;
is_year_end?: boolean;
}
export interface AiPredictV2FeatureImportance { // --- AVM v1 (legacy) ------------------------------------------------------
feature: string; export type AiPredictRequest = AVMPredictRequest;
importance: number; export type AiPredictResponse = AVMPredictResponse;
}
export interface AiPredictV2Comparable { // --- AVM v2 (residential ensemble) ---------------------------------------
district: string; export type AiPredictV2Request = AVMv2PredictRequest;
property_type: string; export type AiPredictV2Response = AVMv2PredictResponse;
area_m2: number; export type AiPredictV2FeatureImportance = AVMv2FeatureImportance;
price_vnd: number; export type AiPredictV2Comparable = AVMv2Comparable;
price_per_m2_vnd: number;
similarity_score: number;
}
export interface AiPredictV2Response { // --- Industrial AVM -------------------------------------------------------
estimated_price_vnd: number; export type AiIndustrialPredictRequest = IndustrialAVMRequest;
confidence: number; export type AiIndustrialPredictResponse = IndustrialAVMResponse;
price_per_m2_vnd: number; export type AiIndustrialComparable = IndustrialComparable;
price_range_low_vnd: number; export type AiIndustrialFeatureImportance = IndustrialFeatureImportance;
price_range_high_vnd: number;
drivers?: AiPredictV2FeatureImportance[];
comparables?: AiPredictV2Comparable[];
model_version?: string;
ensemble_method?: string;
}
export interface AiIndustrialPredictRequest { // --- Moderation -----------------------------------------------------------
province: string; export type AiModerationRequest = ModerationRequest;
region: string; export type AiModerationResponse = ModerationResponse;
park_occupancy_rate: number; export type AiModerationFlag = ModerationFlag;
park_area_ha: number;
park_age_years: number;
distance_to_port_km: number;
distance_to_airport_km: number;
distance_to_highway_km: number;
property_type: string;
area_m2: number;
ceiling_height_m?: number;
floor_load_ton_m2?: number;
power_capacity_kva?: number;
building_coverage?: number;
loading_docks?: number;
zoning?: string;
industry_demand_index?: number;
fdi_province_musd?: number;
labor_cost_province_vnd?: number;
logistics_connectivity_score?: number;
}
export interface AiIndustrialComparable { // --- Neighborhood scoring -------------------------------------------------
park_name: string; export type AiNeighborhoodPOICounts = NeighborhoodPOICounts;
province: string; export type AiNeighborhoodScoreRequest = NeighborhoodScoreRequest;
property_type: string; export type AiNeighborhoodScoreResponse = NeighborhoodScoreResponse;
area_m2: number;
rent_usd_m2: number;
similarity_score: number;
}
export interface AiIndustrialFeatureImportance {
feature: string;
importance: number;
}
export interface AiIndustrialPredictResponse {
estimated_rent_usd_m2: number;
confidence: number;
rent_range_low_usd_m2: number;
rent_range_high_usd_m2: number;
annual_rent_usd_m2: number;
total_monthly_rent_usd: number;
comparables: AiIndustrialComparable[];
drivers: AiIndustrialFeatureImportance[];
model_version: string;
}
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 interface AiNeighborhoodPOICounts {
education: number;
healthcare: number;
transport: number;
shopping: number;
greenery: number;
safety: number;
}
export interface AiNeighborhoodScoreRequest {
district: string;
city: string;
poi_counts: AiNeighborhoodPOICounts;
}
export interface AiNeighborhoodScoreResponse {
district: string;
city: string;
education_score: number;
healthcare_score: number;
transport_score: number;
shopping_score: number;
greenery_score: number;
safety_score: number;
total_score: number;
poi_counts: Record<string, number>;
algorithm_version: string;
}
export const AI_SERVICE_CLIENT = Symbol('AI_SERVICE_CLIENT'); export const AI_SERVICE_CLIENT = Symbol('AI_SERVICE_CLIENT');
@@ -214,30 +87,30 @@ export class AiServiceClient implements IAiServiceClient {
} }
async predict(req: AiPredictRequest): Promise<AiPredictResponse> { async predict(req: AiPredictRequest): Promise<AiPredictResponse> {
return this.post<AiPredictResponse>('/avm/predict', req); return this.post<AiPredictResponse>(AiRoutes.avmPredict, req);
} }
async predictV2(req: AiPredictV2Request): Promise<AiPredictV2Response> { async predictV2(req: AiPredictV2Request): Promise<AiPredictV2Response> {
return this.post<AiPredictV2Response>('/avm/v2/predict', req); return this.post<AiPredictV2Response>(AiRoutes.avmV2Predict, req);
} }
async predictIndustrial(req: AiIndustrialPredictRequest): Promise<AiIndustrialPredictResponse> { async predictIndustrial(req: AiIndustrialPredictRequest): Promise<AiIndustrialPredictResponse> {
return this.post<AiIndustrialPredictResponse>('/avm/industrial/predict', req); return this.post<AiIndustrialPredictResponse>(AiRoutes.avmIndustrialPredict, req);
} }
async moderate(req: AiModerationRequest): Promise<AiModerationResponse> { async moderate(req: AiModerationRequest): Promise<AiModerationResponse> {
return this.post<AiModerationResponse>('/moderation/check', req); return this.post<AiModerationResponse>(AiRoutes.moderationCheck, req);
} }
async scoreNeighborhood( async scoreNeighborhood(
req: AiNeighborhoodScoreRequest, req: AiNeighborhoodScoreRequest,
): Promise<AiNeighborhoodScoreResponse> { ): Promise<AiNeighborhoodScoreResponse> {
return this.post<AiNeighborhoodScoreResponse>('/neighborhood/score', req); return this.post<AiNeighborhoodScoreResponse>(AiRoutes.neighborhoodScore, req);
} }
async isAvailable(): Promise<boolean> { async isAvailable(): Promise<boolean> {
try { try {
const response = await fetch(`${this.baseUrl}/health`, { const response = await fetch(`${this.baseUrl}${AiRoutes.health}`, {
method: 'GET', method: 'GET',
signal: AbortSignal.timeout(2000), signal: AbortSignal.timeout(2000),
}); });
@@ -265,7 +138,10 @@ export class AiServiceClient implements IAiServiceClient {
if (!response.ok) { if (!response.ok) {
const text = await response.text().catch(() => ''); const text = await response.text().catch(() => '');
throw new Error(`AI service ${path} returned ${response.status}: ${text}`); throw new DomainException(
ErrorCode.AI_PROVIDER_ERROR,
`AI service ${path} returned ${response.status}: ${text}`,
);
} }
return response.json() as Promise<T>; return response.json() as Promise<T>;

View File

@@ -1,6 +1,8 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Cron } from '@nestjs/schedule'; import { Cron } from '@nestjs/schedule';
import { PrismaService, LoggerService } from '@modules/shared'; import { PrismaService, LoggerService } from '@modules/shared';
import { DomainException } from '@modules/shared/domain/domain-exception';
import { ErrorCode } from '@modules/shared/domain/error-codes';
@Injectable() @Injectable()
export class AvmRetrainCronService { export class AvmRetrainCronService {
@@ -101,7 +103,7 @@ export class AvmRetrainCronService {
CASE WHEN p.amenities::text ILIKE '%parking%' THEN 1.0 ELSE 0.0 END AS has_parking, CASE WHEN p.amenities::text ILIKE '%parking%' THEN 1.0 ELSE 0.0 END AS has_parking,
CASE WHEN p.amenities::text ILIKE '%pool%' THEN 1.0 ELSE 0.0 END AS has_pool, CASE WHEN p.amenities::text ILIKE '%pool%' THEN 1.0 ELSE 0.0 END AS has_pool,
CASE CASE
WHEN p."legalStatus" IN ('so_do', 'so_hong', 'SO_DO', 'SO_HONG') THEN 1.0 WHEN p."legalStatus"::text IN ('SO_DO', 'SO_HONG') THEN 1.0
ELSE 0.0 ELSE 0.0
END AS has_legal_paper, END AS has_legal_paper,
0.5 AS developer_reputation, 0.5 AS developer_reputation,
@@ -206,7 +208,10 @@ export class AvmRetrainCronService {
if (!response.ok) { if (!response.ok) {
const text = await response.text().catch(() => ''); const text = await response.text().catch(() => '');
throw new Error(`Training data upload failed (${response.status}): ${text}`); throw new DomainException(
ErrorCode.AI_PROVIDER_ERROR,
`Training data upload failed (${response.status}): ${text}`,
);
} }
this.logger.log( this.logger.log(
@@ -235,7 +240,10 @@ export class AvmRetrainCronService {
if (!response.ok) { if (!response.ok) {
const text = await response.text().catch(() => ''); const text = await response.text().catch(() => '');
throw new Error(`Retrain request failed (${response.status}): ${text}`); throw new DomainException(
ErrorCode.AI_PROVIDER_ERROR,
`Retrain request failed (${response.status}): ${text}`,
);
} }
return response.json() as Promise<RetrainResult>; return response.json() as Promise<RetrainResult>;

View File

@@ -1,6 +1,8 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { type PropertyType } from '@prisma/client'; import { type PropertyType } from '@prisma/client';
import { PrismaService } from '@modules/shared'; import { PrismaService } from '@modules/shared';
import { DomainException, NotFoundException } from '@modules/shared/domain/domain-exception';
import { ErrorCode } from '@modules/shared/domain/error-codes';
import { import {
type IAVMService, type IAVMService,
type AVMParams, type AVMParams,
@@ -113,7 +115,10 @@ export class PrismaAVMService implements IAVMService {
}; };
} }
throw new Error('Either propertyId or (latitude, longitude, areaM2) must be provided'); throw new DomainException(
ErrorCode.VALIDATION_FAILED,
'Either propertyId or (latitude, longitude, areaM2) must be provided',
);
} }
private async getPropertyLocation(propertyId: string): Promise<PropertyLocation> { private async getPropertyLocation(propertyId: string): Promise<PropertyLocation> {
@@ -127,7 +132,7 @@ export class PrismaAVMService implements IAVMService {
LIMIT 1 LIMIT 1
`; `;
const row = rows[0]; const row = rows[0];
if (!row) throw new Error(`Property not found: ${propertyId}`); if (!row) throw new NotFoundException('Property', propertyId);
return row; return row;
} }

View File

@@ -3,6 +3,7 @@ import { CqrsModule } from '@nestjs/cqrs';
import { JwtModule } from '@nestjs/jwt'; import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport'; import { PassportModule } from '@nestjs/passport';
import { MulterModule } from '@nestjs/platform-express'; import { MulterModule } from '@nestjs/platform-express';
import { DomainException, ErrorCode } from '@modules/shared';
import { import {
MEDIA_STORAGE_SERVICE, MEDIA_STORAGE_SERVICE,
MinioMediaStorageService, MinioMediaStorageService,
@@ -18,6 +19,7 @@ import { ResetPasswordHandler } from './application/commands/reset-password/rese
import { ProcessScheduledDeletionsHandler } from './application/commands/process-scheduled-deletions/process-scheduled-deletions.handler'; import { ProcessScheduledDeletionsHandler } from './application/commands/process-scheduled-deletions/process-scheduled-deletions.handler';
import { RefreshTokenHandler } from './application/commands/refresh-token/refresh-token.handler'; import { RefreshTokenHandler } from './application/commands/refresh-token/refresh-token.handler';
import { RegisterUserHandler } from './application/commands/register-user/register-user.handler'; import { RegisterUserHandler } from './application/commands/register-user/register-user.handler';
import { RequestPhoneOtpHandler } from './application/commands/request-phone-otp/request-phone-otp.handler';
import { RequestUserDeletionHandler } from './application/commands/request-user-deletion/request-user-deletion.handler'; import { RequestUserDeletionHandler } from './application/commands/request-user-deletion/request-user-deletion.handler';
import { ResendOtpHandler } from './application/commands/resend-otp/resend-otp.handler'; import { ResendOtpHandler } from './application/commands/resend-otp/resend-otp.handler';
import { SetupMfaHandler } from './application/commands/setup-mfa/setup-mfa.handler'; import { SetupMfaHandler } from './application/commands/setup-mfa/setup-mfa.handler';
@@ -29,6 +31,7 @@ import { VerifyKycHandler } from './application/commands/verify-kyc/verify-kyc.h
import { VerifyMfaChallengeHandler } from './application/commands/verify-mfa-challenge/verify-mfa-challenge.handler'; import { VerifyMfaChallengeHandler } from './application/commands/verify-mfa-challenge/verify-mfa-challenge.handler';
import { VerifyMfaSetupHandler } from './application/commands/verify-mfa-setup/verify-mfa-setup.handler'; import { VerifyMfaSetupHandler } from './application/commands/verify-mfa-setup/verify-mfa-setup.handler';
import { VerifyPhoneChangeHandler } from './application/commands/verify-phone-change/verify-phone-change.handler'; import { VerifyPhoneChangeHandler } from './application/commands/verify-phone-change/verify-phone-change.handler';
import { VerifyPhoneOtpHandler } from './application/commands/verify-phone-otp/verify-phone-otp.handler';
import { GetAgentByUserIdHandler } from './application/queries/get-agent-by-user-id/get-agent-by-user-id.handler'; import { GetAgentByUserIdHandler } from './application/queries/get-agent-by-user-id/get-agent-by-user-id.handler';
import { GetMfaStatusHandler } from './application/queries/get-mfa-status/get-mfa-status.handler'; import { GetMfaStatusHandler } from './application/queries/get-mfa-status/get-mfa-status.handler';
import { GetProfileHandler } from './application/queries/get-profile/get-profile.handler'; import { GetProfileHandler } from './application/queries/get-profile/get-profile.handler';
@@ -54,6 +57,8 @@ const CommandHandlers = [
RegisterUserHandler, RegisterUserHandler,
LoginUserHandler, LoginUserHandler,
RefreshTokenHandler, RefreshTokenHandler,
RequestPhoneOtpHandler,
VerifyPhoneOtpHandler,
VerifyKycHandler, VerifyKycHandler,
SubmitKycHandler, SubmitKycHandler,
GenerateKycUploadUrlsHandler, GenerateKycUploadUrlsHandler,
@@ -89,7 +94,7 @@ const QueryHandlers = [GetProfileHandler, GetAgentByUserIdHandler, GetMfaStatusH
useFactory: () => { useFactory: () => {
const secret = process.env['JWT_SECRET']; const secret = process.env['JWT_SECRET'];
if (!secret) { if (!secret) {
throw new Error('JWT_SECRET environment variable is required'); throw new DomainException(ErrorCode.AUTH_CONFIG_MISSING, 'JWT_SECRET environment variable is required');
} }
return { return {
secret, secret,

View File

@@ -23,10 +23,22 @@ vi.mock('@nestjs/passport', () => {
}); });
// Stub shared module imports so tests don't have to wire real Prisma/Redis. // Stub shared module imports so tests don't have to wire real Prisma/Redis.
vi.mock('@modules/shared', () => ({ vi.mock('@modules/shared', () => {
PrismaService: class {}, const { HttpException } = require('@nestjs/common');
RedisService: class {}, class DomainException extends HttpException {
})); constructor(public readonly errorCode: string, message: string, statusCode = 500) {
super(message, statusCode);
}
}
return {
PrismaService: class {},
RedisService: class {},
DomainException,
ErrorCode: {
AUTH_CONFIG_MISSING: 'AUTH_CONFIG_MISSING',
},
};
});
type PrismaStub = { user: { findUnique: ReturnType<typeof vi.fn> } }; type PrismaStub = { user: { findUnique: ReturnType<typeof vi.fn> } };
type RedisStub = { type RedisStub = {

View File

@@ -1,8 +1,8 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable, HttpStatus } from '@nestjs/common';
import { EventBus } from '@nestjs/cqrs'; import { EventBus } from '@nestjs/cqrs';
import { createId } from '@paralleldrive/cuid2'; import { createId } from '@paralleldrive/cuid2';
import { type OAuthProvider, type Prisma } from '@prisma/client'; import { type OAuthProvider, type Prisma } from '@prisma/client';
import { PrismaService, LoggerService } from '@modules/shared'; import { PrismaService, LoggerService, DomainException, ErrorCode } from '@modules/shared';
import { UserEntity } from '../../domain/entities/user.entity'; import { UserEntity } from '../../domain/entities/user.entity';
import { UserRegisteredEvent } from '../../domain/events/user-registered.event'; import { UserRegisteredEvent } from '../../domain/events/user-registered.event';
import { USER_REPOSITORY, type IUserRepository } from '../../domain/repositories/user.repository'; import { USER_REPOSITORY, type IUserRepository } from '../../domain/repositories/user.repository';
@@ -62,7 +62,7 @@ export class OAuthService {
}); });
if (!existingOAuth.user.isActive) { if (!existingOAuth.user.isActive) {
throw new Error('Tài khoản đã bị vô hiệu hóa'); throw new DomainException(ErrorCode.AUTH_ACCOUNT_DISABLED, 'Tài khoản đã bị vô hiệu hóa', HttpStatus.FORBIDDEN);
} }
this.logger.log(`OAuth login: existing account for ${profile.provider}/${profile.providerUserId}`, 'OAuthService'); this.logger.log(`OAuth login: existing account for ${profile.provider}/${profile.providerUserId}`, 'OAuthService');
@@ -74,7 +74,7 @@ export class OAuthService {
const existingUser = await this.userRepo.findByEmail(profile.email); const existingUser = await this.userRepo.findByEmail(profile.email);
if (existingUser) { if (existingUser) {
if (!existingUser.isActive) { if (!existingUser.isActive) {
throw new Error('Tài khoản đã bị vô hiệu hóa'); throw new DomainException(ErrorCode.AUTH_ACCOUNT_DISABLED, 'Tài khoản đã bị vô hiệu hóa', HttpStatus.FORBIDDEN);
} }
await this.createOAuthAccount(existingUser.id, profile); await this.createOAuthAccount(existingUser.id, profile);
this.logger.log(`OAuth link: linked ${profile.provider} to existing user by email`, 'OAuthService'); this.logger.log(`OAuth link: linked ${profile.provider} to existing user by email`, 'OAuthService');
@@ -93,7 +93,7 @@ export class OAuthService {
const existingUser = await this.userRepo.findByPhone(phoneVo.unwrap().value); const existingUser = await this.userRepo.findByPhone(phoneVo.unwrap().value);
if (existingUser) { if (existingUser) {
if (!existingUser.isActive) { if (!existingUser.isActive) {
throw new Error('Tài khoản đã bị vô hiệu hóa'); throw new DomainException(ErrorCode.AUTH_ACCOUNT_DISABLED, 'Tài khoản đã bị vô hiệu hóa', HttpStatus.FORBIDDEN);
} }
await this.createOAuthAccount(existingUser.id, profile); await this.createOAuthAccount(existingUser.id, profile);
this.logger.log(`OAuth link: linked ${profile.provider} to existing user by phone`, 'OAuthService'); this.logger.log(`OAuth link: linked ${profile.provider} to existing user by phone`, 'OAuthService');
@@ -121,6 +121,7 @@ export class OAuthService {
kycStatus: 'NONE', kycStatus: 'NONE',
kycData: null, kycData: null,
isActive: true, isActive: true,
deletedAt: null,
totpSecret: null, totpSecret: null,
totpEnabled: false, totpEnabled: false,
totpBackupCodes: [], totpBackupCodes: [],

View File

@@ -3,7 +3,7 @@ import { PassportStrategy } from '@nestjs/passport';
import { type Request } from 'express'; import { type Request } from 'express';
import { ExtractJwt, Strategy } from 'passport-jwt'; import { ExtractJwt, Strategy } from 'passport-jwt';
// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- NestJS DI requires value imports for emitDecoratorMetadata // eslint-disable-next-line @typescript-eslint/consistent-type-imports -- NestJS DI requires value imports for emitDecoratorMetadata
import { PrismaService, RedisService } from '@modules/shared'; import { PrismaService, RedisService, DomainException, ErrorCode } from '@modules/shared';
import { type JwtPayload } from '../services/token.service'; import { type JwtPayload } from '../services/token.service';
function extractJwtFromCookieOrHeader(req: Request): string | null { function extractJwtFromCookieOrHeader(req: Request): string | null {
@@ -34,7 +34,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
) { ) {
const jwtSecret = process.env['JWT_SECRET']; const jwtSecret = process.env['JWT_SECRET'];
if (!jwtSecret) { if (!jwtSecret) {
throw new Error('JWT_SECRET environment variable is required'); throw new DomainException(ErrorCode.AUTH_CONFIG_MISSING, 'JWT_SECRET environment variable is required');
} }
super({ super({

View File

@@ -1,5 +1,5 @@
import { Injectable } from '@nestjs/common'; import { HttpStatus, Injectable } from '@nestjs/common';
import { LoggerService } from '@modules/shared'; import { DomainException, ErrorCode, LoggerService } from '@modules/shared';
import { OAuthService, type OAuthUserProfile } from '../services/oauth.service'; import { OAuthService, type OAuthUserProfile } from '../services/oauth.service';
import { type TokenPair } from '../services/token.service'; import { type TokenPair } from '../services/token.service';
@@ -126,11 +126,19 @@ export class ZaloOAuthStrategy {
if (data.error) { if (data.error) {
this.logger.error(`Zalo token exchange failed: ${data.error_description ?? data.error}`, undefined, 'ZaloOAuthStrategy'); this.logger.error(`Zalo token exchange failed: ${data.error_description ?? data.error}`, undefined, 'ZaloOAuthStrategy');
throw new Error(`Zalo OAuth error: ${data.error_description ?? 'Token exchange failed'}`); throw new DomainException(
ErrorCode.AUTH_OAUTH_PROVIDER_ERROR,
`Zalo OAuth error: ${data.error_description ?? 'Token exchange failed'}`,
HttpStatus.BAD_GATEWAY,
);
} }
if (!data.access_token) { if (!data.access_token) {
throw new Error('Zalo OAuth error: No access token in response'); throw new DomainException(
ErrorCode.AUTH_OAUTH_PROVIDER_ERROR,
'Zalo OAuth error: No access token in response',
HttpStatus.BAD_GATEWAY,
);
} }
return data; return data;
@@ -150,11 +158,19 @@ export class ZaloOAuthStrategy {
if (data.error) { if (data.error) {
this.logger.error(`Zalo user info fetch failed: ${data.message ?? data.error}`, undefined, 'ZaloOAuthStrategy'); this.logger.error(`Zalo user info fetch failed: ${data.message ?? data.error}`, undefined, 'ZaloOAuthStrategy');
throw new Error(`Zalo OAuth error: ${data.message ?? 'Failed to fetch user info'}`); throw new DomainException(
ErrorCode.AUTH_OAUTH_PROVIDER_ERROR,
`Zalo OAuth error: ${data.message ?? 'Failed to fetch user info'}`,
HttpStatus.BAD_GATEWAY,
);
} }
if (!data.id) { if (!data.id) {
throw new Error('Zalo OAuth error: No user ID in response'); throw new DomainException(
ErrorCode.AUTH_OAUTH_PROVIDER_ERROR,
'Zalo OAuth error: No user ID in response',
HttpStatus.BAD_GATEWAY,
);
} }
return data; return data;

View File

@@ -0,0 +1,115 @@
import { ListingExpiringEvent } from '../../domain/events/listing-expiring.event';
import { ListingExpiryCronService } from '../../infrastructure/cron/listing-expiry-cron.service';
describe('ListingExpiryCronService', () => {
let service: ListingExpiryCronService;
let mockPrisma: { $queryRaw: ReturnType<typeof vi.fn> };
let mockEventBus: { publish: ReturnType<typeof vi.fn> };
let mockLogger: {
log: ReturnType<typeof vi.fn>;
debug: ReturnType<typeof vi.fn>;
error: ReturnType<typeof vi.fn>;
};
beforeEach(() => {
mockPrisma = { $queryRaw: vi.fn() };
mockEventBus = { publish: vi.fn() };
mockLogger = { log: vi.fn(), debug: vi.fn(), error: vi.fn() };
service = new ListingExpiryCronService(
mockPrisma as any,
mockEventBus as any,
mockLogger as any,
);
});
it('publishes ListingExpiringEvent for each expiring listing and logs the count', async () => {
const expiresAt = new Date(Date.now() + 2 * 24 * 60 * 60 * 1000);
mockPrisma.$queryRaw.mockResolvedValue([
{ id: 'listing-a', sellerId: 'seller-a', expiresAt },
{ id: 'listing-b', sellerId: 'seller-b', expiresAt },
]);
await service.notifyExpiringListings();
expect(mockPrisma.$queryRaw).toHaveBeenCalledTimes(1);
expect(mockEventBus.publish).toHaveBeenCalledTimes(2);
const events = mockEventBus.publish.mock.calls.map((c) => c[0]) as ListingExpiringEvent[];
expect(events[0]).toBeInstanceOf(ListingExpiringEvent);
expect(events[1]).toBeInstanceOf(ListingExpiringEvent);
expect(events.map((e) => e.aggregateId).sort()).toEqual(['listing-a', 'listing-b']);
expect(events[0].eventName).toBe('listing.expiring');
expect(events[0].sellerId).toBe('seller-a');
expect(events[0].expiresAt).toBe(expiresAt);
expect(mockLogger.log).toHaveBeenCalledWith(
expect.stringContaining('2 listing(s)'),
'ListingExpiryCronService',
);
});
it('is a no-op when no listings are expiring (idempotent across runs)', async () => {
mockPrisma.$queryRaw.mockResolvedValue([]);
await service.notifyExpiringListings();
expect(mockEventBus.publish).not.toHaveBeenCalled();
expect(mockLogger.log).not.toHaveBeenCalled();
expect(mockLogger.debug).toHaveBeenCalledWith(
'No listings expiring in the next 3 days — nothing to notify',
'ListingExpiryCronService',
);
});
it('catches DB errors and logs them without throwing', async () => {
const boom = new Error('connection lost');
mockPrisma.$queryRaw.mockRejectedValue(boom);
await expect(service.notifyExpiringListings()).resolves.toBeUndefined();
expect(mockEventBus.publish).not.toHaveBeenCalled();
expect(mockLogger.error).toHaveBeenCalledWith(
expect.stringContaining('connection lost'),
expect.any(String),
'ListingExpiryCronService',
);
});
it('uses a single atomic UPDATE ... RETURNING so concurrent runs do not double-notify', async () => {
const expiresAt = new Date(Date.now() + 2 * 24 * 60 * 60 * 1000);
// Simulate two parallel runs against the same DB; only the first sees the row.
mockPrisma.$queryRaw
.mockResolvedValueOnce([{ id: 'listing-1', sellerId: 'seller-1', expiresAt }])
.mockResolvedValueOnce([]);
await Promise.all([
service.notifyExpiringListings(),
service.notifyExpiringListings(),
]);
// Only one event published across both runs.
expect(mockEventBus.publish).toHaveBeenCalledTimes(1);
expect(mockPrisma.$queryRaw).toHaveBeenCalledTimes(2);
});
it('publishes events with correct sellerId and expiresAt per row', async () => {
const expiresAtA = new Date(Date.now() + 1 * 24 * 60 * 60 * 1000);
const expiresAtB = new Date(Date.now() + 2.5 * 24 * 60 * 60 * 1000);
mockPrisma.$queryRaw.mockResolvedValue([
{ id: 'listing-a', sellerId: 'seller-x', expiresAt: expiresAtA },
{ id: 'listing-b', sellerId: 'seller-y', expiresAt: expiresAtB },
]);
await service.notifyExpiringListings();
const events = mockEventBus.publish.mock.calls.map((c) => c[0]) as ListingExpiringEvent[];
expect(events[0].aggregateId).toBe('listing-a');
expect(events[0].sellerId).toBe('seller-x');
expect(events[0].expiresAt).toBe(expiresAtA);
expect(events[1].aggregateId).toBe('listing-b');
expect(events[1].sellerId).toBe('seller-y');
expect(events[1].expiresAt).toBe(expiresAtB);
});
});

View File

@@ -10,6 +10,8 @@ import {
import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { Injectable, type OnModuleInit } from '@nestjs/common'; import { Injectable, type OnModuleInit } from '@nestjs/common';
import { LoggerService } from '@modules/shared'; import { LoggerService } from '@modules/shared';
import { DomainException } from '@modules/shared/domain/domain-exception';
import { ErrorCode } from '@modules/shared/domain/error-codes';
export const MEDIA_STORAGE_SERVICE = Symbol('MEDIA_STORAGE_SERVICE'); export const MEDIA_STORAGE_SERVICE = Symbol('MEDIA_STORAGE_SERVICE');
@@ -36,7 +38,10 @@ export interface IMediaStorageService {
function requireEnv(key: string): string { function requireEnv(key: string): string {
const value = process.env[key]; const value = process.env[key];
if (!value) { if (!value) {
throw new Error(`Missing required environment variable: ${key}`); throw new DomainException(
ErrorCode.INTERNAL_ERROR,
`Missing required environment variable: ${key}`,
);
} }
return value; return value;
} }

View File

@@ -3,6 +3,8 @@ import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { type PaymentProvider } from '@prisma/client'; import { type PaymentProvider } from '@prisma/client';
import { LoggerService } from '@modules/shared'; import { LoggerService } from '@modules/shared';
import { DomainException } from '@modules/shared/domain/domain-exception';
import { ErrorCode } from '@modules/shared/domain/error-codes';
import { import {
type IPaymentGateway, type IPaymentGateway,
type CreatePaymentUrlParams, type CreatePaymentUrlParams,
@@ -89,7 +91,10 @@ export class MomoService implements IPaymentGateway {
const result = await response.json() as { resultCode: number; payUrl: string }; const result = await response.json() as { resultCode: number; payUrl: string };
if (result.resultCode !== 0) { if (result.resultCode !== 0) {
throw new Error(`MoMo create payment failed: resultCode=${result.resultCode}`); throw new DomainException(
ErrorCode.PAYMENT_FAILED,
`MoMo create payment failed: resultCode=${result.resultCode}`,
);
} }
this.logger.log(`MoMo payment URL created for order ${params.orderId}`, 'MomoService'); this.logger.log(`MoMo payment URL created for order ${params.orderId}`, 'MomoService');

View File

@@ -3,6 +3,8 @@ import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { type PaymentProvider } from '@prisma/client'; import { type PaymentProvider } from '@prisma/client';
import { LoggerService } from '@modules/shared'; import { LoggerService } from '@modules/shared';
import { DomainException } from '@modules/shared/domain/domain-exception';
import { ErrorCode } from '@modules/shared/domain/error-codes';
import { import {
type IPaymentGateway, type IPaymentGateway,
type CreatePaymentUrlParams, type CreatePaymentUrlParams,
@@ -85,7 +87,10 @@ export class ZalopayService implements IPaymentGateway {
}; };
if (result.return_code !== 1) { if (result.return_code !== 1) {
throw new Error(`ZaloPay create payment failed: return_code=${result.return_code}`); throw new DomainException(
ErrorCode.PAYMENT_FAILED,
`ZaloPay create payment failed: return_code=${result.return_code}`,
);
} }
this.logger.log(`ZaloPay payment URL created for order ${params.orderId}`, 'ZalopayService'); this.logger.log(`ZaloPay payment URL created for order ${params.orderId}`, 'ZalopayService');

View File

@@ -0,0 +1,267 @@
import { VnAdminService } from '../vn-admin.service';
describe('VnAdminService', () => {
let service: VnAdminService;
let mockPrisma: {
vnProvince: { findMany: ReturnType<typeof vi.fn> };
vnDistrict: { findMany: ReturnType<typeof vi.fn>; findFirst: ReturnType<typeof vi.fn> };
vnWard: { findMany: ReturnType<typeof vi.fn> };
vnAdministrativeAlias: { findFirst: ReturnType<typeof vi.fn> };
};
beforeEach(() => {
mockPrisma = {
vnProvince: { findMany: vi.fn() },
vnDistrict: { findMany: vi.fn(), findFirst: vi.fn() },
vnWard: { findMany: vi.fn() },
vnAdministrativeAlias: { findFirst: vi.fn() },
};
service = new VnAdminService(mockPrisma as any);
});
// ── listProvinces ──────────────────────────────────────────────────
describe('listProvinces', () => {
it('returns all provinces ordered by name ascending', async () => {
const provinces = [
{ code: '01', name: 'Hà Nội', type: 'thanh_pho', codename: 'thanh_pho_ha_noi' },
{ code: '79', name: 'TP Hồ Chí Minh', type: 'thanh_pho', codename: 'thanh_pho_ho_chi_minh' },
];
mockPrisma.vnProvince.findMany.mockResolvedValue(provinces);
const result = await service.listProvinces();
expect(result).toEqual(provinces);
expect(mockPrisma.vnProvince.findMany).toHaveBeenCalledWith({
orderBy: [{ name: 'asc' }],
select: { code: true, name: true, type: true, codename: true },
});
});
it('returns an empty array when no provinces exist', async () => {
mockPrisma.vnProvince.findMany.mockResolvedValue([]);
const result = await service.listProvinces();
expect(result).toEqual([]);
});
});
// ── listDistricts ──────────────────────────────────────────────────
describe('listDistricts', () => {
it('returns districts filtered by provinceCode', async () => {
const districts = [
{ code: '760', provinceCode: '79', name: 'Quận 1', type: 'quan', codename: 'quan_1' },
{ code: '769', provinceCode: '79', name: 'Thành phố Thủ Đức', type: 'thanh_pho', codename: 'thanh_pho_thu_duc' },
];
mockPrisma.vnDistrict.findMany.mockResolvedValue(districts);
const result = await service.listDistricts('79');
expect(result).toEqual(districts);
expect(mockPrisma.vnDistrict.findMany).toHaveBeenCalledWith({
where: { provinceCode: '79' },
orderBy: [{ name: 'asc' }],
select: { code: true, provinceCode: true, name: true, type: true, codename: true },
});
});
it('returns an empty array when province has no districts', async () => {
mockPrisma.vnDistrict.findMany.mockResolvedValue([]);
const result = await service.listDistricts('99');
expect(result).toEqual([]);
});
});
// ── listWards ──────────────────────────────────────────────────────
describe('listWards', () => {
it('returns wards filtered by districtCode', async () => {
const wards = [
{ code: '26734', districtCode: '760', name: 'Phường Bến Nghé', type: 'phuong', codename: 'phuong_ben_nghe' },
{ code: '26737', districtCode: '760', name: 'Phường Bến Thành', type: 'phuong', codename: 'phuong_ben_thanh' },
];
mockPrisma.vnWard.findMany.mockResolvedValue(wards);
const result = await service.listWards('760');
expect(result).toEqual(wards);
expect(mockPrisma.vnWard.findMany).toHaveBeenCalledWith({
where: { districtCode: '760' },
orderBy: [{ name: 'asc' }],
select: { code: true, districtCode: true, name: true, type: true, codename: true },
});
});
it('returns an empty array when district has no wards', async () => {
mockPrisma.vnWard.findMany.mockResolvedValue([]);
const result = await service.listWards('999');
expect(result).toEqual([]);
});
});
// ── resolveDistrict ────────────────────────────────────────────────
describe('resolveDistrict', () => {
it('returns null resolution for empty string input', async () => {
const result = await service.resolveDistrict('');
expect(result).toEqual({
districtCode: null,
isCurrent: false,
resolvedViaAlias: false,
aliasReason: null,
});
expect(mockPrisma.vnDistrict.findFirst).not.toHaveBeenCalled();
expect(mockPrisma.vnAdministrativeAlias.findFirst).not.toHaveBeenCalled();
});
it('returns null resolution for whitespace-only input', async () => {
const result = await service.resolveDistrict(' ');
expect(result).toEqual({
districtCode: null,
isCurrent: false,
resolvedViaAlias: false,
aliasReason: null,
});
});
it('resolves a current district name directly', async () => {
mockPrisma.vnDistrict.findFirst.mockResolvedValue({ code: '760' });
const result = await service.resolveDistrict('Quận 1');
expect(result).toEqual({
districtCode: '760',
isCurrent: true,
resolvedViaAlias: false,
aliasReason: null,
});
expect(mockPrisma.vnDistrict.findFirst).toHaveBeenCalledWith({
where: { name: 'Quận 1' },
select: { code: true },
});
});
it('filters by provinceCode when provided', async () => {
mockPrisma.vnDistrict.findFirst.mockResolvedValue({ code: '760' });
await service.resolveDistrict('Quận 1', '79');
expect(mockPrisma.vnDistrict.findFirst).toHaveBeenCalledWith({
where: { name: 'Quận 1', provinceCode: '79' },
select: { code: true },
});
});
it('resolves a historical alias when no direct match is found', async () => {
mockPrisma.vnDistrict.findFirst.mockResolvedValue(null);
mockPrisma.vnAdministrativeAlias.findFirst.mockResolvedValue({
newDistrictCode: '769',
reason: 'Sáp nhập vào Thủ Đức',
});
const result = await service.resolveDistrict('Quận 2');
expect(result).toEqual({
districtCode: '769',
isCurrent: false,
resolvedViaAlias: true,
aliasReason: 'Sáp nhập vào Thủ Đức',
});
expect(mockPrisma.vnAdministrativeAlias.findFirst).toHaveBeenCalledWith({
where: { oldName: 'Quận 2', level: 'district' },
select: { newDistrictCode: true, reason: true },
});
});
it('returns null resolution when neither direct nor alias match', async () => {
mockPrisma.vnDistrict.findFirst.mockResolvedValue(null);
mockPrisma.vnAdministrativeAlias.findFirst.mockResolvedValue(null);
const result = await service.resolveDistrict('Nonexistent District');
expect(result).toEqual({
districtCode: null,
isCurrent: false,
resolvedViaAlias: false,
aliasReason: null,
});
});
it('returns null resolution when alias exists but has no newDistrictCode', async () => {
mockPrisma.vnDistrict.findFirst.mockResolvedValue(null);
mockPrisma.vnAdministrativeAlias.findFirst.mockResolvedValue({
newDistrictCode: null,
reason: 'Removed',
});
const result = await service.resolveDistrict('Old Name');
expect(result).toEqual({
districtCode: null,
isCurrent: false,
resolvedViaAlias: false,
aliasReason: null,
});
});
it('trims whitespace from input before matching', async () => {
mockPrisma.vnDistrict.findFirst.mockResolvedValue({ code: '760' });
await service.resolveDistrict(' Quận 1 ');
expect(mockPrisma.vnDistrict.findFirst).toHaveBeenCalledWith({
where: { name: 'Quận 1' },
select: { code: true },
});
});
it('skips alias lookup when direct match succeeds', async () => {
mockPrisma.vnDistrict.findFirst.mockResolvedValue({ code: '760' });
await service.resolveDistrict('Quận 1');
expect(mockPrisma.vnAdministrativeAlias.findFirst).not.toHaveBeenCalled();
});
});
// ── isKnownDistrictName ────────────────────────────────────────────
describe('isKnownDistrictName', () => {
it('returns true when resolveDistrict finds a districtCode', async () => {
mockPrisma.vnDistrict.findFirst.mockResolvedValue({ code: '760' });
const result = await service.isKnownDistrictName('Quận 1');
expect(result).toBe(true);
});
it('returns false when resolveDistrict finds no districtCode', async () => {
mockPrisma.vnDistrict.findFirst.mockResolvedValue(null);
mockPrisma.vnAdministrativeAlias.findFirst.mockResolvedValue(null);
const result = await service.isKnownDistrictName('Unknown District');
expect(result).toBe(false);
});
it('returns true when district is resolved via alias', async () => {
mockPrisma.vnDistrict.findFirst.mockResolvedValue(null);
mockPrisma.vnAdministrativeAlias.findFirst.mockResolvedValue({
newDistrictCode: '769',
reason: 'Merged',
});
const result = await service.isKnownDistrictName('Quận 9');
expect(result).toBe(true);
});
});
});

View File

@@ -0,0 +1,151 @@
import { BadRequestException } from '@nestjs/common';
import { VnAdminController } from '../vn-admin.controller';
describe('VnAdminController', () => {
let controller: VnAdminController;
let mockVnAdmin: {
listProvinces: ReturnType<typeof vi.fn>;
listDistricts: ReturnType<typeof vi.fn>;
listWards: ReturnType<typeof vi.fn>;
resolveDistrict: ReturnType<typeof vi.fn>;
};
beforeEach(() => {
mockVnAdmin = {
listProvinces: vi.fn(),
listDistricts: vi.fn(),
listWards: vi.fn(),
resolveDistrict: vi.fn(),
};
controller = new VnAdminController(mockVnAdmin as any);
});
// ── GET /reference/vn-admin/provinces ──────────────────────────────
describe('listProvinces', () => {
it('delegates to VnAdminService.listProvinces and returns result', async () => {
const provinces = [
{ code: '01', name: 'Hà Nội', type: 'thanh_pho', codename: 'thanh_pho_ha_noi' },
];
mockVnAdmin.listProvinces.mockResolvedValue(provinces);
const result = await controller.listProvinces();
expect(result).toEqual(provinces);
expect(mockVnAdmin.listProvinces).toHaveBeenCalledOnce();
});
});
// ── GET /reference/vn-admin/provinces/:code/districts ──────────────
describe('listDistricts', () => {
it('delegates to VnAdminService.listDistricts with the province code', async () => {
const districts = [
{ code: '760', provinceCode: '79', name: 'Quận 1', type: 'quan', codename: 'quan_1' },
];
mockVnAdmin.listDistricts.mockResolvedValue(districts);
const result = await controller.listDistricts('79');
expect(result).toEqual(districts);
expect(mockVnAdmin.listDistricts).toHaveBeenCalledWith('79');
});
});
// ── GET /reference/vn-admin/districts/:code/wards ──────────────────
describe('listWards', () => {
it('delegates to VnAdminService.listWards with the district code', async () => {
const wards = [
{ code: '26734', districtCode: '760', name: 'Phường Bến Nghé', type: 'phuong', codename: 'phuong_ben_nghe' },
];
mockVnAdmin.listWards.mockResolvedValue(wards);
const result = await controller.listWards('760');
expect(result).toEqual(wards);
expect(mockVnAdmin.listWards).toHaveBeenCalledWith('760');
});
});
// ── GET /reference/vn-admin/resolve-district ───────────────────────
describe('resolveDistrict', () => {
it('delegates to VnAdminService.resolveDistrict with name and provinceCode', async () => {
const resolution = {
districtCode: '760',
isCurrent: true,
resolvedViaAlias: false,
aliasReason: null,
};
mockVnAdmin.resolveDistrict.mockResolvedValue(resolution);
const result = await controller.resolveDistrict('Quận 1', '79');
expect(result).toEqual(resolution);
expect(mockVnAdmin.resolveDistrict).toHaveBeenCalledWith('Quận 1', '79');
});
it('delegates without provinceCode when not provided', async () => {
const resolution = {
districtCode: '769',
isCurrent: false,
resolvedViaAlias: true,
aliasReason: 'Sáp nhập',
};
mockVnAdmin.resolveDistrict.mockResolvedValue(resolution);
const result = await controller.resolveDistrict('Quận 2', undefined);
expect(result).toEqual(resolution);
expect(mockVnAdmin.resolveDistrict).toHaveBeenCalledWith('Quận 2', undefined);
});
it('throws BadRequestException when name is undefined', async () => {
await expect(controller.resolveDistrict(undefined, undefined)).rejects.toThrow(
BadRequestException,
);
expect(mockVnAdmin.resolveDistrict).not.toHaveBeenCalled();
});
it('throws BadRequestException when name is empty string', async () => {
await expect(controller.resolveDistrict('', undefined)).rejects.toThrow(
BadRequestException,
);
expect(mockVnAdmin.resolveDistrict).not.toHaveBeenCalled();
});
it('throws BadRequestException when name is whitespace only', async () => {
await expect(controller.resolveDistrict(' ', undefined)).rejects.toThrow(
BadRequestException,
);
expect(mockVnAdmin.resolveDistrict).not.toHaveBeenCalled();
});
it('throws BadRequestException with correct message', async () => {
await expect(controller.resolveDistrict(undefined, undefined)).rejects.toThrow(
'Query parameter "name" is required.',
);
});
});
// ── No auth guard (intentionally public) ───────────────────────────
describe('public access (no auth guard)', () => {
it('controller has no UseGuards decorator — all endpoints are public', () => {
// VnAdminController intentionally has no @UseGuards decorator.
// Verify by checking that the controller class metadata does not include
// the NestJS guards metadata key.
const guards = Reflect.getMetadata('__guards__', VnAdminController);
expect(guards).toBeUndefined();
});
it('individual route handlers have no method-level guards', () => {
const methods = ['listProvinces', 'listDistricts', 'listWards', 'resolveDistrict'] as const;
for (const method of methods) {
const guards = Reflect.getMetadata('__guards__', controller[method]);
expect(guards).toBeUndefined();
}
});
});
});

View File

@@ -18,6 +18,9 @@ export enum ErrorCode {
AUTH_TOKEN_EXPIRED = 'AUTH_TOKEN_EXPIRED', AUTH_TOKEN_EXPIRED = 'AUTH_TOKEN_EXPIRED',
AUTH_TOKEN_INVALID = 'AUTH_TOKEN_INVALID', AUTH_TOKEN_INVALID = 'AUTH_TOKEN_INVALID',
AUTH_INSUFFICIENT_PERMISSIONS = 'AUTH_INSUFFICIENT_PERMISSIONS', AUTH_INSUFFICIENT_PERMISSIONS = 'AUTH_INSUFFICIENT_PERMISSIONS',
AUTH_OAUTH_PROVIDER_ERROR = 'AUTH_OAUTH_PROVIDER_ERROR',
AUTH_CONFIG_MISSING = 'AUTH_CONFIG_MISSING',
AUTH_ACCOUNT_DISABLED = 'AUTH_ACCOUNT_DISABLED',
// User // User
USER_NOT_FOUND = 'USER_NOT_FOUND', USER_NOT_FOUND = 'USER_NOT_FOUND',
@@ -69,4 +72,13 @@ export enum ErrorCode {
// AI / Claude integration // AI / Claude integration
AI_NOT_CONFIGURED = 'AI_NOT_CONFIGURED', AI_NOT_CONFIGURED = 'AI_NOT_CONFIGURED',
AI_PROVIDER_ERROR = 'AI_PROVIDER_ERROR', AI_PROVIDER_ERROR = 'AI_PROVIDER_ERROR',
// Notifications
NOTIFICATION_TEMPLATE_NOT_FOUND = 'NOTIFICATION_TEMPLATE_NOT_FOUND',
NOTIFICATION_PROVIDER_NOT_CONFIGURED = 'NOTIFICATION_PROVIDER_NOT_CONFIGURED',
NOTIFICATION_PROVIDER_ERROR = 'NOTIFICATION_PROVIDER_ERROR',
NOTIFICATION_INVALID_TOKEN = 'NOTIFICATION_INVALID_TOKEN',
NOTIFICATION_INTERACTION_WINDOW_EXPIRED = 'NOTIFICATION_INTERACTION_WINDOW_EXPIRED',
NOTIFICATION_LINK_NOT_FOUND = 'NOTIFICATION_LINK_NOT_FOUND',
NOTIFICATION_OAUTH_INVALID_STATE = 'NOTIFICATION_OAUTH_INVALID_STATE',
} }

View File

@@ -1,3 +1,6 @@
import { DomainException } from './domain-exception';
import { ErrorCode } from './error-codes';
export class Result<T, E = Error> { export class Result<T, E = Error> {
private constructor( private constructor(
private readonly _isOk: boolean, private readonly _isOk: boolean,
@@ -28,7 +31,7 @@ export class Result<T, E = Error> {
unwrapErr(): E { unwrapErr(): E {
if (!this._isOk) return this._error as E; if (!this._isOk) return this._error as E;
throw new Error('Called unwrapErr on an Ok result'); throw new DomainException(ErrorCode.INTERNAL_ERROR, 'Called unwrapErr on an Ok result');
} }
map<U>(fn: (value: T) => U): Result<U, E> { map<U>(fn: (value: T) => U): Result<U, E> {

View File

@@ -6,6 +6,9 @@
* are required only in production. * are required only in production.
*/ */
import { DomainException } from '../domain/domain-exception';
import { ErrorCode } from '../domain/error-codes';
const ALWAYS_REQUIRED: readonly string[] = [ const ALWAYS_REQUIRED: readonly string[] = [
'JWT_SECRET', 'JWT_SECRET',
'JWT_REFRESH_SECRET', 'JWT_REFRESH_SECRET',
@@ -104,9 +107,9 @@ export function validateEnv(): void {
} }
if (missing.length > 0) { if (missing.length > 0) {
throw new Error( throw new DomainException(
`Missing required environment variables:\n ${missing.join('\n ')}\n` + ErrorCode.INTERNAL_ERROR,
'JWT_SECRET and JWT_REFRESH_SECRET must always be set. See .env.example.', `Missing required environment variables:\n ${missing.join('\n ')}\nJWT_SECRET and JWT_REFRESH_SECRET must always be set. See .env.example.`,
); );
} }
@@ -121,9 +124,9 @@ export function validateEnv(): void {
} }
if (secretErrors.length > 0) { if (secretErrors.length > 0) {
throw new Error( throw new DomainException(
`Insecure JWT secret configuration:\n ${secretErrors.join('\n ')}\n` + ErrorCode.INTERNAL_ERROR,
'Generate secure secrets with: openssl rand -base64 48', `Insecure JWT secret configuration:\n ${secretErrors.join('\n ')}\nGenerate secure secrets with: openssl rand -base64 48`,
); );
} }
@@ -140,9 +143,9 @@ export function validateEnv(): void {
} }
if (missingProd.length > 0) { if (missingProd.length > 0) {
throw new Error( throw new DomainException(
`Missing required environment variables in production:\n ${missingProd.join('\n ')}\n` + ErrorCode.INTERNAL_ERROR,
'See .env.example for the full list of variables.', `Missing required environment variables in production:\n ${missingProd.join('\n ')}\nSee .env.example for the full list of variables.`,
); );
} }

View File

@@ -7,6 +7,8 @@
*/ */
import crypto from 'node:crypto'; import crypto from 'node:crypto';
import { DomainException } from '../domain/domain-exception';
import { ErrorCode } from '../domain/error-codes';
const ALGORITHM = 'aes-256-gcm'; const ALGORITHM = 'aes-256-gcm';
const IV_LENGTH = 12; // 96-bit IV recommended for GCM const IV_LENGTH = 12; // 96-bit IV recommended for GCM
@@ -23,7 +25,8 @@ export interface FieldEncryptionConfig {
function deriveKeyBuffer(hexKey: string): Buffer { function deriveKeyBuffer(hexKey: string): Buffer {
const buf = Buffer.from(hexKey, 'hex'); const buf = Buffer.from(hexKey, 'hex');
if (buf.length !== 32) { if (buf.length !== 32) {
throw new Error( throw new DomainException(
ErrorCode.INTERNAL_ERROR,
`KYC_ENCRYPTION_KEY must be exactly 32 bytes (64 hex chars), got ${buf.length} bytes`, `KYC_ENCRYPTION_KEY must be exactly 32 bytes (64 hex chars), got ${buf.length} bytes`,
); );
} }
@@ -63,7 +66,10 @@ export function decryptField(stored: unknown, config: FieldEncryptionConfig): un
// Format: enc:v{version}:{iv}:{authTag}:{ciphertext} // Format: enc:v{version}:{iv}:{authTag}:{ciphertext}
const parts = stored.slice(PREFIX.length).split(':'); const parts = stored.slice(PREFIX.length).split(':');
if (parts.length !== 4) { if (parts.length !== 4) {
throw new Error('Malformed encrypted field: expected 4 segments after prefix'); throw new DomainException(
ErrorCode.VALIDATION_FAILED,
'Malformed encrypted field: expected 4 segments after prefix',
);
} }
const [_versionTag, ivHex, authTagHex, ciphertextHex] = parts; const [_versionTag, ivHex, authTagHex, ciphertextHex] = parts;

View File

@@ -10,6 +10,29 @@ export default defineConfig({
env: { env: {
BCRYPT_ROUNDS: '4', BCRYPT_ROUNDS: '4',
}, },
coverage: {
provider: 'v8',
reporter: ['text', 'text-summary', 'json-summary', 'lcov', 'clover'],
reportsDirectory: './coverage',
include: ['src/modules/**/*.ts'],
exclude: [
'src/**/*.spec.ts',
'src/**/*.integration.spec.ts',
'src/**/*.mock.ts',
'src/**/*.module.ts',
'src/**/index.ts',
'src/**/__tests__/**',
'src/**/__mocks__/**',
'src/**/dto/**',
'node_modules',
],
thresholds: {
statements: 60,
branches: 50,
functions: 60,
lines: 60,
},
},
}, },
resolve: { resolve: {
alias: { alias: {

View File

@@ -0,0 +1,359 @@
import { describe, expect, it, vi, beforeEach } from 'vitest';
import { NextRequest, NextResponse } from 'next/server';
// Hoist mock so it's available in the vi.mock factory
const { mockIntlMiddleware } = vi.hoisted(() => ({
mockIntlMiddleware: vi.fn(() => NextResponse.next()),
}));
vi.mock('next-intl/middleware', () => ({
default: vi.fn(() => mockIntlMiddleware),
}));
// Mock routing config
vi.mock('@/i18n/routing', () => ({
routing: {
locales: ['vi', 'en'],
defaultLocale: 'vi',
localePrefix: 'as-needed',
},
}));
import { middleware, config } from '../middleware';
function createRequest(
pathname: string,
options?: { cookies?: Record<string, string>; origin?: string },
): NextRequest {
const origin = options?.origin ?? 'http://localhost:3200';
const url = new URL(pathname, origin);
const request = new NextRequest(url);
if (options?.cookies) {
for (const [name, value] of Object.entries(options.cookies)) {
request.cookies.set(name, value);
}
}
return request;
}
describe('middleware', () => {
beforeEach(() => {
vi.clearAllMocks();
mockIntlMiddleware.mockReturnValue(NextResponse.next());
});
describe('matcher config', () => {
it('exports a valid matcher that excludes api and static routes', () => {
expect(config).toBeDefined();
expect(config.matcher).toEqual([
'/((?!api|_next/static|_next/image|favicon.ico|public).*)',
]);
});
it('matcher pattern matches normal routes', () => {
const pattern = new RegExp(config.matcher[0]);
expect(pattern.test('/dashboard')).toBe(true);
expect(pattern.test('/login')).toBe(true);
expect(pattern.test('/vi/search')).toBe(true);
});
});
describe('public paths — unauthenticated access', () => {
it('allows unauthenticated access to the root path /', () => {
const request = createRequest('/');
const response = middleware(request);
// Should not redirect to login — should pass through to intlMiddleware
expect(response.headers.get('location')).toBeNull();
});
it('allows unauthenticated access to /login', () => {
const request = createRequest('/login');
const response = middleware(request);
expect(response.headers.get('location')).toBeNull();
});
it('allows unauthenticated access to /register', () => {
const request = createRequest('/register');
const response = middleware(request);
expect(response.headers.get('location')).toBeNull();
});
it('allows unauthenticated access to /search', () => {
const request = createRequest('/search');
const response = middleware(request);
expect(response.headers.get('location')).toBeNull();
});
it('allows unauthenticated access to /search/anything', () => {
const request = createRequest('/search/filters?type=APARTMENT');
const response = middleware(request);
expect(response.headers.get('location')).toBeNull();
});
it('allows unauthenticated access to /listings', () => {
const request = createRequest('/listings');
const response = middleware(request);
expect(response.headers.get('location')).toBeNull();
});
it('allows unauthenticated access to /listings/:id', () => {
const request = createRequest('/listings/abc-123');
const response = middleware(request);
expect(response.headers.get('location')).toBeNull();
});
it('allows unauthenticated access to /auth/callback', () => {
const request = createRequest('/auth/callback');
const response = middleware(request);
expect(response.headers.get('location')).toBeNull();
});
it('allows unauthenticated access to /auth/callback/google', () => {
const request = createRequest('/auth/callback/google');
const response = middleware(request);
expect(response.headers.get('location')).toBeNull();
});
});
describe('auth redirect — protected routes require authentication', () => {
it('redirects unauthenticated users from /dashboard to /login', () => {
const request = createRequest('/dashboard');
const response = middleware(request);
const location = response.headers.get('location');
expect(location).toBeTruthy();
const redirectUrl = new URL(location!);
expect(redirectUrl.pathname).toBe('/login');
expect(redirectUrl.searchParams.get('redirect')).toBe('/dashboard');
});
it('redirects unauthenticated users from /profile to /login', () => {
const request = createRequest('/profile');
const response = middleware(request);
const location = response.headers.get('location');
expect(location).toBeTruthy();
const redirectUrl = new URL(location!);
expect(redirectUrl.pathname).toBe('/login');
expect(redirectUrl.searchParams.get('redirect')).toBe('/profile');
});
it('redirects unauthenticated users from /my-listings to /login with redirect param', () => {
const request = createRequest('/my-listings');
const response = middleware(request);
const location = response.headers.get('location');
expect(location).toBeTruthy();
const redirectUrl = new URL(location!);
expect(redirectUrl.pathname).toBe('/login');
expect(redirectUrl.searchParams.get('redirect')).toBe('/my-listings');
});
it('allows authenticated users to access /dashboard', () => {
const request = createRequest('/dashboard', {
cookies: { goodgo_authenticated: 'true' },
});
const response = middleware(request);
expect(response.headers.get('location')).toBeNull();
});
it('allows authenticated users to access /profile', () => {
const request = createRequest('/profile', {
cookies: { goodgo_authenticated: 'true' },
});
const response = middleware(request);
expect(response.headers.get('location')).toBeNull();
});
});
describe('auth-only redirect — authenticated users bounce from login/register', () => {
it('redirects authenticated users from /login to /dashboard', () => {
const request = createRequest('/login', {
cookies: { goodgo_authenticated: 'true' },
});
const response = middleware(request);
const location = response.headers.get('location');
expect(location).toBeTruthy();
const redirectUrl = new URL(location!);
expect(redirectUrl.pathname).toBe('/dashboard');
});
it('redirects authenticated users from /register to /dashboard', () => {
const request = createRequest('/register', {
cookies: { goodgo_authenticated: 'true' },
});
const response = middleware(request);
const location = response.headers.get('location');
expect(location).toBeTruthy();
const redirectUrl = new URL(location!);
expect(redirectUrl.pathname).toBe('/dashboard');
});
it('does NOT redirect authenticated users from other public paths', () => {
const request = createRequest('/search', {
cookies: { goodgo_authenticated: 'true' },
});
const response = middleware(request);
expect(response.headers.get('location')).toBeNull();
});
});
describe('locale stripping — paths with locale prefix', () => {
it('strips /vi prefix and allows public path', () => {
const request = createRequest('/vi/login');
const response = middleware(request);
// /login is public, no redirect to /login (would be self-redirect)
expect(response.headers.get('location')).toBeNull();
});
it('strips /en prefix and allows public path', () => {
const request = createRequest('/en/search');
const response = middleware(request);
expect(response.headers.get('location')).toBeNull();
});
it('strips /vi prefix and redirects protected path to login', () => {
const request = createRequest('/vi/dashboard');
const response = middleware(request);
const location = response.headers.get('location');
expect(location).toBeTruthy();
const redirectUrl = new URL(location!);
expect(redirectUrl.pathname).toBe('/login');
expect(redirectUrl.searchParams.get('redirect')).toBe('/dashboard');
});
it('strips /en prefix and redirects protected path to login', () => {
const request = createRequest('/en/profile');
const response = middleware(request);
const location = response.headers.get('location');
expect(location).toBeTruthy();
const redirectUrl = new URL(location!);
expect(redirectUrl.pathname).toBe('/login');
expect(redirectUrl.searchParams.get('redirect')).toBe('/profile');
});
it('strips /vi prefix for auth-only redirect for authenticated users', () => {
const request = createRequest('/vi/login', {
cookies: { goodgo_authenticated: 'true' },
});
const response = middleware(request);
const location = response.headers.get('location');
expect(location).toBeTruthy();
const redirectUrl = new URL(location!);
expect(redirectUrl.pathname).toBe('/dashboard');
});
it('strips /en prefix for auth-only redirect for authenticated users', () => {
const request = createRequest('/en/register', {
cookies: { goodgo_authenticated: 'true' },
});
const response = middleware(request);
const location = response.headers.get('location');
expect(location).toBeTruthy();
const redirectUrl = new URL(location!);
expect(redirectUrl.pathname).toBe('/dashboard');
});
});
describe('intl middleware delegation', () => {
it('delegates to intlMiddleware for authenticated public paths', () => {
const request = createRequest('/search', {
cookies: { goodgo_authenticated: 'true' },
});
middleware(request);
expect(mockIntlMiddleware).toHaveBeenCalledWith(request);
});
it('delegates to intlMiddleware for unauthenticated public paths', () => {
const request = createRequest('/');
middleware(request);
expect(mockIntlMiddleware).toHaveBeenCalledWith(request);
});
it('delegates to intlMiddleware for authenticated protected paths', () => {
const request = createRequest('/dashboard', {
cookies: { goodgo_authenticated: 'true' },
});
middleware(request);
expect(mockIntlMiddleware).toHaveBeenCalledWith(request);
});
it('does NOT delegate to intlMiddleware when redirecting to login', () => {
const request = createRequest('/dashboard');
middleware(request);
expect(mockIntlMiddleware).not.toHaveBeenCalled();
});
it('does NOT delegate to intlMiddleware when redirecting to dashboard', () => {
const request = createRequest('/login', {
cookies: { goodgo_authenticated: 'true' },
});
middleware(request);
expect(mockIntlMiddleware).not.toHaveBeenCalled();
});
});
describe('edge cases', () => {
it('handles path with trailing slash', () => {
const request = createRequest('/dashboard/');
const response = middleware(request);
const location = response.headers.get('location');
expect(location).toBeTruthy();
const redirectUrl = new URL(location!);
expect(redirectUrl.pathname).toBe('/login');
});
it('handles deeply nested protected path', () => {
const request = createRequest('/dashboard/analytics/reports');
const response = middleware(request);
const location = response.headers.get('location');
expect(location).toBeTruthy();
const redirectUrl = new URL(location!);
expect(redirectUrl.pathname).toBe('/login');
expect(redirectUrl.searchParams.get('redirect')).toBe(
'/dashboard/analytics/reports',
);
});
it('handles root path with locale prefix /vi', () => {
const request = createRequest('/vi');
const response = middleware(request);
// /vi -> stripped to / -> public, no redirect
expect(response.headers.get('location')).toBeNull();
});
it('handles root path with locale prefix /en', () => {
const request = createRequest('/en');
const response = middleware(request);
expect(response.headers.get('location')).toBeNull();
});
it('does not treat partial locale match as locale prefix', () => {
// /video should NOT strip "vi" prefix — "vi" must be followed by / or end
const request = createRequest('/video');
const response = middleware(request);
// /video is not public, so should redirect to login
const location = response.headers.get('location');
expect(location).toBeTruthy();
const redirectUrl = new URL(location!);
expect(redirectUrl.pathname).toBe('/login');
});
it('preserves redirect path through query string in login redirect', () => {
const request = createRequest('/settings?tab=profile');
const response = middleware(request);
const location = response.headers.get('location');
expect(location).toBeTruthy();
const redirectUrl = new URL(location!);
expect(redirectUrl.pathname).toBe('/login');
// The redirect param should contain the stripped path
expect(redirectUrl.searchParams.get('redirect')).toBe('/settings');
});
it('handles cookie with empty string value as having the cookie', () => {
const request = createRequest('/dashboard', {
cookies: { goodgo_authenticated: '' },
});
const response = middleware(request);
// cookies.has() returns true even with empty value
expect(response.headers.get('location')).toBeNull();
});
});
});

View File

@@ -0,0 +1,49 @@
import { describe, expect, it } from 'vitest';
import { locales, defaultLocale } from '../config';
import type { Locale } from '../config';
describe('i18n/config', () => {
describe('locales', () => {
it('exports an array of supported locales', () => {
expect(Array.isArray(locales)).toBe(true);
expect(locales.length).toBeGreaterThanOrEqual(2);
});
it('includes Vietnamese (vi) as a supported locale', () => {
expect(locales).toContain('vi');
});
it('includes English (en) as a supported locale', () => {
expect(locales).toContain('en');
});
it('only contains vi and en', () => {
expect([...locales]).toEqual(['vi', 'en']);
});
it('is a readonly tuple', () => {
// TypeScript const assertion — runtime check that it behaves as expected
const copy = [...locales];
expect(copy).toEqual(['vi', 'en']);
});
});
describe('defaultLocale', () => {
it('is set to Vietnamese (vi)', () => {
expect(defaultLocale).toBe('vi');
});
it('is one of the supported locales', () => {
expect(locales).toContain(defaultLocale);
});
});
describe('Locale type', () => {
it('accepts valid locale values', () => {
const vi: Locale = 'vi';
const en: Locale = 'en';
expect(vi).toBe('vi');
expect(en).toBe('en');
});
});
});

View File

@@ -0,0 +1,51 @@
import { describe, expect, it, vi } from 'vitest';
const { mockCreateNavigation } = vi.hoisted(() => ({
mockCreateNavigation: vi.fn(() => ({
Link: 'MockLink',
redirect: 'mockRedirect',
usePathname: 'mockUsePathname',
useRouter: 'mockUseRouter',
})),
}));
// Mock routing config
vi.mock('../routing', () => ({
routing: {
locales: ['vi', 'en'] as const,
defaultLocale: 'vi',
localePrefix: 'as-needed',
},
}));
vi.mock('next-intl/navigation', () => ({
createNavigation: mockCreateNavigation,
}));
import { Link, redirect, usePathname, useRouter } from '../navigation';
describe('i18n/navigation', () => {
it('calls createNavigation with the routing config', () => {
expect(mockCreateNavigation).toHaveBeenCalledWith({
locales: ['vi', 'en'],
defaultLocale: 'vi',
localePrefix: 'as-needed',
});
});
it('exports Link from createNavigation', () => {
expect(Link).toBeDefined();
});
it('exports redirect from createNavigation', () => {
expect(redirect).toBeDefined();
});
it('exports usePathname from createNavigation', () => {
expect(usePathname).toBeDefined();
});
it('exports useRouter from createNavigation', () => {
expect(useRouter).toBeDefined();
});
});

View File

@@ -0,0 +1,121 @@
import { describe, expect, it, vi, beforeEach } from 'vitest';
// Mock the routing module
vi.mock('../routing', () => ({
routing: {
locales: ['vi', 'en'] as const,
defaultLocale: 'vi',
localePrefix: 'as-needed',
},
}));
// Track getRequestConfig calls
let capturedConfigFn: ((params: { requestLocale: Promise<string | undefined> }) => Promise<{
locale: string;
messages: Record<string, unknown>;
}>) | null = null;
vi.mock('next-intl/server', () => ({
getRequestConfig: (fn: typeof capturedConfigFn) => {
capturedConfigFn = fn;
return fn;
},
}));
// Provide message mocks
vi.mock('../../messages/vi.json', () => ({
default: {
common: { goodgo: 'GoodGo', loading: 'Đang tải...' },
metadata: { title: 'GoodGo — Nền tảng Bất động sản Việt Nam' },
},
}));
vi.mock('../../messages/en.json', () => ({
default: {
common: { goodgo: 'GoodGo', loading: 'Loading...' },
metadata: { title: 'GoodGo — Vietnam Real Estate Platform' },
},
}));
// Import triggers getRequestConfig, which captures the config function
await import('../request');
describe('i18n/request', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('captures a config function via getRequestConfig', () => {
expect(capturedConfigFn).toBeTypeOf('function');
});
describe('locale resolution', () => {
it('uses the requested locale when it is valid (vi)', async () => {
const result = await capturedConfigFn!({
requestLocale: Promise.resolve('vi'),
});
expect(result.locale).toBe('vi');
});
it('uses the requested locale when it is valid (en)', async () => {
const result = await capturedConfigFn!({
requestLocale: Promise.resolve('en'),
});
expect(result.locale).toBe('en');
});
it('falls back to default locale (vi) when requestLocale is undefined', async () => {
const result = await capturedConfigFn!({
requestLocale: Promise.resolve(undefined),
});
expect(result.locale).toBe('vi');
});
it('falls back to default locale (vi) when requestLocale is empty string', async () => {
const result = await capturedConfigFn!({
requestLocale: Promise.resolve(''),
});
expect(result.locale).toBe('vi');
});
it('falls back to default locale (vi) for an invalid locale', async () => {
const result = await capturedConfigFn!({
requestLocale: Promise.resolve('fr'),
});
expect(result.locale).toBe('vi');
});
it('falls back to default locale (vi) for another invalid locale', async () => {
const result = await capturedConfigFn!({
requestLocale: Promise.resolve('ja'),
});
expect(result.locale).toBe('vi');
});
});
describe('message loading', () => {
it('loads Vietnamese messages for vi locale', async () => {
const result = await capturedConfigFn!({
requestLocale: Promise.resolve('vi'),
});
expect(result.messages).toBeDefined();
expect((result.messages as Record<string, Record<string, string>>).common.loading).toBe('Đang tải...');
});
it('loads English messages for en locale', async () => {
const result = await capturedConfigFn!({
requestLocale: Promise.resolve('en'),
});
expect(result.messages).toBeDefined();
expect((result.messages as Record<string, Record<string, string>>).common.loading).toBe('Loading...');
});
it('loads Vietnamese messages when falling back from invalid locale', async () => {
const result = await capturedConfigFn!({
requestLocale: Promise.resolve('fr'),
});
expect(result.locale).toBe('vi');
expect((result.messages as Record<string, Record<string, string>>).common.loading).toBe('Đang tải...');
});
});
});

View File

@@ -0,0 +1,53 @@
import { describe, expect, it, vi } from 'vitest';
const { mockDefineRouting } = vi.hoisted(() => ({
mockDefineRouting: vi.fn((config: unknown) => config),
}));
// Mock the config module before importing routing
vi.mock('../config', () => ({
locales: ['vi', 'en'] as const,
defaultLocale: 'vi' as const,
}));
// Mock next-intl/routing
vi.mock('next-intl/routing', () => ({
defineRouting: mockDefineRouting,
}));
import { routing } from '../routing';
describe('i18n/routing', () => {
it('calls defineRouting with the correct configuration', () => {
expect(mockDefineRouting).toHaveBeenCalledWith({
locales: ['vi', 'en'],
defaultLocale: 'vi',
localePrefix: 'as-needed',
});
});
it('exports a routing object', () => {
expect(routing).toBeDefined();
});
it('includes all supported locales', () => {
const routingConfig = mockDefineRouting.mock.calls[0][0] as {
locales: readonly string[];
};
expect(routingConfig.locales).toEqual(['vi', 'en']);
});
it('sets defaultLocale to vi', () => {
const routingConfig = mockDefineRouting.mock.calls[0][0] as {
defaultLocale: string;
};
expect(routingConfig.defaultLocale).toBe('vi');
});
it('uses as-needed locale prefix strategy', () => {
const routingConfig = mockDefineRouting.mock.calls[0][0] as {
localePrefix: string;
};
expect(routingConfig.localePrefix).toBe('as-needed');
});
});

View File

@@ -0,0 +1,222 @@
import { describe, expect, it } from 'vitest';
import viMessages from '../../messages/vi.json';
import enMessages from '../../messages/en.json';
describe('locale messages', () => {
describe('structural parity between vi.json and en.json', () => {
function getKeys(obj: Record<string, unknown>, prefix = ''): string[] {
const keys: string[] = [];
for (const key of Object.keys(obj)) {
const fullKey = prefix ? `${prefix}.${key}` : key;
if (
typeof obj[key] === 'object' &&
obj[key] !== null &&
!Array.isArray(obj[key])
) {
keys.push(
...getKeys(obj[key] as Record<string, unknown>, fullKey),
);
} else {
keys.push(fullKey);
}
}
return keys.sort();
}
it('vi.json and en.json have the same top-level namespaces', () => {
const viNamespaces = Object.keys(viMessages).sort();
const enNamespaces = Object.keys(enMessages).sort();
expect(viNamespaces).toEqual(enNamespaces);
});
it('vi.json and en.json have the exact same key structure', () => {
const viKeys = getKeys(viMessages as unknown as Record<string, unknown>);
const enKeys = getKeys(enMessages as unknown as Record<string, unknown>);
expect(viKeys).toEqual(enKeys);
});
it('no keys are missing from en.json', () => {
const viKeys = new Set(
getKeys(viMessages as unknown as Record<string, unknown>),
);
const enKeys = new Set(
getKeys(enMessages as unknown as Record<string, unknown>),
);
const missingInEn = [...viKeys].filter((k) => !enKeys.has(k));
expect(missingInEn).toEqual([]);
});
it('no keys are missing from vi.json', () => {
const viKeys = new Set(
getKeys(viMessages as unknown as Record<string, unknown>),
);
const enKeys = new Set(
getKeys(enMessages as unknown as Record<string, unknown>),
);
const missingInVi = [...enKeys].filter((k) => !viKeys.has(k));
expect(missingInVi).toEqual([]);
});
});
describe('Vietnamese (vi.json) content', () => {
it('has a metadata namespace with title', () => {
expect(viMessages.metadata).toBeDefined();
expect(viMessages.metadata.title).toBe(
'GoodGo — Nền tảng Bất động sản Việt Nam',
);
});
it('has common namespace with Vietnamese translations', () => {
expect(viMessages.common.loading).toBe('Đang tải...');
expect(viMessages.common.login).toBe('Đăng nhập');
expect(viMessages.common.register).toBe('Đăng ký');
expect(viMessages.common.logout).toBe('Đăng xuất');
});
it('has nav namespace with Vietnamese navigation labels', () => {
expect(viMessages.nav.home).toBe('Trang chủ');
expect(viMessages.nav.search).toBe('Tìm kiếm');
expect(viMessages.nav.pricing).toBe('Bảng giá');
});
it('has propertyTypes namespace with all 9 property types in Vietnamese', () => {
const types = viMessages.propertyTypes;
expect(Object.keys(types)).toHaveLength(9);
expect(types.APARTMENT).toBe('Căn hộ');
expect(types.HOUSE).toBe('Nhà riêng');
expect(types.VILLA).toBe('Biệt thự');
expect(types.LAND).toBe('Đất nền');
expect(types.OFFICE).toBe('Văn phòng');
expect(types.SHOPHOUSE).toBe('Shophouse');
expect(types.ROOM_RENTAL).toBe('Phòng trọ');
expect(types.CONDOTEL).toBe('Condotel');
expect(types.SERVICED_APARTMENT).toBe('Căn hộ dịch vụ');
});
it('has transactionTypes in Vietnamese', () => {
expect(viMessages.transactionTypes.SALE).toBe('Bán');
expect(viMessages.transactionTypes.RENT).toBe('Cho thuê');
});
it('has language labels including self-referencing Vietnamese label', () => {
expect(viMessages.language.label).toBe('Ngôn ngữ');
expect(viMessages.language.vi).toBe('Tiếng Việt');
expect(viMessages.language.en).toBe('English');
});
it('has auth namespace with login form labels', () => {
expect(viMessages.auth.loginTitle).toBe('Đăng nhập');
expect(viMessages.auth.phone).toBe('Số điện thoại');
expect(viMessages.auth.password).toBe('Mật khẩu');
});
it('has pricing namespace with plan tier labels', () => {
expect(viMessages.pricing.tiers.FREE).toBe('Miễn phí');
expect(viMessages.pricing.tiers.AGENT_PRO).toBe('Môi giới Pro');
expect(viMessages.pricing.tiers.INVESTOR).toBe('Nhà đầu tư');
expect(viMessages.pricing.tiers.ENTERPRISE).toBe('Doanh nghiệp');
});
it('has search namespace with filter labels', () => {
expect(viMessages.search.filters).toBe('Bộ lọc');
expect(viMessages.search.district).toBe('Quận/huyện');
expect(viMessages.search.ward).toBe('Phường/xã');
});
});
describe('English (en.json) content', () => {
it('has a metadata namespace with English title', () => {
expect(enMessages.metadata.title).toBe(
'GoodGo — Vietnam Real Estate Platform',
);
});
it('has common namespace with English translations', () => {
expect(enMessages.common.loading).toBe('Loading...');
expect(enMessages.common.login).toBe('Login');
expect(enMessages.common.register).toBe('Register');
expect(enMessages.common.logout).toBe('Logout');
});
it('has propertyTypes in English', () => {
expect(enMessages.propertyTypes.APARTMENT).toBe('Apartment');
expect(enMessages.propertyTypes.HOUSE).toBe('House');
expect(enMessages.propertyTypes.VILLA).toBe('Villa');
});
it('has transactionTypes in English', () => {
expect(enMessages.transactionTypes.SALE).toBe('Sale');
expect(enMessages.transactionTypes.RENT).toBe('Rent');
});
});
describe('interpolation placeholders', () => {
it('vi.json common.errorCode contains {code} placeholder', () => {
expect(viMessages.common.errorCode).toContain('{code}');
});
it('en.json common.errorCode contains {code} placeholder', () => {
expect(enMessages.common.errorCode).toContain('{code}');
});
it('vi.json common.retriedCount contains {count} placeholder', () => {
expect(viMessages.common.retriedCount).toContain('{count}');
});
it('en.json common.retriedCount contains {count} placeholder', () => {
expect(enMessages.common.retriedCount).toContain('{count}');
});
it('vi.json search.bedroomsCount contains {count} placeholder', () => {
expect(viMessages.search.bedroomsCount).toContain('{count}');
});
it('en.json search.bedroomsCount contains {count} placeholder', () => {
expect(enMessages.search.bedroomsCount).toContain('{count}');
});
it('vi.json and en.json pricing.currentPlanBadge both contain {plan}', () => {
expect(viMessages.pricing.currentPlanBadge).toContain('{plan}');
expect(enMessages.pricing.currentPlanBadge).toContain('{plan}');
});
});
describe('no empty values', () => {
function findEmptyValues(
obj: Record<string, unknown>,
prefix = '',
): string[] {
const empty: string[] = [];
for (const key of Object.keys(obj)) {
const fullKey = prefix ? `${prefix}.${key}` : key;
const value = obj[key];
if (
typeof value === 'object' &&
value !== null &&
!Array.isArray(value)
) {
empty.push(
...findEmptyValues(value as Record<string, unknown>, fullKey),
);
} else if (typeof value === 'string' && value.trim() === '') {
empty.push(fullKey);
}
}
return empty;
}
it('vi.json has no empty string values', () => {
const emptyKeys = findEmptyValues(
viMessages as unknown as Record<string, unknown>,
);
expect(emptyKeys).toEqual([]);
});
it('en.json has no empty string values', () => {
const emptyKeys = findEmptyValues(
enMessages as unknown as Record<string, unknown>,
);
expect(emptyKeys).toEqual([]);
});
});
});

View File

@@ -8,6 +8,7 @@
"start": "next start", "start": "next start",
"lint": "eslint src/ app/ components/ lib/ hooks/ i18n/ --no-error-on-unmatched-pattern", "lint": "eslint src/ app/ components/ lib/ hooks/ i18n/ --no-error-on-unmatched-pattern",
"test": "vitest run", "test": "vitest run",
"test:coverage": "vitest run --coverage",
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },
"dependencies": { "dependencies": {
@@ -44,6 +45,7 @@
"@types/react": "^18.3.0", "@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0", "@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.7.0", "@vitejs/plugin-react": "^4.7.0",
"@vitest/coverage-v8": "^4.1.3",
"autoprefixer": "^10.4.0", "autoprefixer": "^10.4.0",
"jsdom": "^29.0.2", "jsdom": "^29.0.2",
"msw": "^2.13.2", "msw": "^2.13.2",

View File

@@ -10,6 +10,38 @@ export default defineConfig({
environment: 'jsdom', environment: 'jsdom',
setupFiles: ['./vitest.setup.ts'], setupFiles: ['./vitest.setup.ts'],
globals: true, globals: true,
coverage: {
provider: 'v8',
reporter: ['text', 'text-summary', 'json-summary', 'lcov', 'clover'],
reportsDirectory: './coverage',
include: [
'app/**/*.ts',
'app/**/*.tsx',
'components/**/*.ts',
'components/**/*.tsx',
'lib/**/*.ts',
'hooks/**/*.ts',
],
exclude: [
'**/*.spec.ts',
'**/*.spec.tsx',
'**/*.test.ts',
'**/*.test.tsx',
'**/__tests__/**',
'**/__mocks__/**',
'**/node_modules/**',
'app/**/layout.tsx',
'app/**/loading.tsx',
'app/**/error.tsx',
'app/**/not-found.tsx',
],
thresholds: {
statements: 60,
branches: 50,
functions: 60,
lines: 60,
},
},
}, },
resolve: { resolve: {
alias: { alias: {

View File

@@ -1,80 +1,144 @@
# GoodGo Platform AI — Theo Dõi Dự Án # GoodGo Platform AI — Theo Dõi Dự Án
**Cập Nhật Lần Cuối:** 2026-04-22 **Cập Nhật Lần Cuối:** 2026-04-23
**Dự Án:** Goodgo Platform AI **Dự Án:** Goodgo Platform AI
**Trạng Thái:** GOO-2 Audit & Execution — Sprint 1 đang triển khai **Trạng Thái:** GOO-2 Audit & Execution — Audit phase đang triển khai
--- ---
## GOO-2 Lead Orchestrator Audit — Task Tracker (2026-04-22) ## GOO-2 Lead Orchestrator — Task Tracker (2026-04-23)
### Sprint 1 — Blockers + P0 Security ### Sprint 1 — Blockers + P0 Security
| Task | Tiêu đề | Ưu tiên | Owner | Trạng thái | | Task | Tiêu đề | Ưu tiên | Owner | Trạng thái |
|------|---------|---------|-------|------------| |------|---------|---------|-------|------------|
| GOO-3 | Fix double CSRF middleware | Critical | Backend TechLead | ✅ done | | GOO-3 | Fix double CSRF middleware | Critical | Backend TechLead | ✅ done |
| GOO-4 | UsageRecord atomic metering | Critical | Senior Backend Engineer | 🔄 in_progress | | GOO-4 | UsageRecord atomic metering | Critical | Senior Backend Engineer | ✅ done |
| GOO-5 | Rate-limit exchange-token | Critical | Junior Backend Engineer | 🔄 in_progress | | GOO-5 | Rate-limit exchange-token | Critical | Junior Backend Engineer | ✅ done |
| GOO-6 | Fix MoMo IPN URL | Critical | Middle Backend Engineer | 🔄 in_progress | | GOO-6 | Fix MoMo IPN URL | Critical | Middle Backend Engineer | ✅ done |
| GOO-7 | JWT validate user status | Critical | Security Engineer | 🔄 in_progress | | GOO-7 | JWT validate user status | Critical | Security Engineer | ✅ done |
| GOO-8 | Encrypt SystemSetting secrets | Critical | Security Engineer | ⏳ todo | | GOO-8 | Encrypt SystemSetting secrets | Critical | CEO | ✅ done |
| GOO-9 | Fix MCP status filter | Critical | Junior Backend Engineer | ⏳ todo | | GOO-9 | Fix MCP status filter | Critical | Junior Backend Engineer | ✅ done |
| GOO-10 | Update PRODUCTION_READINESS.md | High | Doc Bot | 🔄 in_progress | | GOO-10 | Update PRODUCTION_READINESS.md | High | Product Manager | ✅ done |
### Sprint 2 — P0 Features + Trust ### Sprint 2 — P0 Features + Trust
| Task | Tiêu đề | Ưu tiên | Owner | Trạng thái | | Task | Tiêu đề | Ưu tiên | Owner | Trạng thái |
|------|---------|---------|-------|------------| |------|---------|---------|-------|------------|
| GOO-11 | Phone-OTP login | Critical | Backend TechLead | 🔄 in_progress | | GOO-11 | Phone-OTP login | Critical | Backend TechLead | ✅ done |
| GOO-12 | legalStatus enum + badge | Critical | Senior Backend Engineer | ⏳ todo | | GOO-12 | legalStatus enum + badge | Critical | Senior Backend Engineer | ✅ done |
| GOO-13 | Vietnamese diacritic search | Critical | Backend TechLead | ⏳ todo | | GOO-13 | Vietnamese diacritic search | Critical | Senior Backend Engineer | ✅ done |
| GOO-14 | Remove $queryRawUnsafe | High | Middle Backend Engineer | ⏳ todo | | GOO-14 | Remove $queryRawUnsafe | High | Middle Backend Engineer | ✅ done |
| GOO-15 | Fix soft-deleted user login | High | Junior Backend Engineer | ⏳ todo (blocked by GOO-7) | | GOO-15 | Fix soft-deleted user login | High | Junior Backend Engineer | ✅ done |
| GOO-16 | Fix obsolete districts | High | Middle Frontend Engineer | 🔄 in_progress | | GOO-16 | Fix obsolete districts | High | Middle Frontend Engineer | ✅ done |
| GOO-17 | ZNS template registration | High | CMO | 🚫 blocked | | GOO-17 | ZNS template registration | High | CEO | 🚫 blocked |
### Sprint 3 — MVP Feature Completeness ### Sprint 3 — MVP Feature Completeness
| Task | Tiêu đề | Ưu tiên | Owner | Trạng thái | | Task | Tiêu đề | Ưu tiên | Owner | Trạng thái |
|------|---------|---------|-------|------------| |------|---------|---------|-------|------------|
| GOO-18 | Ward-level search | High | Middle Backend Engineer | ⏳ todo | | GOO-18 | Ward-level search | High | Middle Backend Engineer | ✅ done |
| GOO-19 | Scam/abuse report flow | High | Senior Backend Engineer | ⏳ todo | | GOO-19 | Scam/abuse report flow | High | Senior Backend Engineer | ✅ done |
| GOO-20 | ROOM_RENTAL property type | High | Junior Backend Engineer #2 | 🔄 in_progress | | GOO-20 | ROOM_RENTAL property type | High | Junior Backend Engineer #2 | ✅ done |
| GOO-21 | Vietnam admin data (ĐVHCVN) | High | Database Architect | 🔄 in_progress | | GOO-21 | Vietnam admin data (ĐVHCVN) | High | Founding Engineer | ✅ done |
| GOO-22 | Subscription plan seeding | Medium | Junior Backend Engineer #2 | ⏳ todo | | GOO-22 | Subscription plan seeding | Medium | Junior Backend Engineer #2 | ✅ done |
| GOO-23 | Module boundary fixes | Medium | Backend TechLead | ⏳ todo | | GOO-23 | Module boundary fixes | Medium | Backend TechLead | ✅ done |
### Sprint 4 — Trust + Monetization ### Sprint 4 — Trust + Monetization
| Task | Tiêu đề | Owner | Trạng thái | | Task | Tiêu đề | Ưu tiên | Owner | Trạng thái |
|------|---------|-------|------------| |------|---------|---------|-------|------------|
| GOO-24 | Certificate verification | Senior Backend Engineer | ⏳ todo (blocked by GOO-12) | | GOO-24 | Certificate verification | High | Senior Backend Engineer | ✅ done |
| GOO-25 | Payment go-live checklist | DevOps Engineer | 🔄 in_progress | | GOO-25 | Payment go-live checklist | High | DevOps Engineer | ✅ done |
| GOO-26 | Revenue stats fix | Middle Backend Engineer | ⏳ todo | | GOO-26 | Revenue stats fix | High | Middle Backend Engineer | ✅ done |
| GOO-27 | Float→Decimal migration | Database Architect | 🔄 in_progress | | GOO-27 | Float→Decimal migration | High | Middle Backend Engineer | ✅ done |
| GOO-28 | Typesense+MinIO health | Infrastructure Engineer | 🔄 in_progress | | GOO-28 | Typesense+MinIO health | Medium | Infrastructure Engineer | ✅ done |
### Sprint 5 — UX + Performance ### Sprint 5 — UX + Performance
| Task | Tiêu đề | Owner | Trạng thái | | Task | Tiêu đề | Ưu tiên | Owner | Trạng thái |
|------|---------|-------|------------| |------|---------|---------|-------|------------|
| GOO-29 | Mobile swipe gallery | Middle Frontend Engineer | ⏳ todo | | GOO-29 | Mobile swipe gallery | Medium | Middle Frontend Engineer | ✅ done |
| GOO-30 | Listing expiry notification | Middle Backend Engineer | ⏳ todo | | GOO-30 | Listing expiry notification | Medium | Middle Backend Engineer | ✅ done |
| GOO-31 | AVM circuit breaker | Infrastructure Engineer | ⏳ todo | | GOO-31 | AVM circuit breaker | Medium | Infrastructure Engineer | ✅ done |
| GOO-34 | P2 batch fixes | Founding Engineer | 🔄 in_progress | | GOO-34 | P2 batch fixes | Medium | Founding Engineer | ✅ done |
| GOO-44 | Unit tests cho ListingExpiryCronService | Low | Junior Backend Engineer | 🔄 in_progress |
### Sprint 6 — Architecture + Docs ### Sprint 6 — Architecture + Docs
| Task | Tiêu đề | Owner | Trạng thái | | Task | Tiêu đề | Ưu tiên | Owner | Trạng thái |
|------|---------|-------|------------| |------|---------|---------|-------|------------|
| GOO-32 | Architecture hygiene batch | Backend TechLead | ⏳ todo | | GOO-32 | Architecture hygiene batch | Medium | CEO | ✅ done |
| GOO-33 | Documentation updates | Doc Bot | 🔄 in_progress | | GOO-33 | Documentation updates | Medium | Doc Bot | ✅ done |
| GOO-35 | Index ProjectDevelopment in Typesense | Medium | Backend TechLead | ✅ done |
| GOO-36 | AI sidecar OpenAPI contract generation | Medium | Backend TechLead | ✅ done |
### Tổng kết tiến độ ### Coordination & Ops Tasks
- **Done:** 1/32 (3%)
- **In progress:** 13/32 (41%) | Task | Tiêu đề | Ưu tiên | Owner | Trạng thái |
- **Todo:** 16/32 (50%) |------|---------|---------|-------|------------|
- **Blocked:** 2/32 (6%) | GOO-1 | Approve toàn bộ Agents | Medium | — | ✅ done |
| GOO-2 | Lead Orchestrator — Audit, Plan & Execute | Medium | CEO | 🔄 in_progress |
| GOO-37 | Kiểm tra đường dẫn workspace | Medium | — | 🚫 blocked |
| GOO-38 | Kiểm tra files đã cập nhật | Medium | CEO | ✅ done |
| GOO-39 | CTO — Điều phối kỹ thuật unblock GOO-21/24 | High | CTO | ✅ done |
| GOO-40 | Backend TechLead — Unblock GOO-21 + Review GOO-24 | High | Backend TechLead | ✅ done |
| GOO-41 | Frontend TechLead — Hỗ trợ unblock GOO-21 | High | Frontend TechLead | ✅ done |
| GOO-42 | QA — Verify GOO-24 + GOO-30 | High | QA Engineer | ✅ done |
| GOO-43 | Unblock GOO-21 AC3 — Fix filter-bar TS error | High | Frontend TechLead | ✅ done |
| GOO-45 | Cập nhật Agents Config → GLM 5.1 | Medium | CEO | ✅ done |
| GOO-46 | Cập nhật Agents Config & Permission | Medium | CEO | ✅ done |
### Audit Phase — Unit Tests (A1A5)
| Task | Tiêu đề | Ưu tiên | Owner | Trạng thái |
|------|---------|---------|-------|------------|
| GOO-47 | [A1] Unit tests cho industrial/ (50 files) | Critical | Backend TechLead | 🔄 in_progress |
| GOO-48 | [A2] Unit tests cho projects/ (25 files) | High | Senior Backend Engineer | 🔄 in_progress |
| GOO-51 | [A3] Unit tests cho documents/ (20 files) | High | Middle Backend Engineer | 🔄 in_progress |
| GOO-58 | [A4] Unit tests cho favorites/ (14 files) | Medium | Junior Backend Engineer | ⏳ todo |
| GOO-59 | [A5] Unit tests cho reference/ (4 files) | Low | Junior Backend Engineer #2 | 🔄 in_progress |
### Audit Phase — Error Handling Migration (A6A8)
| Task | Tiêu đề | Ưu tiên | Owner | Trạng thái |
|------|---------|---------|-------|------------|
| GOO-49 | [A6] Migrate Error → DomainException trong notifications/ (21) | High | Backend TechLead | ⏳ todo |
| GOO-52 | [A7] Migrate Error → DomainException trong auth/ (9) | Medium | Senior Backend Engineer | ⏳ todo |
| GOO-53 | [A8] Migrate Error → DomainException trong shared/analytics/payments/ (14) | Medium | Middle Backend Engineer | ⏳ todo |
### Audit Phase — Frontend & Misc (A9A11)
| Task | Tiêu đề | Ưu tiên | Owner | Trạng thái |
|------|---------|---------|-------|------------|
| GOO-54 | [A9] Frontend component tests — 108 components | High | Frontend TechLead | 🔄 in_progress |
| GOO-60 | [A10] Frontend middleware + i18n tests | Medium | Senior Frontend Engineer | 🔄 in_progress |
| GOO-61 | [A11] Sync PROJECT_TRACKER.md với Paperclip | Medium | Doc Bot | 🔄 in_progress |
### Audit Phase — Infrastructure & Quality (B1B8)
| Task | Tiêu đề | Ưu tiên | Owner | Trạng thái |
|------|---------|---------|-------|------------|
| GOO-62 | [B1] E2E test suite expansion | High | QA Engineer | 🔄 in_progress |
| GOO-55 | [B2] API rate limiting fine-tuning | Medium | Security Engineer | 🔄 in_progress |
| GOO-63 | [B3] Frontend error boundary components | Medium | Frontend TechLead | ⏳ todo |
| GOO-56 | [B4] API request validation audit | Medium | API Architect | 🔄 in_progress |
| GOO-57 | [B5] Database query optimization audit | Medium | Database Architect | 🔄 in_progress |
| GOO-50 | [B6] CI pipeline: test coverage thresholds | High | DevOps Engineer | 🔄 in_progress |
| GOO-64 | [B7] Sentry error tracking verification | Medium | SRE Engineer | 🔄 in_progress |
| GOO-65 | [B8] Frontend accessibility audit (WCAG 2.1) | Medium | UX/UI Designer | 🔄 in_progress |
### Tổng kết tiến độ GOO-2
| Nhóm | Tổng | Done | In Progress | Todo | Blocked |
|------|------|------|-------------|------|---------|
| Sprint 16 (GOO-3 → GOO-36) | 32 | 30 | 1 | 0 | 1 |
| Coordination & Ops | 11 | 9 | 1 | 0 | 1 |
| Audit A (A1A11) | 11 | 0 | 7 | 4 | 0 |
| Audit B (B1B8) | 8 | 0 | 6 | 1 | 0 |
| **Tổng GOO issues** | **65** | **42** | **16** | **5** | **2** |
**Tiến độ tổng: 42/65 (65%) done — 16 in_progress — 5 todo — 2 blocked**
--- ---
@@ -455,7 +519,7 @@ Task cha: [TEC-1915](/TEC/issues/TEC-1915) — Goodgo Platform AI
| [TEC-1922](/TEC/issues/TEC-1922) | Tạo checklist sẵn sàng sản xuất chính thức và ký duyệt | Trung bình | cần làm | Kỹ Sư SRE | | [TEC-1922](/TEC/issues/TEC-1922) | Tạo checklist sẵn sàng sản xuất chính thức và ký duyệt | Trung bình | cần làm | Kỹ Sư SRE |
| [TEC-1923](/TEC/issues/TEC-1923) | Cập nhật PROJECT_TRACKER.md với kết quả kiểm toán Wave 13 | Trung bình | hoàn thành | Kỹ Sư Viết Tài Liệu | | [TEC-1923](/TEC/issues/TEC-1923) | Cập nhật PROJECT_TRACKER.md với kết quả kiểm toán Wave 13 | Trung bình | hoàn thành | Kỹ Sư Viết Tài Liệu |
### Wave 14 — Kiểm Toán CEO (2026-04-12) — Build Xanh ### Wave 14 — Kiểm Toán CEO (2026-04-12) — Build Xanh
Task cha: [TEC-1970](/TEC/issues/TEC-1970) — Goodgo Platform AI Task cha: [TEC-1970](/TEC/issues/TEC-1970) — Goodgo Platform AI
@@ -517,10 +581,11 @@ Tất cả đều không chặn sẵn sàng sản xuất.
| Giai Đoạn 5 | 4 | 4 | 0 | 0 | 0 | 0 | | Giai Đoạn 5 | 4 | 4 | 0 | 0 | 0 | 0 |
| Giai Đoạn 6 | 16 | 16 | 0 | 0 | 0 | 0 | | Giai Đoạn 6 | 16 | 16 | 0 | 0 | 0 | 0 |
| Giai Đoạn 7 | 108 | 97 | 1 | 0 | 5 | 5 | | Giai Đoạn 7 | 108 | 97 | 1 | 0 | 5 | 5 |
| **Tổng** | **159** | **148** | **1** | **0** | **5** | **5** | | **Tổng (Legacy)** | **159** | **148** | **1** | **0** | **5** | **5** |
| **GOO-2 (Active)** | **65** | **42** | **16** | **2** | **5** | **0** |
*Lưu ý: 5 vấn đề đã huỷ (TEC-1547, TEC-1876, TEC-1877 + 2 vấn đề khác). Số liệu được lấy từ bộ theo dõi vấn đề Paperclip vào ngày 2026-04-12.* *Lưu ý: 5 vấn đề legacy đã huỷ (TEC-1547, TEC-1876, TEC-1877 + 2 vấn đề khác). Số liệu GOO-2 được đồng bộ từ Paperclip API vào ngày 2026-04-23.*
--- ---
*Cập nhật lần cuối bởi Kỹ Sư Viết Tài Liệu — 2026-04-12 (Kiểm toán CEO Wave 14: build xanh, dọn dẹp backlog bởi Kỹ Sư QA)* *Cập nhật lần cuối bởi Doc Bot — 2026-04-23 (GOO-61: Sync PROJECT_TRACKER.md với Paperclip issue statuses)*

View File

@@ -26,6 +26,7 @@
"build": "turbo run build", "build": "turbo run build",
"lint": "eslint .", "lint": "eslint .",
"test": "turbo run test", "test": "turbo run test",
"test:coverage": "turbo run test:coverage",
"typecheck": "turbo run typecheck", "typecheck": "turbo run typecheck",
"format": "prettier --write .", "format": "prettier --write .",
"format:check": "prettier --check .", "format:check": "prettier --check .",

339
pnpm-lock.yaml generated
View File

@@ -84,6 +84,9 @@ importers:
'@aws-sdk/s3-request-presigner': '@aws-sdk/s3-request-presigner':
specifier: ^3.1026.0 specifier: ^3.1026.0
version: 3.1026.0 version: 3.1026.0
'@goodgo/ai-contract':
specifier: workspace:*
version: link:../../libs/ai-contract
'@goodgo/mcp-servers': '@goodgo/mcp-servers':
specifier: workspace:* specifier: workspace:*
version: link:../../libs/mcp-servers version: link:../../libs/mcp-servers
@@ -168,9 +171,6 @@ importers:
class-validator: class-validator:
specifier: ^0.15.1 specifier: ^0.15.1
version: 0.15.1 version: 0.15.1
cockatiel:
specifier: ^3.2.1
version: 3.2.1
cookie-parser: cookie-parser:
specifier: ^1.4.7 specifier: ^1.4.7
version: 1.4.7 version: 1.4.7
@@ -286,6 +286,9 @@ importers:
'@types/supertest': '@types/supertest':
specifier: ^7.2.0 specifier: ^7.2.0
version: 7.2.0 version: 7.2.0
'@vitest/coverage-v8':
specifier: ^4.1.3
version: 4.1.3(vitest@4.1.3)
prisma: prisma:
specifier: ^7.7.0 specifier: ^7.7.0
version: 7.7.0(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.2) version: 7.7.0(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.2)
@@ -297,7 +300,7 @@ importers:
version: 6.0.2 version: 6.0.2
vitest: vitest:
specifier: ^4.1.3 specifier: ^4.1.3
version: 4.1.3(@opentelemetry/api@1.9.1)(@types/node@25.5.2)(jsdom@29.0.2(@noble/hashes@2.0.1))(msw@2.13.2(@types/node@25.5.2)(typescript@6.0.2))(vite@7.3.2(@types/node@25.5.2)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) version: 4.1.3(@opentelemetry/api@1.9.1)(@types/node@25.5.2)(@vitest/coverage-v8@4.1.3)(jsdom@29.0.2(@noble/hashes@2.0.1))(msw@2.13.2(@types/node@25.5.2)(typescript@6.0.2))(vite@7.3.2(@types/node@25.5.2)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
apps/web: apps/web:
dependencies: dependencies:
@@ -395,6 +398,9 @@ importers:
'@vitejs/plugin-react': '@vitejs/plugin-react':
specifier: ^4.7.0 specifier: ^4.7.0
version: 4.7.0(vite@7.3.2(@types/node@25.5.2)(jiti@1.21.7)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) version: 4.7.0(vite@7.3.2(@types/node@25.5.2)(jiti@1.21.7)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
'@vitest/coverage-v8':
specifier: ^4.1.3
version: 4.1.3(vitest@4.1.3)
autoprefixer: autoprefixer:
specifier: ^10.4.0 specifier: ^10.4.0
version: 10.4.27(postcss@8.5.8) version: 10.4.27(postcss@8.5.8)
@@ -418,7 +424,16 @@ importers:
version: 6.0.2 version: 6.0.2
vitest: vitest:
specifier: ^4.1.3 specifier: ^4.1.3
version: 4.1.3(@opentelemetry/api@1.9.1)(@types/node@25.5.2)(jsdom@29.0.2(@noble/hashes@2.0.1))(msw@2.13.2(@types/node@25.5.2)(typescript@6.0.2))(vite@7.3.2(@types/node@25.5.2)(jiti@1.21.7)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) version: 4.1.3(@opentelemetry/api@1.9.1)(@types/node@25.5.2)(@vitest/coverage-v8@4.1.3)(jsdom@29.0.2(@noble/hashes@2.0.1))(msw@2.13.2(@types/node@25.5.2)(typescript@6.0.2))(vite@7.3.2(@types/node@25.5.2)(jiti@1.21.7)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
libs/ai-contract:
devDependencies:
openapi-typescript:
specifier: ^7.4.4
version: 7.13.0(typescript@6.0.2)
typescript:
specifier: ^6.0.2
version: 6.0.2
libs/mcp-servers: libs/mcp-servers:
dependencies: dependencies:
@@ -446,7 +461,7 @@ importers:
version: 6.0.2 version: 6.0.2
vitest: vitest:
specifier: ^4.1.3 specifier: ^4.1.3
version: 4.1.3(@opentelemetry/api@1.9.1)(@types/node@25.5.2)(jsdom@29.0.2(@noble/hashes@2.0.1))(msw@2.13.2(@types/node@25.5.2)(typescript@6.0.2))(vite@7.3.2(@types/node@25.5.2)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) version: 4.1.3(@opentelemetry/api@1.9.1)(@types/node@25.5.2)(@vitest/coverage-v8@4.1.3)(jsdom@29.0.2(@noble/hashes@2.0.1))(msw@2.13.2(@types/node@25.5.2)(typescript@6.0.2))(vite@7.3.2(@types/node@25.5.2)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
packages: packages:
@@ -749,6 +764,10 @@ packages:
resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
engines: {node: '>=6.9.0'} engines: {node: '>=6.9.0'}
'@bcoe/v8-coverage@1.0.2':
resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==}
engines: {node: '>=18'}
'@borewit/text-codec@0.2.2': '@borewit/text-codec@0.2.2':
resolution: {integrity: sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==} resolution: {integrity: sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==}
@@ -2343,6 +2362,16 @@ packages:
'@types/react': '@types/react':
optional: true optional: true
'@redocly/ajv@8.11.2':
resolution: {integrity: sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg==}
'@redocly/config@0.22.0':
resolution: {integrity: sha512-gAy93Ddo01Z3bHuVdPWfCwzgfaYgMdaZPcfL7JZ7hWJoK9V0lXDbigTWkhiPFAaLWzbOJ+kbUQG1+XwIm0KRGQ==}
'@redocly/openapi-core@1.34.12':
resolution: {integrity: sha512-b32XWsz6enN6K4bx8xWsqUaXTJR/DnYT3lL1CzDYzIYKw243NNlz6fexmr71q/U4HrEcMoJGBvwAfcxOb8ymQw==}
engines: {node: '>=18.17.0', npm: '>=9.5.0'}
'@reduxjs/toolkit@2.11.2': '@reduxjs/toolkit@2.11.2':
resolution: {integrity: sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==} resolution: {integrity: sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==}
peerDependencies: peerDependencies:
@@ -3477,6 +3506,15 @@ packages:
peerDependencies: peerDependencies:
vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0
'@vitest/coverage-v8@4.1.3':
resolution: {integrity: sha512-/MBdrkA8t6hbdCWFKs09dPik774xvs4Z6L4bycdCxYNLHM8oZuRyosumQMG19LUlBsB6GeVpL1q4kFFazvyKGA==}
peerDependencies:
'@vitest/browser': 4.1.3
vitest: 4.1.3
peerDependenciesMeta:
'@vitest/browser':
optional: true
'@vitest/expect@4.1.3': '@vitest/expect@4.1.3':
resolution: {integrity: sha512-CW8Q9KMtXDGHj0vCsqui0M5KqRsu0zm0GNDW7Gd3U7nZ2RFpPKSCpeCXoT+/+5zr1TNlsoQRDEz+LzZUyq6gnQ==} resolution: {integrity: sha512-CW8Q9KMtXDGHj0vCsqui0M5KqRsu0zm0GNDW7Gd3U7nZ2RFpPKSCpeCXoT+/+5zr1TNlsoQRDEz+LzZUyq6gnQ==}
@@ -3723,6 +3761,9 @@ packages:
resolution: {integrity: sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==} resolution: {integrity: sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==}
engines: {node: '>=4'} engines: {node: '>=4'}
ast-v8-to-istanbul@1.0.0:
resolution: {integrity: sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg==}
async-retry@1.3.3: async-retry@1.3.3:
resolution: {integrity: sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==} resolution: {integrity: sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==}
@@ -3864,6 +3905,9 @@ packages:
brace-expansion@1.1.13: brace-expansion@1.1.13:
resolution: {integrity: sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==} resolution: {integrity: sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==}
brace-expansion@2.1.0:
resolution: {integrity: sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==}
brace-expansion@5.0.5: brace-expansion@5.0.5:
resolution: {integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==} resolution: {integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==}
engines: {node: 18 || 20 || >=22} engines: {node: 18 || 20 || >=22}
@@ -3947,6 +3991,9 @@ packages:
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
engines: {node: '>=10'} engines: {node: '>=10'}
change-case@5.4.4:
resolution: {integrity: sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==}
chardet@2.1.1: chardet@2.1.1:
resolution: {integrity: sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==} resolution: {integrity: sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==}
@@ -4046,10 +4093,6 @@ packages:
resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==} resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
cockatiel@3.2.1:
resolution: {integrity: sha512-gfrHV6ZPkquExvMh9IOkKsBzNDk6sDuZ6DdBGUBkvFnTCqCxzpuq48RySgP0AnaqQkw2zynOFj9yly6T1Q2G5Q==}
engines: {node: '>=16'}
color-convert@2.0.1: color-convert@2.0.1:
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
engines: {node: '>=7.0.0'} engines: {node: '>=7.0.0'}
@@ -4057,6 +4100,9 @@ packages:
color-name@1.1.4: color-name@1.1.4:
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
colorette@1.4.0:
resolution: {integrity: sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==}
colorette@2.0.20: colorette@2.0.20:
resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==}
@@ -5023,6 +5069,9 @@ packages:
html-entities@2.6.0: html-entities@2.6.0:
resolution: {integrity: sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==} resolution: {integrity: sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==}
html-escaper@2.0.2:
resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==}
html2canvas@1.4.1: html2canvas@1.4.1:
resolution: {integrity: sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==} resolution: {integrity: sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==}
engines: {node: '>=8.0.0'} engines: {node: '>=8.0.0'}
@@ -5104,6 +5153,10 @@ packages:
resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==}
engines: {node: '>=8'} engines: {node: '>=8'}
index-to-position@1.2.0:
resolution: {integrity: sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==}
engines: {node: '>=18'}
inherits@2.0.4: inherits@2.0.4:
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
@@ -5213,6 +5266,18 @@ packages:
isexe@2.0.0: isexe@2.0.0:
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
istanbul-lib-coverage@3.2.2:
resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==}
engines: {node: '>=8'}
istanbul-lib-report@3.0.1:
resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==}
engines: {node: '>=10'}
istanbul-reports@3.2.0:
resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==}
engines: {node: '>=8'}
iterare@1.2.1: iterare@1.2.1:
resolution: {integrity: sha512-RKYVTCjAnRthyJes037NX/IiqeidgN1xc3j1RjFfECFp28A1GVwK9nA+i0rJPaHqSZwygLzRnFlzUuHFoWWy+Q==} resolution: {integrity: sha512-RKYVTCjAnRthyJes037NX/IiqeidgN1xc3j1RjFfECFp28A1GVwK9nA+i0rJPaHqSZwygLzRnFlzUuHFoWWy+Q==}
engines: {node: '>=6'} engines: {node: '>=6'}
@@ -5239,6 +5304,13 @@ packages:
resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==}
engines: {node: '>=10'} engines: {node: '>=10'}
js-levenshtein@1.1.6:
resolution: {integrity: sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==}
engines: {node: '>=0.10.0'}
js-tokens@10.0.0:
resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==}
js-tokens@4.0.0: js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
@@ -5464,6 +5536,13 @@ packages:
magic-string@0.30.21: magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
magicast@0.5.2:
resolution: {integrity: sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==}
make-dir@4.0.0:
resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==}
engines: {node: '>=10'}
mapbox-gl@3.21.0: mapbox-gl@3.21.0:
resolution: {integrity: sha512-0mv/LHDoW6QmxLEoNqCPuby428WTekfs38TtNXd/cxDOLdY6kFd/ztaSkcBeqNksbYSz2lXnPfBm8nN5+hxA0w==} resolution: {integrity: sha512-0mv/LHDoW6QmxLEoNqCPuby428WTekfs38TtNXd/cxDOLdY6kFd/ztaSkcBeqNksbYSz2lXnPfBm8nN5+hxA0w==}
@@ -5553,6 +5632,10 @@ packages:
minimatch@3.1.5: minimatch@3.1.5:
resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==}
minimatch@5.1.9:
resolution: {integrity: sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==}
engines: {node: '>=10'}
minimist@1.2.8: minimist@1.2.8:
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
@@ -5785,6 +5868,12 @@ packages:
resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==}
engines: {node: '>=18'} engines: {node: '>=18'}
openapi-typescript@7.13.0:
resolution: {integrity: sha512-EFP392gcqXS7ntPvbhBzbF8TyBA+baIYEm791Hy5YkjDYKTnk/Tn5OQeKm5BIZvJihpp8Zzr4hzx0Irde1LNGQ==}
hasBin: true
peerDependencies:
typescript: ^5.x
optionator@0.9.4: optionator@0.9.4:
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
engines: {node: '>= 0.8.0'} engines: {node: '>= 0.8.0'}
@@ -5838,6 +5927,10 @@ packages:
resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==}
engines: {node: '>=8'} engines: {node: '>=8'}
parse-json@8.3.0:
resolution: {integrity: sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==}
engines: {node: '>=18'}
parse-srcset@1.0.2: parse-srcset@1.0.2:
resolution: {integrity: sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==} resolution: {integrity: sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==}
@@ -6703,6 +6796,10 @@ packages:
resolution: {integrity: sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==} resolution: {integrity: sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==}
engines: {node: '>=14.18.0'} engines: {node: '>=14.18.0'}
supports-color@10.2.2:
resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==}
engines: {node: '>=18'}
supports-color@7.2.0: supports-color@7.2.0:
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
engines: {node: '>=8'} engines: {node: '>=8'}
@@ -6909,6 +7006,10 @@ packages:
resolution: {integrity: sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg==} resolution: {integrity: sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg==}
engines: {node: '>=8'} engines: {node: '>=8'}
type-fest@4.41.0:
resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==}
engines: {node: '>=16'}
type-fest@5.5.0: type-fest@5.5.0:
resolution: {integrity: sha512-PlBfpQwiUvGViBNX84Yxwjsdhd1TUlXr6zjX7eoirtCPIr08NAmxwa+fcYBTeRQxHo9YC9wwF3m9i700sHma8g==} resolution: {integrity: sha512-PlBfpQwiUvGViBNX84Yxwjsdhd1TUlXr6zjX7eoirtCPIr08NAmxwa+fcYBTeRQxHo9YC9wwF3m9i700sHma8g==}
engines: {node: '>=20'} engines: {node: '>=20'}
@@ -6997,6 +7098,9 @@ packages:
peerDependencies: peerDependencies:
browserslist: '>= 4.21.0' browserslist: '>= 4.21.0'
uri-js-replace@1.0.1:
resolution: {integrity: sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g==}
uri-js@4.4.1: uri-js@4.4.1:
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
@@ -7293,6 +7397,9 @@ packages:
yallist@4.0.0: yallist@4.0.0:
resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==}
yaml-ast-parser@0.0.43:
resolution: {integrity: sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==}
yaml@2.8.3: yaml@2.8.3:
resolution: {integrity: sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==} resolution: {integrity: sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==}
engines: {node: '>= 14.6'} engines: {node: '>= 14.6'}
@@ -7901,7 +8008,7 @@ snapshots:
'@babel/types': 7.29.0 '@babel/types': 7.29.0
'@jridgewell/remapping': 2.3.5 '@jridgewell/remapping': 2.3.5
convert-source-map: 2.0.0 convert-source-map: 2.0.0
debug: 4.4.3 debug: 4.4.3(supports-color@10.2.2)
gensync: 1.0.0-beta.2 gensync: 1.0.0-beta.2
json5: 2.2.3 json5: 2.2.3
semver: 6.3.1 semver: 6.3.1
@@ -7985,7 +8092,7 @@ snapshots:
'@babel/parser': 7.29.2 '@babel/parser': 7.29.2
'@babel/template': 7.28.6 '@babel/template': 7.28.6
'@babel/types': 7.29.0 '@babel/types': 7.29.0
debug: 4.4.3 debug: 4.4.3(supports-color@10.2.2)
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@@ -7994,6 +8101,8 @@ snapshots:
'@babel/helper-string-parser': 7.27.1 '@babel/helper-string-parser': 7.27.1
'@babel/helper-validator-identifier': 7.28.5 '@babel/helper-validator-identifier': 7.28.5
'@bcoe/v8-coverage@1.0.2': {}
'@borewit/text-codec@0.2.2': {} '@borewit/text-codec@0.2.2': {}
'@bramus/specificity@2.4.2': '@bramus/specificity@2.4.2':
@@ -8141,7 +8250,7 @@ snapshots:
'@eslint/config-array@0.21.2': '@eslint/config-array@0.21.2':
dependencies: dependencies:
'@eslint/object-schema': 2.1.7 '@eslint/object-schema': 2.1.7
debug: 4.4.3 debug: 4.4.3(supports-color@10.2.2)
minimatch: 3.1.5 minimatch: 3.1.5
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@@ -8157,7 +8266,7 @@ snapshots:
'@eslint/eslintrc@3.3.5': '@eslint/eslintrc@3.3.5':
dependencies: dependencies:
ajv: 6.14.0 ajv: 6.14.0
debug: 4.4.3 debug: 4.4.3(supports-color@10.2.2)
espree: 10.4.0 espree: 10.4.0
globals: 14.0.0 globals: 14.0.0
ignore: 5.3.2 ignore: 5.3.2
@@ -9490,7 +9599,7 @@ snapshots:
'@puppeteer/browsers@2.13.0': '@puppeteer/browsers@2.13.0':
dependencies: dependencies:
debug: 4.4.3 debug: 4.4.3(supports-color@10.2.2)
extract-zip: 2.0.1 extract-zip: 2.0.1
progress: 2.0.3 progress: 2.0.3
proxy-agent: 6.5.0 proxy-agent: 6.5.0
@@ -9559,6 +9668,29 @@ snapshots:
optionalDependencies: optionalDependencies:
'@types/react': 18.3.28 '@types/react': 18.3.28
'@redocly/ajv@8.11.2':
dependencies:
fast-deep-equal: 3.1.3
json-schema-traverse: 1.0.0
require-from-string: 2.0.2
uri-js-replace: 1.0.1
'@redocly/config@0.22.0': {}
'@redocly/openapi-core@1.34.12(supports-color@10.2.2)':
dependencies:
'@redocly/ajv': 8.11.2
'@redocly/config': 0.22.0
colorette: 1.4.0
https-proxy-agent: 7.0.6(supports-color@10.2.2)
js-levenshtein: 1.1.6
js-yaml: 4.1.1
minimatch: 5.1.9
pluralize: 8.0.0
yaml-ast-parser: 0.0.43
transitivePeerDependencies:
- supports-color
'@reduxjs/toolkit@2.11.2(react-redux@9.2.0(@types/react@18.3.28)(react@18.3.1)(redux@5.0.1))(react@18.3.1)': '@reduxjs/toolkit@2.11.2(react-redux@9.2.0(@types/react@18.3.28)(react@18.3.1)(redux@5.0.1))(react@18.3.1)':
dependencies: dependencies:
'@standard-schema/spec': 1.1.0 '@standard-schema/spec': 1.1.0
@@ -10364,7 +10496,7 @@ snapshots:
'@tokenizer/inflate@0.4.1': '@tokenizer/inflate@0.4.1':
dependencies: dependencies:
debug: 4.4.3 debug: 4.4.3(supports-color@10.2.2)
token-types: 6.1.2 token-types: 6.1.2
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@@ -10706,7 +10838,7 @@ snapshots:
'@typescript-eslint/types': 8.58.0 '@typescript-eslint/types': 8.58.0
'@typescript-eslint/typescript-estree': 8.58.0(typescript@6.0.2) '@typescript-eslint/typescript-estree': 8.58.0(typescript@6.0.2)
'@typescript-eslint/visitor-keys': 8.58.0 '@typescript-eslint/visitor-keys': 8.58.0
debug: 4.4.3 debug: 4.4.3(supports-color@10.2.2)
eslint: 9.39.4(jiti@2.6.1) eslint: 9.39.4(jiti@2.6.1)
typescript: 6.0.2 typescript: 6.0.2
transitivePeerDependencies: transitivePeerDependencies:
@@ -10716,7 +10848,7 @@ snapshots:
dependencies: dependencies:
'@typescript-eslint/tsconfig-utils': 8.58.0(typescript@6.0.2) '@typescript-eslint/tsconfig-utils': 8.58.0(typescript@6.0.2)
'@typescript-eslint/types': 8.58.0 '@typescript-eslint/types': 8.58.0
debug: 4.4.3 debug: 4.4.3(supports-color@10.2.2)
typescript: 6.0.2 typescript: 6.0.2
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@@ -10735,7 +10867,7 @@ snapshots:
'@typescript-eslint/types': 8.58.0 '@typescript-eslint/types': 8.58.0
'@typescript-eslint/typescript-estree': 8.58.0(typescript@6.0.2) '@typescript-eslint/typescript-estree': 8.58.0(typescript@6.0.2)
'@typescript-eslint/utils': 8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.2) '@typescript-eslint/utils': 8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.2)
debug: 4.4.3 debug: 4.4.3(supports-color@10.2.2)
eslint: 9.39.4(jiti@2.6.1) eslint: 9.39.4(jiti@2.6.1)
ts-api-utils: 2.5.0(typescript@6.0.2) ts-api-utils: 2.5.0(typescript@6.0.2)
typescript: 6.0.2 typescript: 6.0.2
@@ -10750,7 +10882,7 @@ snapshots:
'@typescript-eslint/tsconfig-utils': 8.58.0(typescript@6.0.2) '@typescript-eslint/tsconfig-utils': 8.58.0(typescript@6.0.2)
'@typescript-eslint/types': 8.58.0 '@typescript-eslint/types': 8.58.0
'@typescript-eslint/visitor-keys': 8.58.0 '@typescript-eslint/visitor-keys': 8.58.0
debug: 4.4.3 debug: 4.4.3(supports-color@10.2.2)
minimatch: 10.2.5 minimatch: 10.2.5
semver: 7.7.4 semver: 7.7.4
tinyglobby: 0.2.16 tinyglobby: 0.2.16
@@ -10846,6 +10978,20 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@vitest/coverage-v8@4.1.3(vitest@4.1.3)':
dependencies:
'@bcoe/v8-coverage': 1.0.2
'@vitest/utils': 4.1.3
ast-v8-to-istanbul: 1.0.0
istanbul-lib-coverage: 3.2.2
istanbul-lib-report: 3.0.1
istanbul-reports: 3.2.0
magicast: 0.5.2
obug: 2.1.1
std-env: 4.0.0
tinyrainbow: 3.1.0
vitest: 4.1.3(@opentelemetry/api@1.9.1)(@types/node@25.5.2)(@vitest/coverage-v8@4.1.3)(jsdom@29.0.2(@noble/hashes@2.0.1))(msw@2.13.2(@types/node@25.5.2)(typescript@6.0.2))(vite@7.3.2(@types/node@25.5.2)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
'@vitest/expect@4.1.3': '@vitest/expect@4.1.3':
dependencies: dependencies:
'@standard-schema/spec': 1.1.0 '@standard-schema/spec': 1.1.0
@@ -11023,7 +11169,7 @@ snapshots:
agent-base@6.0.2: agent-base@6.0.2:
dependencies: dependencies:
debug: 4.4.3 debug: 4.4.3(supports-color@10.2.2)
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@@ -11116,6 +11262,12 @@ snapshots:
dependencies: dependencies:
tslib: 2.8.1 tslib: 2.8.1
ast-v8-to-istanbul@1.0.0:
dependencies:
'@jridgewell/trace-mapping': 0.3.31
estree-walker: 3.0.3
js-tokens: 10.0.0
async-retry@1.3.3: async-retry@1.3.3:
dependencies: dependencies:
retry: 0.13.1 retry: 0.13.1
@@ -11221,7 +11373,7 @@ snapshots:
dependencies: dependencies:
bytes: 3.1.2 bytes: 3.1.2
content-type: 1.0.5 content-type: 1.0.5
debug: 4.4.3 debug: 4.4.3(supports-color@10.2.2)
http-errors: 2.0.1 http-errors: 2.0.1
iconv-lite: 0.7.2 iconv-lite: 0.7.2
on-finished: 2.4.1 on-finished: 2.4.1
@@ -11249,6 +11401,10 @@ snapshots:
balanced-match: 1.0.2 balanced-match: 1.0.2
concat-map: 0.0.1 concat-map: 0.0.1
brace-expansion@2.1.0:
dependencies:
balanced-match: 1.0.2
brace-expansion@5.0.5: brace-expansion@5.0.5:
dependencies: dependencies:
balanced-match: 4.0.4 balanced-match: 4.0.4
@@ -11348,6 +11504,8 @@ snapshots:
ansi-styles: 4.3.0 ansi-styles: 4.3.0
supports-color: 7.2.0 supports-color: 7.2.0
change-case@5.4.4: {}
chardet@2.1.1: {} chardet@2.1.1: {}
chart.js@4.5.1: chart.js@4.5.1:
@@ -11447,14 +11605,14 @@ snapshots:
cluster-key-slot@1.1.2: {} cluster-key-slot@1.1.2: {}
cockatiel@3.2.1: {}
color-convert@2.0.1: color-convert@2.0.1:
dependencies: dependencies:
color-name: 1.1.4 color-name: 1.1.4
color-name@1.1.4: {} color-name@1.1.4: {}
colorette@1.4.0: {}
colorette@2.0.20: {} colorette@2.0.20: {}
combined-stream@1.0.8: combined-stream@1.0.8:
@@ -11625,9 +11783,11 @@ snapshots:
dependencies: dependencies:
ms: 2.1.3 ms: 2.1.3
debug@4.4.3: debug@4.4.3(supports-color@10.2.2):
dependencies: dependencies:
ms: 2.1.3 ms: 2.1.3
optionalDependencies:
supports-color: 10.2.2
decamelize@1.2.0: {} decamelize@1.2.0: {}
@@ -11778,7 +11938,7 @@ snapshots:
engine.io-client@6.6.4: engine.io-client@6.6.4:
dependencies: dependencies:
'@socket.io/component-emitter': 3.1.2 '@socket.io/component-emitter': 3.1.2
debug: 4.4.3 debug: 4.4.3(supports-color@10.2.2)
engine.io-parser: 5.2.3 engine.io-parser: 5.2.3
ws: 8.18.3 ws: 8.18.3
xmlhttprequest-ssl: 2.1.2 xmlhttprequest-ssl: 2.1.2
@@ -11798,7 +11958,7 @@ snapshots:
base64id: 2.0.0 base64id: 2.0.0
cookie: 0.7.2 cookie: 0.7.2
cors: 2.8.6 cors: 2.8.6
debug: 4.4.3 debug: 4.4.3(supports-color@10.2.2)
engine.io-parser: 5.2.3 engine.io-parser: 5.2.3
ws: 8.18.3 ws: 8.18.3
transitivePeerDependencies: transitivePeerDependencies:
@@ -11904,7 +12064,7 @@ snapshots:
eslint-import-resolver-typescript@4.4.4(eslint-plugin-import-x@4.16.2(@typescript-eslint/utils@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.2))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)): eslint-import-resolver-typescript@4.4.4(eslint-plugin-import-x@4.16.2(@typescript-eslint/utils@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.2))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)):
dependencies: dependencies:
debug: 4.4.3 debug: 4.4.3(supports-color@10.2.2)
eslint: 9.39.4(jiti@2.6.1) eslint: 9.39.4(jiti@2.6.1)
eslint-import-context: 0.1.9(unrs-resolver@1.11.1) eslint-import-context: 0.1.9(unrs-resolver@1.11.1)
get-tsconfig: 4.13.7 get-tsconfig: 4.13.7
@@ -11922,7 +12082,7 @@ snapshots:
'@package-json/types': 0.0.12 '@package-json/types': 0.0.12
'@typescript-eslint/types': 8.58.0 '@typescript-eslint/types': 8.58.0
comment-parser: 1.4.6 comment-parser: 1.4.6
debug: 4.4.3 debug: 4.4.3(supports-color@10.2.2)
eslint: 9.39.4(jiti@2.6.1) eslint: 9.39.4(jiti@2.6.1)
eslint-import-context: 0.1.9(unrs-resolver@1.11.1) eslint-import-context: 0.1.9(unrs-resolver@1.11.1)
is-glob: 4.0.3 is-glob: 4.0.3
@@ -11968,7 +12128,7 @@ snapshots:
ajv: 6.14.0 ajv: 6.14.0
chalk: 4.1.2 chalk: 4.1.2
cross-spawn: 7.0.6 cross-spawn: 7.0.6
debug: 4.4.3 debug: 4.4.3(supports-color@10.2.2)
escape-string-regexp: 4.0.0 escape-string-regexp: 4.0.0
eslint-scope: 8.4.0 eslint-scope: 8.4.0
eslint-visitor-keys: 4.2.1 eslint-visitor-keys: 4.2.1
@@ -12058,7 +12218,7 @@ snapshots:
content-type: 1.0.5 content-type: 1.0.5
cookie: 0.7.2 cookie: 0.7.2
cookie-signature: 1.2.2 cookie-signature: 1.2.2
debug: 4.4.3 debug: 4.4.3(supports-color@10.2.2)
depd: 2.0.0 depd: 2.0.0
encodeurl: 2.0.0 encodeurl: 2.0.0
escape-html: 1.0.3 escape-html: 1.0.3
@@ -12089,7 +12249,7 @@ snapshots:
extract-zip@2.0.1: extract-zip@2.0.1:
dependencies: dependencies:
debug: 4.4.3 debug: 4.4.3(supports-color@10.2.2)
get-stream: 5.2.0 get-stream: 5.2.0
yauzl: 2.10.0 yauzl: 2.10.0
optionalDependencies: optionalDependencies:
@@ -12190,7 +12350,7 @@ snapshots:
finalhandler@2.1.1: finalhandler@2.1.1:
dependencies: dependencies:
debug: 4.4.3 debug: 4.4.3(supports-color@10.2.2)
encodeurl: 2.0.0 encodeurl: 2.0.0
escape-html: 1.0.3 escape-html: 1.0.3
on-finished: 2.4.1 on-finished: 2.4.1
@@ -12317,7 +12477,7 @@ snapshots:
gaxios@6.7.1: gaxios@6.7.1:
dependencies: dependencies:
extend: 3.0.2 extend: 3.0.2
https-proxy-agent: 7.0.6 https-proxy-agent: 7.0.6(supports-color@10.2.2)
is-stream: 2.0.1 is-stream: 2.0.1
node-fetch: 2.7.0 node-fetch: 2.7.0
uuid: 9.0.1 uuid: 9.0.1
@@ -12329,7 +12489,7 @@ snapshots:
gaxios@7.1.4: gaxios@7.1.4:
dependencies: dependencies:
extend: 3.0.2 extend: 3.0.2
https-proxy-agent: 7.0.6 https-proxy-agent: 7.0.6(supports-color@10.2.2)
node-fetch: 3.3.2 node-fetch: 3.3.2
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@@ -12396,7 +12556,7 @@ snapshots:
dependencies: dependencies:
basic-ftp: 5.3.0 basic-ftp: 5.3.0
data-uri-to-buffer: 6.0.2 data-uri-to-buffer: 6.0.2
debug: 4.4.3 debug: 4.4.3(supports-color@10.2.2)
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@@ -12542,6 +12702,8 @@ snapshots:
html-entities@2.6.0: html-entities@2.6.0:
optional: true optional: true
html-escaper@2.0.2: {}
html2canvas@1.4.1: html2canvas@1.4.1:
dependencies: dependencies:
css-line-break: 2.1.0 css-line-break: 2.1.0
@@ -12568,7 +12730,7 @@ snapshots:
dependencies: dependencies:
'@tootallnate/once': 3.0.1 '@tootallnate/once': 3.0.1
agent-base: 6.0.2 agent-base: 6.0.2
debug: 4.4.3 debug: 4.4.3(supports-color@10.2.2)
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
optional: true optional: true
@@ -12576,7 +12738,7 @@ snapshots:
http-proxy-agent@7.0.2: http-proxy-agent@7.0.2:
dependencies: dependencies:
agent-base: 7.1.4 agent-base: 7.1.4
debug: 4.4.3 debug: 4.4.3(supports-color@10.2.2)
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@@ -12585,14 +12747,14 @@ snapshots:
https-proxy-agent@5.0.1: https-proxy-agent@5.0.1:
dependencies: dependencies:
agent-base: 6.0.2 agent-base: 6.0.2
debug: 4.4.3 debug: 4.4.3(supports-color@10.2.2)
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
https-proxy-agent@7.0.6: https-proxy-agent@7.0.6(supports-color@10.2.2):
dependencies: dependencies:
agent-base: 7.1.4 agent-base: 7.1.4
debug: 4.4.3 debug: 4.4.3(supports-color@10.2.2)
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@@ -12639,6 +12801,8 @@ snapshots:
indent-string@4.0.0: {} indent-string@4.0.0: {}
index-to-position@1.2.0: {}
inherits@2.0.4: {} inherits@2.0.4: {}
ini@4.1.1: {} ini@4.1.1: {}
@@ -12659,7 +12823,7 @@ snapshots:
dependencies: dependencies:
'@ioredis/commands': 1.5.1 '@ioredis/commands': 1.5.1
cluster-key-slot: 1.1.2 cluster-key-slot: 1.1.2
debug: 4.4.3 debug: 4.4.3(supports-color@10.2.2)
denque: 2.1.0 denque: 2.1.0
lodash.defaults: 4.2.0 lodash.defaults: 4.2.0
lodash.isarguments: 3.1.0 lodash.isarguments: 3.1.0
@@ -12731,6 +12895,19 @@ snapshots:
isexe@2.0.0: {} isexe@2.0.0: {}
istanbul-lib-coverage@3.2.2: {}
istanbul-lib-report@3.0.1:
dependencies:
istanbul-lib-coverage: 3.2.2
make-dir: 4.0.0
supports-color: 7.2.0
istanbul-reports@3.2.0:
dependencies:
html-escaper: 2.0.2
istanbul-lib-report: 3.0.1
iterare@1.2.1: {} iterare@1.2.1: {}
jest-worker@27.5.1: jest-worker@27.5.1:
@@ -12749,6 +12926,10 @@ snapshots:
joycon@3.1.1: {} joycon@3.1.1: {}
js-levenshtein@1.1.6: {}
js-tokens@10.0.0: {}
js-tokens@4.0.0: {} js-tokens@4.0.0: {}
js-yaml@4.1.1: js-yaml@4.1.1:
@@ -12847,7 +13028,7 @@ snapshots:
jwks-rsa@3.2.2: jwks-rsa@3.2.2:
dependencies: dependencies:
'@types/jsonwebtoken': 9.0.10 '@types/jsonwebtoken': 9.0.10
debug: 4.4.3 debug: 4.4.3(supports-color@10.2.2)
jose: 4.15.9 jose: 4.15.9
limiter: 1.1.5 limiter: 1.1.5
lru-memoizer: 2.3.0 lru-memoizer: 2.3.0
@@ -12993,6 +13174,16 @@ snapshots:
dependencies: dependencies:
'@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/sourcemap-codec': 1.5.5
magicast@0.5.2:
dependencies:
'@babel/parser': 7.29.2
'@babel/types': 7.29.0
source-map-js: 1.2.1
make-dir@4.0.0:
dependencies:
semver: 7.7.4
mapbox-gl@3.21.0: mapbox-gl@3.21.0:
dependencies: dependencies:
'@mapbox/jsonlint-lines-primitives': 2.0.2 '@mapbox/jsonlint-lines-primitives': 2.0.2
@@ -13082,6 +13273,10 @@ snapshots:
dependencies: dependencies:
brace-expansion: 1.1.13 brace-expansion: 1.1.13
minimatch@5.1.9:
dependencies:
brace-expansion: 2.1.0
minimist@1.2.8: {} minimist@1.2.8: {}
minipass@7.1.3: {} minipass@7.1.3: {}
@@ -13310,6 +13505,16 @@ snapshots:
dependencies: dependencies:
mimic-function: 5.0.1 mimic-function: 5.0.1
openapi-typescript@7.13.0(typescript@6.0.2):
dependencies:
'@redocly/openapi-core': 1.34.12(supports-color@10.2.2)
ansi-colors: 4.1.3
change-case: 5.4.4
parse-json: 8.3.0
supports-color: 10.2.2
typescript: 6.0.2
yargs-parser: 21.1.1
optionator@0.9.4: optionator@0.9.4:
dependencies: dependencies:
deep-is: 0.1.4 deep-is: 0.1.4
@@ -13364,10 +13569,10 @@ snapshots:
dependencies: dependencies:
'@tootallnate/quickjs-emscripten': 0.23.0 '@tootallnate/quickjs-emscripten': 0.23.0
agent-base: 7.1.4 agent-base: 7.1.4
debug: 4.4.3 debug: 4.4.3(supports-color@10.2.2)
get-uri: 6.0.5 get-uri: 6.0.5
http-proxy-agent: 7.0.2 http-proxy-agent: 7.0.2
https-proxy-agent: 7.0.6 https-proxy-agent: 7.0.6(supports-color@10.2.2)
pac-resolver: 7.0.1 pac-resolver: 7.0.1
socks-proxy-agent: 8.0.5 socks-proxy-agent: 8.0.5
transitivePeerDependencies: transitivePeerDependencies:
@@ -13391,6 +13596,12 @@ snapshots:
json-parse-even-better-errors: 2.3.1 json-parse-even-better-errors: 2.3.1
lines-and-columns: 1.2.4 lines-and-columns: 1.2.4
parse-json@8.3.0:
dependencies:
'@babel/code-frame': 7.29.0
index-to-position: 1.2.0
type-fest: 4.41.0
parse-srcset@1.0.2: {} parse-srcset@1.0.2: {}
parse5@8.0.0: parse5@8.0.0:
@@ -13710,9 +13921,9 @@ snapshots:
proxy-agent@6.5.0: proxy-agent@6.5.0:
dependencies: dependencies:
agent-base: 7.1.4 agent-base: 7.1.4
debug: 4.4.3 debug: 4.4.3(supports-color@10.2.2)
http-proxy-agent: 7.0.2 http-proxy-agent: 7.0.2
https-proxy-agent: 7.0.6 https-proxy-agent: 7.0.6(supports-color@10.2.2)
lru-cache: 7.18.3 lru-cache: 7.18.3
pac-proxy-agent: 7.2.0 pac-proxy-agent: 7.2.0
proxy-from-env: 1.1.0 proxy-from-env: 1.1.0
@@ -13735,7 +13946,7 @@ snapshots:
dependencies: dependencies:
'@puppeteer/browsers': 2.13.0 '@puppeteer/browsers': 2.13.0
chromium-bidi: 14.0.0(devtools-protocol@0.0.1595872) chromium-bidi: 14.0.0(devtools-protocol@0.0.1595872)
debug: 4.4.3 debug: 4.4.3(supports-color@10.2.2)
devtools-protocol: 0.0.1595872 devtools-protocol: 0.0.1595872
typed-query-selector: 2.12.1 typed-query-selector: 2.12.1
webdriver-bidi-protocol: 0.4.1 webdriver-bidi-protocol: 0.4.1
@@ -13905,7 +14116,7 @@ snapshots:
require-in-the-middle@8.0.1: require-in-the-middle@8.0.1:
dependencies: dependencies:
debug: 4.4.3 debug: 4.4.3(supports-color@10.2.2)
module-details-from-path: 1.0.4 module-details-from-path: 1.0.4
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@@ -13997,7 +14208,7 @@ snapshots:
router@2.2.0: router@2.2.0:
dependencies: dependencies:
debug: 4.4.3 debug: 4.4.3(supports-color@10.2.2)
depd: 2.0.0 depd: 2.0.0
is-promise: 4.0.0 is-promise: 4.0.0
parseurl: 1.3.3 parseurl: 1.3.3
@@ -14065,7 +14276,7 @@ snapshots:
send@1.2.1: send@1.2.1:
dependencies: dependencies:
debug: 4.4.3 debug: 4.4.3(supports-color@10.2.2)
encodeurl: 2.0.0 encodeurl: 2.0.0
escape-html: 1.0.3 escape-html: 1.0.3
etag: 1.8.1 etag: 1.8.1
@@ -14182,7 +14393,7 @@ snapshots:
socket.io-adapter@2.5.6: socket.io-adapter@2.5.6:
dependencies: dependencies:
debug: 4.4.3 debug: 4.4.3(supports-color@10.2.2)
ws: 8.18.3 ws: 8.18.3
transitivePeerDependencies: transitivePeerDependencies:
- bufferutil - bufferutil
@@ -14192,7 +14403,7 @@ snapshots:
socket.io-client@4.8.3: socket.io-client@4.8.3:
dependencies: dependencies:
'@socket.io/component-emitter': 3.1.2 '@socket.io/component-emitter': 3.1.2
debug: 4.4.3 debug: 4.4.3(supports-color@10.2.2)
engine.io-client: 6.6.4 engine.io-client: 6.6.4
socket.io-parser: 4.2.6 socket.io-parser: 4.2.6
transitivePeerDependencies: transitivePeerDependencies:
@@ -14203,7 +14414,7 @@ snapshots:
socket.io-parser@4.2.6: socket.io-parser@4.2.6:
dependencies: dependencies:
'@socket.io/component-emitter': 3.1.2 '@socket.io/component-emitter': 3.1.2
debug: 4.4.3 debug: 4.4.3(supports-color@10.2.2)
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@@ -14212,7 +14423,7 @@ snapshots:
accepts: 1.3.8 accepts: 1.3.8
base64id: 2.0.0 base64id: 2.0.0
cors: 2.8.6 cors: 2.8.6
debug: 4.4.3 debug: 4.4.3(supports-color@10.2.2)
engine.io: 6.6.6 engine.io: 6.6.6
socket.io-adapter: 2.5.6 socket.io-adapter: 2.5.6
socket.io-parser: 4.2.6 socket.io-parser: 4.2.6
@@ -14224,7 +14435,7 @@ snapshots:
socks-proxy-agent@8.0.5: socks-proxy-agent@8.0.5:
dependencies: dependencies:
agent-base: 7.1.4 agent-base: 7.1.4
debug: 4.4.3 debug: 4.4.3(supports-color@10.2.2)
socks: 2.8.7 socks: 2.8.7
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@@ -14371,7 +14582,7 @@ snapshots:
dependencies: dependencies:
component-emitter: 1.3.1 component-emitter: 1.3.1
cookiejar: 2.1.4 cookiejar: 2.1.4
debug: 4.4.3 debug: 4.4.3(supports-color@10.2.2)
fast-safe-stringify: 2.1.1 fast-safe-stringify: 2.1.1
form-data: 4.0.5 form-data: 4.0.5
formidable: 3.5.4 formidable: 3.5.4
@@ -14393,6 +14604,8 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
supports-color@10.2.2: {}
supports-color@7.2.0: supports-color@7.2.0:
dependencies: dependencies:
has-flag: 4.0.0 has-flag: 4.0.0
@@ -14645,6 +14858,8 @@ snapshots:
type-fest@0.7.1: {} type-fest@0.7.1: {}
type-fest@4.41.0: {}
type-fest@5.5.0: type-fest@5.5.0:
dependencies: dependencies:
tagged-tag: 1.0.0 tagged-tag: 1.0.0
@@ -14741,6 +14956,8 @@ snapshots:
escalade: 3.2.0 escalade: 3.2.0
picocolors: 1.1.1 picocolors: 1.1.1
uri-js-replace@1.0.1: {}
uri-js@4.4.1: uri-js@4.4.1:
dependencies: dependencies:
punycode: 2.3.1 punycode: 2.3.1
@@ -14829,7 +15046,7 @@ snapshots:
tsx: 4.21.0 tsx: 4.21.0
yaml: 2.8.3 yaml: 2.8.3
vitest@4.1.3(@opentelemetry/api@1.9.1)(@types/node@25.5.2)(jsdom@29.0.2(@noble/hashes@2.0.1))(msw@2.13.2(@types/node@25.5.2)(typescript@6.0.2))(vite@7.3.2(@types/node@25.5.2)(jiti@1.21.7)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): vitest@4.1.3(@opentelemetry/api@1.9.1)(@types/node@25.5.2)(@vitest/coverage-v8@4.1.3)(jsdom@29.0.2(@noble/hashes@2.0.1))(msw@2.13.2(@types/node@25.5.2)(typescript@6.0.2))(vite@7.3.2(@types/node@25.5.2)(jiti@1.21.7)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
dependencies: dependencies:
'@vitest/expect': 4.1.3 '@vitest/expect': 4.1.3
'@vitest/mocker': 4.1.3(msw@2.13.2(@types/node@25.5.2)(typescript@6.0.2))(vite@7.3.2(@types/node@25.5.2)(jiti@1.21.7)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) '@vitest/mocker': 4.1.3(msw@2.13.2(@types/node@25.5.2)(typescript@6.0.2))(vite@7.3.2(@types/node@25.5.2)(jiti@1.21.7)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
@@ -14854,11 +15071,12 @@ snapshots:
optionalDependencies: optionalDependencies:
'@opentelemetry/api': 1.9.1 '@opentelemetry/api': 1.9.1
'@types/node': 25.5.2 '@types/node': 25.5.2
'@vitest/coverage-v8': 4.1.3(vitest@4.1.3)
jsdom: 29.0.2(@noble/hashes@2.0.1) jsdom: 29.0.2(@noble/hashes@2.0.1)
transitivePeerDependencies: transitivePeerDependencies:
- msw - msw
vitest@4.1.3(@opentelemetry/api@1.9.1)(@types/node@25.5.2)(jsdom@29.0.2(@noble/hashes@2.0.1))(msw@2.13.2(@types/node@25.5.2)(typescript@6.0.2))(vite@7.3.2(@types/node@25.5.2)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): vitest@4.1.3(@opentelemetry/api@1.9.1)(@types/node@25.5.2)(@vitest/coverage-v8@4.1.3)(jsdom@29.0.2(@noble/hashes@2.0.1))(msw@2.13.2(@types/node@25.5.2)(typescript@6.0.2))(vite@7.3.2(@types/node@25.5.2)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
dependencies: dependencies:
'@vitest/expect': 4.1.3 '@vitest/expect': 4.1.3
'@vitest/mocker': 4.1.3(msw@2.13.2(@types/node@25.5.2)(typescript@6.0.2))(vite@7.3.2(@types/node@25.5.2)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) '@vitest/mocker': 4.1.3(msw@2.13.2(@types/node@25.5.2)(typescript@6.0.2))(vite@7.3.2(@types/node@25.5.2)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
@@ -14883,6 +15101,7 @@ snapshots:
optionalDependencies: optionalDependencies:
'@opentelemetry/api': 1.9.1 '@opentelemetry/api': 1.9.1
'@types/node': 25.5.2 '@types/node': 25.5.2
'@vitest/coverage-v8': 4.1.3(vitest@4.1.3)
jsdom: 29.0.2(@noble/hashes@2.0.1) jsdom: 29.0.2(@noble/hashes@2.0.1)
transitivePeerDependencies: transitivePeerDependencies:
- msw - msw
@@ -15062,6 +15281,8 @@ snapshots:
yallist@4.0.0: {} yallist@4.0.0: {}
yaml-ast-parser@0.0.43: {}
yaml@2.8.3: {} yaml@2.8.3: {}
yargs-parser@18.1.3: yargs-parser@18.1.3:

View File

@@ -15,6 +15,10 @@
"test": { "test": {
"dependsOn": ["^build"] "dependsOn": ["^build"]
}, },
"test:coverage": {
"dependsOn": ["^build"],
"outputs": ["coverage/**"]
},
"typecheck": { "typecheck": {
"dependsOn": ["^build"] "dependsOn": ["^build"]
} }