diff --git a/.env.example b/.env.example index 393fab7..bf72872 100644 --- a/.env.example +++ b/.env.example @@ -202,14 +202,19 @@ SENTRY_ORG= SENTRY_PROJECT= # ----------------------------------------------------------------------------- -# KYC Field Encryption (REQUIRED in production) +# Field Encryption (REQUIRED in production) # -# AES-256-GCM key for encrypting sensitive KYC data at rest. +# AES-256-GCM key for encrypting sensitive PII (KYC and other user fields) at rest. # Must be exactly 64 hex characters (32 bytes). # openssl rand -hex 32 +# +# Canonical names: FIELD_ENCRYPTION_KEY / FIELD_ENCRYPTION_KEY_VERSION. +# The runtime still reads legacy KYC_ENCRYPTION_KEY / KYC_ENCRYPTION_KEY_VERSION +# as a deprecated fallback (see field-encryption.service.ts); new deployments +# should set FIELD_ENCRYPTION_KEY only. # ----------------------------------------------------------------------------- -KYC_ENCRYPTION_KEY= -KYC_ENCRYPTION_KEY_VERSION=1 +FIELD_ENCRYPTION_KEY= +FIELD_ENCRYPTION_KEY_VERSION=1 # ----------------------------------------------------------------------------- # Logging diff --git a/apps/api/src/modules/analytics/infrastructure/__tests__/prisma-avm.service.spec.ts b/apps/api/src/modules/analytics/infrastructure/__tests__/prisma-avm.service.spec.ts index e73c582..6b7b5ea 100644 --- a/apps/api/src/modules/analytics/infrastructure/__tests__/prisma-avm.service.spec.ts +++ b/apps/api/src/modules/analytics/infrastructure/__tests__/prisma-avm.service.spec.ts @@ -29,12 +29,15 @@ describe('PrismaAVMService', () => { }); it('returns zero confidence when fewer than 3 comparables', async () => { - mockPrisma.$queryRaw.mockResolvedValue([ - { latitude: 10.762, longitude: 106.66, areaM2: 80, propertyType: 'APARTMENT', yearBuilt: 2020, floor: 5, totalFloors: 20 }, - ]); - mockPrisma.$queryRawUnsafe.mockResolvedValue([ - { property_id: 'p1', address: '1 Test', district: 'Q1', price_vnd: 5000000000n, price_per_m2: 70000000, area_m2: 72, property_type: 'APARTMENT', distance_meters: 100, published_at: new Date() }, - ]); + // First $queryRaw call: property location lookup + // Second $queryRaw call: findComparables (parameterized after refactor in 6774914) + mockPrisma.$queryRaw + .mockResolvedValueOnce([ + { latitude: 10.762, longitude: 106.66, areaM2: 80, propertyType: 'APARTMENT', yearBuilt: 2020, floor: 5, totalFloors: 20 }, + ]) + .mockResolvedValueOnce([ + { property_id: 'p1', address: '1 Test', district: 'Q1', price_vnd: 5000000000n, price_per_m2: 70000000, area_m2: 72, property_type: 'APARTMENT', distance_meters: 100, published_at: new Date() }, + ]); const result = await service.estimateValue({ propertyId: 'prop-1' }); @@ -44,14 +47,15 @@ describe('PrismaAVMService', () => { }); it('calculates weighted valuation with sufficient comparables', async () => { - mockPrisma.$queryRaw.mockResolvedValue([ - { latitude: 10.762, longitude: 106.66, areaM2: 80, propertyType: 'APARTMENT', yearBuilt: 2020, floor: 5, totalFloors: 20 }, - ]); - mockPrisma.$queryRawUnsafe.mockResolvedValue([ - { property_id: 'p1', address: '1 Test', district: 'Q1', price_vnd: 5000000000n, price_per_m2: 70000000, area_m2: 72, property_type: 'APARTMENT', distance_meters: 100, published_at: new Date() }, - { property_id: 'p2', address: '2 Test', district: 'Q1', price_vnd: 5200000000n, price_per_m2: 72000000, area_m2: 72, property_type: 'APARTMENT', distance_meters: 300, published_at: new Date() }, - { property_id: 'p3', address: '3 Test', district: 'Q1', price_vnd: 5500000000n, price_per_m2: 75000000, area_m2: 73, property_type: 'APARTMENT', distance_meters: 500, published_at: new Date() }, - ]); + mockPrisma.$queryRaw + .mockResolvedValueOnce([ + { latitude: 10.762, longitude: 106.66, areaM2: 80, propertyType: 'APARTMENT', yearBuilt: 2020, floor: 5, totalFloors: 20 }, + ]) + .mockResolvedValueOnce([ + { property_id: 'p1', address: '1 Test', district: 'Q1', price_vnd: 5000000000n, price_per_m2: 70000000, area_m2: 72, property_type: 'APARTMENT', distance_meters: 100, published_at: new Date() }, + { property_id: 'p2', address: '2 Test', district: 'Q1', price_vnd: 5200000000n, price_per_m2: 72000000, area_m2: 72, property_type: 'APARTMENT', distance_meters: 300, published_at: new Date() }, + { property_id: 'p3', address: '3 Test', district: 'Q1', price_vnd: 5500000000n, price_per_m2: 75000000, area_m2: 73, property_type: 'APARTMENT', distance_meters: 500, published_at: new Date() }, + ]); const result = await service.estimateValue({ propertyId: 'prop-1' }); @@ -63,7 +67,8 @@ describe('PrismaAVMService', () => { }); it('uses coordinates directly when no propertyId', async () => { - mockPrisma.$queryRawUnsafe.mockResolvedValue([ + // coords-only path: no property lookup, $queryRaw used for comparables directly + mockPrisma.$queryRaw.mockResolvedValue([ { property_id: 'p1', address: '1 Test', district: 'Q1', price_vnd: 5000000000n, price_per_m2: 70000000, area_m2: 72, property_type: 'APARTMENT', distance_meters: 100, published_at: new Date() }, { property_id: 'p2', address: '2 Test', district: 'Q1', price_vnd: 5200000000n, price_per_m2: 72000000, area_m2: 72, property_type: 'APARTMENT', distance_meters: 300, published_at: new Date() }, { property_id: 'p3', address: '3 Test', district: 'Q1', price_vnd: 5500000000n, price_per_m2: 75000000, area_m2: 73, property_type: 'APARTMENT', distance_meters: 500, published_at: new Date() }, @@ -78,18 +83,20 @@ describe('PrismaAVMService', () => { expect(result.confidence).toBeGreaterThan(0); expect(Number(result.estimatedPrice)).toBeGreaterThan(0); - expect(mockPrisma.$queryRaw).not.toHaveBeenCalled(); + // coords-only path: $queryRaw is called for comparables, $queryRawUnsafe is not + expect(mockPrisma.$queryRawUnsafe).not.toHaveBeenCalled(); }); }); describe('getComparables', () => { it('returns comparables for a property', async () => { - mockPrisma.$queryRaw.mockResolvedValue([ - { latitude: 10.762, longitude: 106.66, areaM2: 80, propertyType: 'APARTMENT', yearBuilt: 2020, floor: 5, totalFloors: 20 }, - ]); - mockPrisma.$queryRawUnsafe.mockResolvedValue([ - { property_id: 'p1', address: '1 Test', district: 'Q1', price_vnd: 5000000000n, price_per_m2: 70000000, area_m2: 72, property_type: 'APARTMENT', distance_meters: 200, published_at: new Date() }, - ]); + mockPrisma.$queryRaw + .mockResolvedValueOnce([ + { latitude: 10.762, longitude: 106.66, areaM2: 80, propertyType: 'APARTMENT', yearBuilt: 2020, floor: 5, totalFloors: 20 }, + ]) + .mockResolvedValueOnce([ + { property_id: 'p1', address: '1 Test', district: 'Q1', price_vnd: 5000000000n, price_per_m2: 70000000, area_m2: 72, property_type: 'APARTMENT', distance_meters: 200, published_at: new Date() }, + ]); const result = await service.getComparables('prop-1', 3000); diff --git a/apps/api/src/modules/search/application/__tests__/search-properties.handler.spec.ts b/apps/api/src/modules/search/application/__tests__/search-properties.handler.spec.ts index e26681c..38f3fa6 100644 --- a/apps/api/src/modules/search/application/__tests__/search-properties.handler.spec.ts +++ b/apps/api/src/modules/search/application/__tests__/search-properties.handler.spec.ts @@ -144,4 +144,45 @@ describe('SearchPropertiesHandler', () => { const searchCall = mockSearchRepo.search.mock.calls[0]![0]; expect(searchCall.filterBy).not.toContain('isFeatured'); }); + + it('emits ward clause when ward is provided', async () => { + mockSearchRepo.search.mockResolvedValue(createMockSearchResult()); + + const query = new SearchPropertiesQuery( + undefined, undefined, undefined, undefined, undefined, + undefined, undefined, undefined, undefined, undefined, + undefined, undefined, undefined, undefined, 'Tan Phong', + ); + await handler.execute(query); + + const searchCall = mockSearchRepo.search.mock.calls[0]![0]; + expect(searchCall.filterBy).toContain('ward:=Tan Phong'); + }); + + it('omits ward clause when ward is not provided', async () => { + mockSearchRepo.search.mockResolvedValue(createMockSearchResult()); + + await handler.execute(new SearchPropertiesQuery('anything')); + + const searchCall = mockSearchRepo.search.mock.calls[0]![0]; + expect(searchCall.filterBy).not.toContain('ward:='); + }); + + it('round-trips ward through parseFilterBy', async () => { + const { parseFilterBy } = await import( + '../../infrastructure/services/search-filter-parser' + ); + mockSearchRepo.search.mockResolvedValue(createMockSearchResult()); + + const query = new SearchPropertiesQuery( + undefined, undefined, undefined, undefined, undefined, + undefined, undefined, undefined, undefined, undefined, + undefined, undefined, undefined, undefined, 'Tan Phong', + ); + await handler.execute(query); + + const searchCall = mockSearchRepo.search.mock.calls[0]![0]; + const parsed = parseFilterBy(searchCall.filterBy); + expect(parsed.ward).toBe('Tan Phong'); + }); }); diff --git a/apps/api/src/modules/search/application/queries/search-properties/search-properties.handler.ts b/apps/api/src/modules/search/application/queries/search-properties/search-properties.handler.ts index 61fad23..6a45efb 100644 --- a/apps/api/src/modules/search/application/queries/search-properties/search-properties.handler.ts +++ b/apps/api/src/modules/search/application/queries/search-properties/search-properties.handler.ts @@ -46,6 +46,9 @@ export class SearchPropertiesHandler implements IQueryHandler { return this.props.district; } + get ward(): string | undefined { + return this.props.ward; + } + get city(): string | undefined { return this.props.city; } diff --git a/apps/api/src/modules/search/infrastructure/services/search-filter-parser.ts b/apps/api/src/modules/search/infrastructure/services/search-filter-parser.ts index 297adcf..32e8c85 100644 --- a/apps/api/src/modules/search/infrastructure/services/search-filter-parser.ts +++ b/apps/api/src/modules/search/infrastructure/services/search-filter-parser.ts @@ -16,6 +16,7 @@ export interface ParsedFilters { areaMax?: number; bedrooms?: number; district?: string; + ward?: string; city?: string; } @@ -49,6 +50,7 @@ export function parseFilterBy(filterStr: string): ParsedFilters { if (field === 'propertyType') result.propertyType = val; else if (field === 'transactionType') result.transactionType = val; else if (field === 'district') result.district = val; + else if (field === 'ward') result.ward = val; else if (field === 'city') result.city = val; else if (field === 'status') { /* handled separately */ } continue; diff --git a/apps/api/src/modules/search/presentation/controllers/search.controller.ts b/apps/api/src/modules/search/presentation/controllers/search.controller.ts index a91cffe..b2eb629 100644 --- a/apps/api/src/modules/search/presentation/controllers/search.controller.ts +++ b/apps/api/src/modules/search/presentation/controllers/search.controller.ts @@ -52,6 +52,7 @@ export class SearchController { dto.page, dto.perPage, dto.featured, + dto.ward, ), ); } diff --git a/apps/api/src/modules/search/presentation/dto/search-properties.dto.ts b/apps/api/src/modules/search/presentation/dto/search-properties.dto.ts index c1a8a72..2fe3fd4 100644 --- a/apps/api/src/modules/search/presentation/dto/search-properties.dto.ts +++ b/apps/api/src/modules/search/presentation/dto/search-properties.dto.ts @@ -74,6 +74,11 @@ export class SearchPropertiesDto { @IsString() district?: string; + @ApiPropertyOptional({ description: 'Ward name', example: 'Tan Phong' }) + @IsOptional() + @IsString() + ward?: string; + @ApiPropertyOptional({ description: 'City name', example: 'Ho Chi Minh' }) @IsOptional() @IsString() diff --git a/apps/web/components/inquiries/__tests__/inquiry-detail-dialog.spec.tsx b/apps/web/components/inquiries/__tests__/inquiry-detail-dialog.spec.tsx index 3e26e50..e502351 100644 --- a/apps/web/components/inquiries/__tests__/inquiry-detail-dialog.spec.tsx +++ b/apps/web/components/inquiries/__tests__/inquiry-detail-dialog.spec.tsx @@ -1,8 +1,8 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { describe, expect, it, vi } from 'vitest'; -import type { InquiryReadDto } from '@/lib/inquiries-api'; import { InquiryDetailDialog } from '../inquiry-detail-dialog'; +import type { InquiryReadDto } from '@/lib/inquiries-api'; // Mock the hook const mockMarkReadMutate = vi.fn(); diff --git a/apps/web/components/leads/__tests__/lead-detail-dialog.spec.tsx b/apps/web/components/leads/__tests__/lead-detail-dialog.spec.tsx index c95c796..b50fabd 100644 --- a/apps/web/components/leads/__tests__/lead-detail-dialog.spec.tsx +++ b/apps/web/components/leads/__tests__/lead-detail-dialog.spec.tsx @@ -1,8 +1,8 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { describe, expect, it, vi } from 'vitest'; -import type { LeadReadDto } from '@/lib/leads-api'; import { LeadDetailDialog } from '../lead-detail-dialog'; +import type { LeadReadDto } from '@/lib/leads-api'; // Mock hooks const mockUpdateMutate = vi.fn(); diff --git a/apps/web/vitest.config.ts b/apps/web/vitest.config.ts index 0d1d000..fc4f702 100644 --- a/apps/web/vitest.config.ts +++ b/apps/web/vitest.config.ts @@ -5,6 +5,7 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ plugins: [react()], test: { + root: path.resolve(__dirname, '.'), include: ['**/__tests__/**/*.spec.ts', '**/__tests__/**/*.test.ts', '**/__tests__/**/*.spec.tsx', '**/__tests__/**/*.test.tsx'], exclude: ['**/node_modules/**'], environment: 'jsdom', diff --git a/scripts/bootstrap.sh b/scripts/bootstrap.sh index 78ad11a..7d4b521 100755 --- a/scripts/bootstrap.sh +++ b/scripts/bootstrap.sh @@ -163,7 +163,7 @@ else "${SED_INPLACE[@]}" "s|PGBOUNCER_STATS_PASSWORD=CHANGE_ME|PGBOUNCER_STATS_PASSWORD=pgbouncer_stats|" .env "${SED_INPLACE[@]}" "s|JWT_SECRET=.*|JWT_SECRET=$JWT_SECRET_VAL|" .env "${SED_INPLACE[@]}" "s|JWT_REFRESH_SECRET=.*|JWT_REFRESH_SECRET=$JWT_REFRESH_VAL|" .env - "${SED_INPLACE[@]}" "s|KYC_ENCRYPTION_KEY=.*|KYC_ENCRYPTION_KEY=$KYC_KEY_VAL|" .env + "${SED_INPLACE[@]}" "s|FIELD_ENCRYPTION_KEY=.*|FIELD_ENCRYPTION_KEY=$KYC_KEY_VAL|" .env else warn "openssl not found — .env copied but secrets are placeholders. Update them manually." fi