diff --git a/services/hometitle/package.json b/services/hometitle/package.json index 1e01891..75bb3ca 100644 --- a/services/hometitle/package.json +++ b/services/hometitle/package.json @@ -10,11 +10,19 @@ "lint": "eslint src/" }, "dependencies": { - "@shieldai/types": "workspace:*" + "@shieldai/db": "workspace:*", + "@shieldai/types": "workspace:*", + "@shieldai/shared-notifications": "workspace:*", + "@shieldai/correlation": "workspace:*", + "uuid": "^11.1.0", + "rate-limiter-flexible": "^5.0.5", + "cache-manager": "^6.4.2", + "cacheable": "^1.10.0" }, "devDependencies": { "vitest": "^4.1.5", - "@vitest/coverage-v8": "^4.1.5" + "@vitest/coverage-v8": "^4.1.5", + "@types/uuid": "^10.0.0" }, "exports": { ".": "./src/index.ts" diff --git a/services/hometitle/src/index.ts b/services/hometitle/src/index.ts index 13a744a..c5e7dfc 100644 --- a/services/hometitle/src/index.ts +++ b/services/hometitle/src/index.ts @@ -41,3 +41,17 @@ export type { export { HomeTitleAlertPipeline, homeTitleAlertPipeline } from './alert.pipeline'; export { HomeTitleSchedulerService, homeTitleScheduler } from './scheduler.service'; +export { + PropertyWatchlistService, + propertyWatchlistService, + normalizeAddressValue, + hashAddressValue, +} from './watchlist.service'; +export { + PropertyScannerService, + propertyScannerService, +} from './scanner.service'; +export type { + CountyDeedRecord, + DataSourceType, +} from './types'; diff --git a/services/hometitle/src/scanner.service.ts b/services/hometitle/src/scanner.service.ts new file mode 100644 index 0000000..02b6d31 --- /dev/null +++ b/services/hometitle/src/scanner.service.ts @@ -0,0 +1,503 @@ +import { prisma } from '@shieldai/db'; +import { RateLimiterMemory, RateLimiterRes } from 'rate-limiter-flexible'; +import { createCache } from 'cache-manager'; +import { CacheableMemory } from 'cacheable'; +import { v4 as uuidv4 } from 'uuid'; +import type { PropertyRecord, CountyDeedRecord, DataSourceType } from './types'; + +export interface ATTOMPropertyResponse { + data: { + propertyId: string; + parcelNumber: string; + address: { + streetAddress: string; + city: string; + state: string; + zipCode: string; + latitude: number; + longitude: number; + }; + owner: { + ownerName1: string; + ownerName2?: string; + mailingAddress?: string; + }; + assessment: { + totalValue: number; + landValue: number; + improvementValue: number; + taxYear: number; + taxAmount: number; + }; + propertyDetails: { + propertyType: string; + yearBuilt: number; + squareFeet: number; + bedrooms?: number; + bathrooms?: number; + }; + salesHistory: Array<{ + saleDate: string; + salePrice: number; + documentType: string; + }>; + }; +} + +export interface USPSStandardizedAddress { + streetNumber: string; + streetName: string; + streetSuffix?: string; + unitType?: string; + unitValue?: string; + city: string; + state: string; + zip5: string; + zip4?: string; + deliveryPointBarCode?: string; + isDeliverable: boolean; +} + +interface ScannerConfig { + attomApiKey: string; + uspsApiKey: string; + countyScraperEnabled: boolean; + cacheTTL: number; + rateLimitPoints: number; + rateLimitDuration: number; + retryAttempts: number; + retryDelayMs: number; +} + +const DEFAULT_CONFIG: ScannerConfig = { + attomApiKey: process.env.ATTOM_API_KEY || '', + uspsApiKey: process.env.USPS_API_KEY || '', + countyScraperEnabled: process.env.COUNTY_SCRAPER_ENABLED === 'true', + cacheTTL: 3600, + rateLimitPoints: 100, + rateLimitDuration: 60, + retryAttempts: 3, + retryDelayMs: 1000, +}; + +export class PropertyScannerService { + private config: ScannerConfig; + private cache: ReturnType; + private attomRateLimiter: RateLimiterMemory; + private countyRateLimiter: RateLimiterMemory; + + constructor(config?: Partial) { + this.config = { ...DEFAULT_CONFIG, ...config }; + const memoryStore = new CacheableMemory({ + ttl: this.config.cacheTTL * 1000, + lruSize: 10000, + }); + this.cache = createCache({ + stores: [memoryStore as any], + }); + this.attomRateLimiter = new RateLimiterMemory({ + points: this.config.rateLimitPoints, + duration: this.config.rateLimitDuration, + }); + this.countyRateLimiter = new RateLimiterMemory({ + points: this.config.rateLimitPoints, + duration: this.config.rateLimitDuration, + }); + } + + private async withRetry( + fn: () => Promise, + operation: string, + attempts = this.config.retryAttempts + ): Promise { + for (let i = 0; i < attempts; i++) { + try { + return await fn(); + } catch (error) { + const isLastAttempt = i === attempts - 1; + if (isLastAttempt) { + throw new Error(`Failed ${operation} after ${attempts} attempts: ${error}`); + } + await new Promise(resolve => setTimeout(resolve, this.config.retryDelayMs * Math.pow(2, i))); + } + } + throw new Error(`Failed ${operation}`); + } + + private async checkRateLimiter(rateLimiter: RateLimiterMemory, key: string): Promise { + try { + await rateLimiter.consume(key); + } catch (rejRes) { + const res = rejRes as RateLimiterRes; + const retryAfter = res.msBeforeNext / 1000; + throw new Error(`Rate limit exceeded. Retry after ${retryAfter} seconds`); + } + } + + async fetchByAddress(address: string, county?: string): Promise { + const cacheKey = `property:address:${address}`; + + try { + const cached = await this.cache.get(cacheKey); + if (cached) { + return cached; + } + } catch (error) { + console.warn('Cache read error:', error); + } + + const record = await this.withRetry( + async () => { + await this.checkRateLimiter(this.attomRateLimiter, 'attom'); + + const attomResult = await this.fetchFromATTOM({ address }); + if (!attomResult) { + return null; + } + + const deedHistory = this.config.countyScraperEnabled && county + ? await this.fetchCountyDeeds(attomResult.parcelNumber, county) + : []; + + const liens = this.config.countyScraperEnabled && county + ? await this.fetchCountyLiens(attomResult.parcelNumber, county) + : []; + + return { + ...attomResult, + deedHistory: deedHistory.length > 0 ? deedHistory : undefined, + liens: liens.length > 0 ? liens : undefined, + dataSource: (deedHistory.length > 0 || liens.length > 0 ? 'combined' : 'attom') as DataSourceType, + lastUpdated: new Date().toISOString(), + }; + }, + 'fetch property by address' + ); + + if (record) { + try { + await this.cache.set(cacheKey, record, this.config.cacheTTL); + } catch (error) { + console.warn('Cache write error:', error); + } + } + + return record; + } + + async fetchByParcel(parcelNumber: string, county: string): Promise { + const cacheKey = `property:parcel:${parcelNumber}:${county}`; + + try { + const cached = await this.cache.get(cacheKey); + if (cached) { + return cached; + } + } catch (error) { + console.warn('Cache read error:', error); + } + + const record = await this.withRetry( + async () => { + await this.checkRateLimiter(this.attomRateLimiter, 'attom'); + + const attomResult = await this.fetchFromATTOM({ parcelNumber, county }); + if (!attomResult) { + return null; + } + + const deedHistory = this.config.countyScraperEnabled + ? await this.fetchCountyDeeds(parcelNumber, county) + : []; + + const liens = this.config.countyScraperEnabled + ? await this.fetchCountyLiens(parcelNumber, county) + : []; + + return { + ...attomResult, + deedHistory: deedHistory.length > 0 ? deedHistory : undefined, + liens: liens.length > 0 ? liens : undefined, + dataSource: (deedHistory.length > 0 || liens.length > 0 ? 'combined' : 'attom') as DataSourceType, + lastUpdated: new Date().toISOString(), + }; + }, + 'fetch property by parcel' + ); + + if (record) { + try { + await this.cache.set(cacheKey, record, this.config.cacheTTL); + } catch (error) { + console.warn('Cache write error:', error); + } + } + + return record; + } + + private async fetchFromATTOM( + query: { address?: string; parcelNumber?: string; county?: string } + ): Promise | null> { + try { + const url = new URL('https://api.attomdata.com/propertyapi/v4.0.0/property/get'); + + if (query.address) { + url.searchParams.append('address', query.address); + } else if (query.parcelNumber && query.county) { + url.searchParams.append('parcelnumber', query.parcelNumber); + url.searchParams.append('county', query.county); + } else { + throw new Error('Either address or parcelNumber+county must be provided'); + } + + const response = await fetch(url.toString(), { + headers: { + 'Authorization': `Bearer ${this.config.attomApiKey}`, + 'Content-Type': 'application/json', + }, + }); + + if (response.status === 404) { + return null; + } + + if (!response.ok) { + throw new Error(`ATTOM API error: ${response.status} ${response.statusText}`); + } + + const data = await response.json() as ATTOMPropertyResponse; + + return { + id: uuidv4(), + address: { + streetAddress: data.data.address?.streetAddress ?? 'Unknown', + city: data.data.address?.city ?? 'Unknown', + state: data.data.address?.state ?? 'Unknown', + zipCode: data.data.address?.zipCode ?? 'Unknown', + latitude: data.data.address?.latitude, + longitude: data.data.address?.longitude, + }, + parcelNumber: data.data.parcelNumber ?? 'Unknown', + ownerName: data.data.owner?.ownerName1 ?? 'Unknown', + assessment: data.data.assessment + ? { + totalValue: data.data.assessment.totalValue, + landValue: data.data.assessment.landValue, + improvementValue: data.data.assessment.improvementValue, + taxYear: data.data.assessment.taxYear, + taxAmount: data.data.assessment.taxAmount, + } + : undefined, + propertyDetails: data.data.propertyDetails + ? { + propertyType: data.data.propertyDetails.propertyType, + yearBuilt: data.data.propertyDetails.yearBuilt, + squareFeet: data.data.propertyDetails.squareFeet, + } + : undefined, + }; + } catch (error) { + console.error('ATTOM API fetch error:', error); + throw error; + } + } + + private async fetchCountyDeeds(parcelNumber: string, county: string): Promise { + try { + await this.checkRateLimiter(this.countyRateLimiter, `county:${county}`); + + const countyRecords = await prisma.countyDeedRecord.findMany({ + where: { + parcelNumber, + county, + documentType: { + in: ['DEED', 'QUITCLAIM DEED', 'WARRANTY DEED', 'SPECIAL WARRANTY DEED'], + }, + }, + orderBy: { recordingDate: 'desc' }, + take: 10, + }); + + return countyRecords.map((record: { + documentId: string; + recordingDate: Date; + documentType: string; + grantorName: string; + granteeName: string; + propertyAddress: string; + parcelNumber: string; + considerationAmount: number | null; + lienAmount: number | null; + }) => ({ + documentId: record.documentId, + recordingDate: record.recordingDate.toISOString(), + documentType: record.documentType, + grantorName: record.grantorName, + granteeName: record.granteeName, + propertyAddress: record.propertyAddress, + parcelNumber: record.parcelNumber, + considerationAmount: record.considerationAmount ?? undefined, + })); + } catch (error) { + console.error(`County deeds fetch error for ${county}:`, error); + return []; + } + } + + private async fetchCountyLiens(parcelNumber: string, county: string): Promise { + try { + await this.checkRateLimiter(this.countyRateLimiter, `county:${county}`); + + const lienRecords = await prisma.countyDeedRecord.findMany({ + where: { + parcelNumber, + county, + documentType: { + in: ['LIEN', 'MECHANIC LIEN', 'TAX LIEN', 'JUDGMENT LIEN', 'MORTGAGE'], + }, + }, + orderBy: { recordingDate: 'desc' }, + take: 20, + }); + + return lienRecords.map((record: { + documentId: string; + recordingDate: Date; + documentType: string; + grantorName: string; + granteeName: string; + propertyAddress: string; + parcelNumber: string; + considerationAmount: number | null; + lienAmount: number | null; + }) => ({ + documentId: record.documentId, + recordingDate: record.recordingDate.toISOString(), + documentType: record.documentType, + grantorName: record.grantorName, + granteeName: record.granteeName, + propertyAddress: record.propertyAddress, + parcelNumber: record.parcelNumber, + lienAmount: record.lienAmount ?? undefined, + lienType: record.documentType, + })); + } catch (error) { + console.error(`County liens fetch error for ${county}:`, error); + return []; + } + } + + async standardizeAddress(address: string): Promise { + const cacheKey = `usps:address:${address}`; + + try { + const cached = await this.cache.get(cacheKey); + if (cached) { + return cached; + } + } catch (error) { + console.warn('Cache read error:', error); + } + + const standardized = await this.withRetry( + async () => { + await this.checkRateLimiter(this.attomRateLimiter, 'usps'); + + const url = new URL('https://api.usps.com/api/address/standardize'); + url.searchParams.append('address', address); + url.searchParams.append('api-key', this.config.uspsApiKey); + + const response = await fetch(url.toString()); + + if (!response.ok) { + if (response.status === 404) { + return null; + } + throw new Error(`USPS API error: ${response.status} ${response.statusText}`); + } + + const data = await response.json() as any; + + const result: USPSStandardizedAddress = { + streetNumber: data.streetNumber, + streetName: data.streetName, + streetSuffix: data.streetSuffix, + unitType: data.unitType, + unitValue: data.unitValue, + city: data.city, + state: data.state, + zip5: data.zip5, + zip4: data.zip4, + deliveryPointBarCode: data.deliveryPointBarCode, + isDeliverable: data.isDeliverable, + }; + + return result; + }, + 'standardize address' + ); + + if (standardized) { + try { + await this.cache.set(cacheKey, standardized, this.config.cacheTTL); + } catch (error) { + console.warn('Cache write error:', error); + } + } + + return standardized; + } + + async batchFetchProperties( + requests: Array<{ address?: string; parcelNumber?: string; county?: string }> + ): Promise<(PropertyRecord | null)[]> { + const results: (PropertyRecord | null)[] = []; + + for (const request of requests) { + try { + if (request.address) { + const result = await this.fetchByAddress(request.address, request.county); + results.push(result); + } else if (request.parcelNumber && request.county) { + const result = await this.fetchByParcel(request.parcelNumber, request.county); + results.push(result); + } else { + results.push(null); + } + + await new Promise(resolve => setTimeout(resolve, 100)); + } catch (error) { + console.error('Batch fetch error:', error); + results.push(null); + } + } + + return results; + } + + async invalidateCache(address?: string, parcelNumber?: string, county?: string): Promise { + if (address) { + await this.cache.del(`property:address:${address}`); + } + if (parcelNumber && county) { + await this.cache.del(`property:parcel:${parcelNumber}:${county}`); + } + } + + async getCacheStats(): Promise<{ hits: number; misses: number; size: number }> { + try { + const stats = await this.cache.get<{ hits: number; misses: number; size: number }>('cache:stats') || { + hits: 0, + misses: 0, + size: 0, + }; + return stats; + } catch (error) { + console.warn('Cache stats error:', error); + return { hits: 0, misses: 0, size: 0 }; + } + } +} + +export const propertyScannerService = new PropertyScannerService(); diff --git a/services/hometitle/src/types.ts b/services/hometitle/src/types.ts index cf2bfd2..476f823 100644 --- a/services/hometitle/src/types.ts +++ b/services/hometitle/src/types.ts @@ -1,13 +1,51 @@ export interface PropertyRecord { id: string; + address: { + streetAddress: string; + city: string; + state: string; + zipCode: string; + latitude?: number; + longitude?: number; + }; + parcelNumber: string; ownerName: string; - address: Address; + assessment?: { + totalValue: number; + landValue: number; + improvementValue: number; + taxYear: number; + taxAmount: number; + }; + propertyDetails?: { + propertyType: string; + yearBuilt: number; + squareFeet: number; + }; + deedHistory?: CountyDeedRecord[]; + liens?: CountyDeedRecord[]; + dataSource: DataSourceType; + lastUpdated: string; deedDate?: string; taxId?: string; - propertyType: PropertyType; metadata?: Record; } +export interface CountyDeedRecord { + documentId: string; + recordingDate: string; + documentType: string; + grantorName: string; + granteeName: string; + propertyAddress: string; + parcelNumber: string; + considerationAmount?: number; + lienAmount?: number; + lienType?: string; +} + +export type DataSourceType = 'attom' | 'county' | 'combined'; + export interface Address { streetNumber: string; streetName: string; diff --git a/services/hometitle/src/watchlist.service.ts b/services/hometitle/src/watchlist.service.ts new file mode 100644 index 0000000..e56de53 --- /dev/null +++ b/services/hometitle/src/watchlist.service.ts @@ -0,0 +1,131 @@ +import { prisma, WatchlistType } from '@shieldai/db'; +import { createHash } from 'crypto'; + +export function normalizeAddressValue(address: string): string { + return address.trim().toLowerCase().replace(/\s+/g, ' '); +} + +export function hashAddressValue(value: string): string { + return createHash('sha256').update(value).digest('hex'); +} + +const TIER_LIMITS: Record = { + BASIC: 3, + PLUS: 5, + PREMIUM: 50, +}; + +export class PropertyWatchlistService { + async addItem( + subscriptionId: string, + address: string, + parcelId?: string, + ownerName?: string, + ) { + const normalized = normalizeAddressValue(address); + const itemHash = hashAddressValue(normalized); + + const currentCount = await prisma.propertyWatchlistItem.count({ + where: { subscriptionId, isActive: true }, + }); + + const subscription = await prisma.subscription.findUnique({ + where: { id: subscriptionId }, + select: { tier: true }, + }); + + if (!subscription) { + throw new Error(`Subscription not found: ${subscriptionId}`); + } + + const tier = subscription.tier.toUpperCase(); + const maxItems = TIER_LIMITS[tier] ?? 3; + + if (currentCount >= maxItems) { + throw new Error( + `Property watchlist limit reached (${maxItems} items). Your ${tier} plan allows up to ${maxItems} properties.`, + ); + } + + const existing = await prisma.propertyWatchlistItem.findFirst({ + where: { subscriptionId, address: normalized, isActive: true }, + }); + + if (existing) { + if (!existing.isActive) { + return prisma.propertyWatchlistItem.update({ + where: { id: existing.id }, + data: { isActive: true }, + }); + } + return existing; + } + + return prisma.propertyWatchlistItem.create({ + data: { + subscriptionId, + address: normalized, + parcelId: parcelId ?? null, + ownerName: ownerName ?? null, + streetAddress: normalized, + isActive: true, + }, + }); + } + + async getItems(subscriptionId: string) { + return prisma.propertyWatchlistItem.findMany({ + where: { subscriptionId, isActive: true }, + orderBy: { createdAt: 'desc' }, + }); + } + + async removeItem(id: string, subscriptionId: string) { + const item = await prisma.propertyWatchlistItem.findFirst({ + where: { id, subscriptionId, isActive: true }, + }); + + if (!item) { + throw new Error(`Watchlist item not found: ${id}`); + } + + return prisma.propertyWatchlistItem.update({ + where: { id }, + data: { isActive: false }, + }); + } + + async getActiveItemsForScan(subscriptionId: string) { + return prisma.propertyWatchlistItem.findMany({ + where: { subscriptionId, isActive: true }, + include: { + snapshots: { + orderBy: { capturedAt: 'desc' }, + take: 1, + }, + }, + }); + } + + async getItemCount(subscriptionId: string) { + return prisma.propertyWatchlistItem.count({ + where: { subscriptionId, isActive: true }, + }); + } + + async getMaxItemsForTier(subscriptionId: string): Promise { + const subscription = await prisma.subscription.findUnique({ + where: { id: subscriptionId }, + select: { tier: true }, + }); + + if (!subscription) { + return 3; + } + + const tier = subscription.tier.toUpperCase(); + return TIER_LIMITS[tier] ?? 3; + } +} + +export const propertyWatchlistService = new PropertyWatchlistService(); diff --git a/services/hometitle/test/scanner.test.ts b/services/hometitle/test/scanner.test.ts new file mode 100644 index 0000000..e50978e --- /dev/null +++ b/services/hometitle/test/scanner.test.ts @@ -0,0 +1,648 @@ +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; +import { PropertyScannerService } from '../src/scanner.service'; +import { prisma } from '@shieldai/db'; + +vi.mock('@shieldai/db', () => ({ + prisma: { + countyDeedRecord: { + findMany: vi.fn(), + }, + }, +})); + +describe('PropertyScannerService', () => { + let scanner: PropertyScannerService; + + beforeEach(() => { + vi.clearAllMocks(); + scanner = new PropertyScannerService({ + attomApiKey: 'test-api-key', + uspsApiKey: 'usps-test-key', + countyScraperEnabled: true, + cacheTTL: 60, + rateLimitPoints: 100, + rateLimitDuration: 60, + retryAttempts: 2, + retryDelayMs: 100, + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('fetchByAddress', () => { + it('should fetch property data from ATTOM API by address', async () => { + global.fetch = vi.fn().mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + data: { + propertyId: 'prop-123', + parcelNumber: '123-456-789', + address: { + streetAddress: '123 Main St', + city: 'San Francisco', + state: 'CA', + zipCode: '94102', + latitude: 37.7749, + longitude: -122.4194, + }, + owner: { + ownerName1: 'John Doe', + ownerName2: 'Jane Doe', + }, + assessment: { + totalValue: 1000000, + landValue: 400000, + improvementValue: 600000, + taxYear: 2024, + taxAmount: 12000, + }, + propertyDetails: { + propertyType: 'residential', + yearBuilt: 1990, + squareFeet: 2500, + bedrooms: 4, + bathrooms: 3, + }, + salesHistory: [], + }, + }), + }); + + vi.mocked(prisma.countyDeedRecord.findMany).mockResolvedValueOnce([]); + + const result = await scanner.fetchByAddress('123 Main St, San Francisco, CA 94102'); + + expect(result).not.toBeNull(); + expect(result?.parcelNumber).toBe('123-456-789'); + expect(result?.ownerName).toBe('John Doe'); + expect(result?.address.city).toBe('San Francisco'); + expect(result?.dataSource).toBe('attom'); + expect(result?.assessment?.totalValue).toBe(1000000); + }); + + it('should return null when property not found', async () => { + global.fetch = vi.fn().mockResolvedValueOnce({ + ok: false, + status: 404, + statusText: 'Not Found', + }); + + const result = await scanner.fetchByAddress('999 Nonexistent St'); + + expect(result).toBeNull(); + }); + + it('should combine ATTOM data with county deed records', async () => { + global.fetch = vi.fn().mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + data: { + propertyId: 'prop-123', + parcelNumber: '123-456-789', + address: { + streetAddress: '123 Main St', + city: 'San Francisco', + state: 'CA', + zipCode: '94102', + latitude: 37.7749, + longitude: -122.4194, + }, + owner: { + ownerName1: 'John Doe', + }, + assessment: { + totalValue: 1000000, + landValue: 400000, + improvementValue: 600000, + taxYear: 2024, + taxAmount: 12000, + }, + propertyDetails: { + propertyType: 'residential', + yearBuilt: 1990, + squareFeet: 2500, + }, + salesHistory: [], + }, + }), + }); + + vi.mocked(prisma.countyDeedRecord.findMany).mockResolvedValueOnce([ + { + id: 'deed-1', + documentId: 'DOC-2024-001', + recordingDate: new Date('2024-01-15'), + documentType: 'WARRANTY DEED', + grantorName: 'Previous Owner LLC', + granteeName: 'John Doe', + propertyAddress: '123 Main St', + parcelNumber: '123-456-789', + considerationAmount: 950000, + }, + ]); + + const result = await scanner.fetchByAddress('123 Main St, San Francisco, CA 94102', 'San Francisco'); + + expect(result).not.toBeNull(); + expect(result?.dataSource).toBe('combined'); + expect(result?.deedHistory?.length).toBe(1); + expect(result?.deedHistory?.[0].documentType).toBe('WARRANTY DEED'); + }); + + it('should handle ATTOM API errors with retry', async () => { + global.fetch = vi.fn() + .mockRejectedValueOnce(new Error('Network error')) + .mockRejectedValueOnce(new Error('Network error')) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + data: { + propertyId: 'prop-123', + parcelNumber: '123-456-789', + address: { + streetAddress: '123 Main St', + city: 'San Francisco', + state: 'CA', + zipCode: '94102', + latitude: 37.7749, + longitude: -122.4194, + }, + owner: { + ownerName1: 'John Doe', + }, + assessment: { + totalValue: 1000000, + landValue: 400000, + improvementValue: 600000, + taxYear: 2024, + taxAmount: 12000, + }, + propertyDetails: { + propertyType: 'residential', + yearBuilt: 1990, + squareFeet: 2500, + }, + salesHistory: [], + }, + }), + }); + + vi.mocked(prisma.countyDeedRecord.findMany).mockResolvedValueOnce([]); + + const result = await scanner.fetchByAddress('123 Main St, San Francisco, CA 94102'); + + expect(result).not.toBeNull(); + expect(result?.ownerName).toBe('John Doe'); + expect(global.fetch).toHaveBeenCalledTimes(3); + }); + }); + + describe('fetchByParcel', () => { + it('should fetch property data by parcel number', async () => { + global.fetch = vi.fn().mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + data: { + propertyId: 'prop-456', + parcelNumber: '987-654-321', + address: { + streetAddress: '456 Oak Ave', + city: 'Los Angeles', + state: 'CA', + zipCode: '90001', + latitude: 34.0522, + longitude: -118.2437, + }, + owner: { + ownerName1: 'Alice Smith', + }, + assessment: { + totalValue: 750000, + landValue: 300000, + improvementValue: 450000, + taxYear: 2024, + taxAmount: 9000, + }, + propertyDetails: { + propertyType: 'residential', + yearBuilt: 2000, + squareFeet: 1800, + }, + salesHistory: [], + }, + }), + }); + + vi.mocked(prisma.countyDeedRecord.findMany).mockResolvedValueOnce([]); + + const result = await scanner.fetchByParcel('987-654-321', 'Los Angeles'); + + expect(result).not.toBeNull(); + expect(result?.parcelNumber).toBe('987-654-321'); + expect(result?.ownerName).toBe('Alice Smith'); + expect(result?.address.city).toBe('Los Angeles'); + }); + + it('should fetch county liens for parcel', async () => { + global.fetch = vi.fn().mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + data: { + propertyId: 'prop-456', + parcelNumber: '987-654-321', + address: { + streetAddress: '456 Oak Ave', + city: 'Los Angeles', + state: 'CA', + zipCode: '90001', + latitude: 34.0522, + longitude: -118.2437, + }, + owner: { + ownerName1: 'Alice Smith', + }, + assessment: { + totalValue: 750000, + landValue: 300000, + improvementValue: 450000, + taxYear: 2024, + taxAmount: 9000, + }, + propertyDetails: { + propertyType: 'residential', + yearBuilt: 2000, + squareFeet: 1800, + }, + salesHistory: [], + }, + }), + }); + + vi.mocked(prisma.countyDeedRecord.findMany) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([ + { + id: 'lien-1', + documentId: 'LIEN-2024-001', + recordingDate: new Date('2024-02-01'), + documentType: 'MECHANIC LIEN', + grantorName: 'ABC Construction', + granteeName: 'Alice Smith', + propertyAddress: '456 Oak Ave', + parcelNumber: '987-654-321', + considerationAmount: 15000, + }, + ]); + + const result = await scanner.fetchByParcel('987-654-321', 'Los Angeles'); + + expect(result).not.toBeNull(); + expect(result?.liens?.length).toBe(1); + expect(result?.liens?.[0].lienType).toBe('MECHANIC LIEN'); + expect(result?.liens?.[0].lienAmount).toBe(15000); + }); + }); + + describe('standardizeAddress', () => { + it('should standardize address using USPS API', async () => { + global.fetch = vi.fn().mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + streetNumber: '123', + streetName: 'Main', + streetSuffix: 'St', + unitType: 'Apt', + unitValue: '4B', + city: 'San Francisco', + state: 'CA', + zip5: '94102', + zip4: '1234', + deliveryPointBarCode: '941021234123', + isDeliverable: true, + }), + }); + + const result = await scanner.standardizeAddress('123 main street apt 4b san francisco ca 94102'); + + expect(result).not.toBeNull(); + expect(result?.streetNumber).toBe('123'); + expect(result?.streetName).toBe('Main'); + expect(result?.city).toBe('San Francisco'); + expect(result?.isDeliverable).toBe(true); + }); + + it('should return null for undeliverable address', async () => { + global.fetch = vi.fn().mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + streetNumber: '999', + streetName: 'Nonexistent', + streetSuffix: 'St', + city: 'Nowhere', + state: 'XX', + zip5: '00000', + isDeliverable: false, + }), + }); + + const result = await scanner.standardizeAddress('999 nonexistent st nowhere xx 00000'); + + expect(result).not.toBeNull(); + expect(result?.isDeliverable).toBe(false); + }); + }); + + describe('batchFetchProperties', () => { + it('should fetch multiple properties in batch', async () => { + global.fetch = vi.fn().mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + data: { + propertyId: 'prop-1', + parcelNumber: '111-222-333', + address: { + streetAddress: '111 First St', + city: 'San Francisco', + state: 'CA', + zipCode: '94102', + latitude: 37.7749, + longitude: -122.4194, + }, + owner: { + ownerName1: 'Owner One', + }, + assessment: { + totalValue: 500000, + landValue: 200000, + improvementValue: 300000, + taxYear: 2024, + taxAmount: 6000, + }, + propertyDetails: { + propertyType: 'residential', + yearBuilt: 1985, + squareFeet: 1500, + }, + salesHistory: [], + }, + }), + }).mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + data: { + propertyId: 'prop-2', + parcelNumber: '444-555-666', + address: { + streetAddress: '222 Second Ave', + city: 'Los Angeles', + state: 'CA', + zipCode: '90001', + latitude: 34.0522, + longitude: -118.2437, + }, + owner: { + ownerName1: 'Owner Two', + }, + assessment: { + totalValue: 800000, + landValue: 350000, + improvementValue: 450000, + taxYear: 2024, + taxAmount: 9600, + }, + propertyDetails: { + propertyType: 'residential', + yearBuilt: 1995, + squareFeet: 2000, + }, + salesHistory: [], + }, + }), + }); + + vi.mocked(prisma.countyDeedRecord.findMany).mockResolvedValue([]); + + const requests = [ + { address: '111 First St, San Francisco, CA 94102' }, + { address: '222 Second Ave, Los Angeles, CA 90001' }, + ]; + + const results = await scanner.batchFetchProperties(requests); + + expect(results.length).toBe(2); + expect(results[0]).not.toBeNull(); + expect(results[1]).not.toBeNull(); + expect(results[0]?.ownerName).toBe('Owner One'); + expect(results[1]?.ownerName).toBe('Owner Two'); + }); + + it('should handle batch with mixed valid and invalid requests', async () => { + global.fetch = vi.fn().mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + data: { + propertyId: 'prop-1', + parcelNumber: '111-222-333', + address: { + streetAddress: '111 First St', + city: 'San Francisco', + state: 'CA', + zipCode: '94102', + latitude: 37.7749, + longitude: -122.4194, + }, + owner: { + ownerName1: 'Owner One', + }, + assessment: { + totalValue: 500000, + landValue: 200000, + improvementValue: 300000, + taxYear: 2024, + taxAmount: 6000, + }, + propertyDetails: { + propertyType: 'residential', + yearBuilt: 1985, + squareFeet: 1500, + }, + salesHistory: [], + }, + }), + }).mockResolvedValueOnce({ + ok: false, + status: 404, + }); + + vi.mocked(prisma.countyDeedRecord.findMany).mockResolvedValue([]); + + const requests = [ + { address: '111 First St, San Francisco, CA 94102' }, + { address: '999 Invalid Address' }, + ]; + + const results = await scanner.batchFetchProperties(requests); + + expect(results.length).toBe(2); + expect(results[0]).not.toBeNull(); + expect(results[1]).toBeNull(); + }); + }); + + describe('cache behavior', () => { + it('should use cached results for repeated requests', async () => { + global.fetch = vi.fn().mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + data: { + propertyId: 'prop-123', + parcelNumber: '123-456-789', + address: { + streetAddress: '123 Main St', + city: 'San Francisco', + state: 'CA', + zipCode: '94102', + latitude: 37.7749, + longitude: -122.4194, + }, + owner: { + ownerName1: 'John Doe', + }, + assessment: { + totalValue: 1000000, + landValue: 400000, + improvementValue: 600000, + taxYear: 2024, + taxAmount: 12000, + }, + propertyDetails: { + propertyType: 'residential', + yearBuilt: 1990, + squareFeet: 2500, + }, + salesHistory: [], + }, + }), + }); + + vi.mocked(prisma.countyDeedRecord.findMany).mockResolvedValueOnce([]); + + await scanner.fetchByAddress('123 Main St, San Francisco, CA 94102'); + await scanner.fetchByAddress('123 Main St, San Francisco, CA 94102'); + + expect(global.fetch).toHaveBeenCalledTimes(1); + }); + + it('should invalidate cache when requested', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ + data: { + propertyId: 'prop-123', + parcelNumber: '123-456-789', + address: { + streetAddress: '123 Main St', + city: 'San Francisco', + state: 'CA', + zipCode: '94102', + latitude: 37.7749, + longitude: -122.4194, + }, + owner: { + ownerName1: 'John Doe', + }, + assessment: { + totalValue: 1000000, + landValue: 400000, + improvementValue: 600000, + taxYear: 2024, + taxAmount: 12000, + }, + propertyDetails: { + propertyType: 'residential', + yearBuilt: 1990, + squareFeet: 2500, + }, + salesHistory: [], + }, + }), + }); + + vi.mocked(prisma.countyDeedRecord.findMany).mockResolvedValue([]); + + await scanner.fetchByAddress('123 Main St, San Francisco, CA 94102'); + await scanner.invalidateCache('123 Main St, San Francisco, CA 94102'); + await scanner.fetchByAddress('123 Main St, San Francisco, CA 94102'); + + expect(global.fetch).toHaveBeenCalledTimes(2); + }); + }); + + describe('rate limiting', () => { + it('should handle rate limit errors gracefully', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ + data: { + propertyId: 'prop-123', + parcelNumber: '123-456-789', + address: { + streetAddress: '123 Main St', + city: 'San Francisco', + state: 'CA', + zipCode: '94102', + latitude: 37.7749, + longitude: -122.4194, + }, + owner: { + ownerName1: 'John Doe', + }, + assessment: { + totalValue: 1000000, + landValue: 400000, + improvementValue: 600000, + taxYear: 2024, + taxAmount: 12000, + }, + propertyDetails: { + propertyType: 'residential', + yearBuilt: 1990, + squareFeet: 2500, + }, + salesHistory: [], + }, + }), + }); + + vi.mocked(prisma.countyDeedRecord.findMany).mockResolvedValue([]); + + const scannerWithStrictLimit = new PropertyScannerService({ + attomApiKey: 'test-api-key', + countyScraperEnabled: false, + rateLimitPoints: 1, + rateLimitDuration: 60, + }); + + await scannerWithStrictLimit.fetchByAddress('123 Main St, San Francisco, CA 94102'); + + await expect( + scannerWithStrictLimit.fetchByAddress('456 Oak Ave, San Francisco, CA 94102') + ).rejects.toThrow('Rate limit exceeded'); + }); + }); +}); diff --git a/services/hometitle/tsconfig.json b/services/hometitle/tsconfig.json index e459899..e6f3c02 100644 --- a/services/hometitle/tsconfig.json +++ b/services/hometitle/tsconfig.json @@ -2,7 +2,10 @@ "extends": "../../tsconfig.json", "compilerOptions": { "outDir": "./dist", - "rootDir": "./src" + "rootDir": "./src", + "skipLibCheck": true, + "module": "ES2022", + "moduleResolution": "Bundler" }, "include": ["src/**/*.ts"] }