import { z } from 'zod';
import { Property, Address, PriceHistoryEntry, ZillowPropertyResponse, RentCastPropertyResponse } from '../types/real-estate.js';
import { ValidationError } from '../utils/errors.js';
/**
* Zod schemas for validation
*/
const AddressSchema = z.object({
street: z.string().optional(),
city: z.string().optional(),
state: z.string().optional(),
postalCode: z.string().optional(),
country: z.string().optional(),
latitude: z.number().optional(),
longitude: z.number().optional(),
});
const PropertySchema = z.object({
id: z.string().optional(),
address: AddressSchema,
price: z.number().optional(),
beds: z.number().optional(),
baths: z.number().optional(),
sqft: z.number().optional(),
lotSizeSqft: z.number().optional(),
propertyType: z.string().optional(),
yearBuilt: z.number().optional(),
url: z.string().url().optional(),
source: z.enum(['zillow', 'redfin', 'rentcast', 'mls', 'other']).optional(),
mlsId: z.string().optional(),
daysOnMarket: z.number().optional(),
hoaMonthly: z.number().optional(),
taxesAnnual: z.number().optional(),
estimate: z.number().optional(),
description: z.string().optional(),
photos: z.array(z.string()).optional(),
features: z.array(z.string()).optional(),
listingStatus: z.enum(['active', 'pending', 'sold', 'off_market']).optional(),
listingDate: z.string().optional(),
priceHistory: z.array(z.any()).optional(),
schoolDistrict: z.string().optional(),
walkScore: z.number().optional(),
transitScore: z.number().optional(),
crimeScore: z.number().optional(),
lastSoldPrice: z.number().optional(),
lastSoldDate: z.string().optional(),
propertyTaxRate: z.number().optional(),
insurance: z.number().optional(),
utilities: z.number().optional(),
});
/**
* Data transformation service for normalizing API responses
*/
export class DataTransformerService {
/**
* Transform Zillow API response to Property interface
*/
transformZillowProperty(zillowData: ZillowPropertyResponse): Property {
try {
// Extract and normalize address
const address: Address = {
street: zillowData.address?.streetAddress,
city: zillowData.address?.city,
state: zillowData.address?.state,
postalCode: zillowData.address?.zipcode,
country: 'US',
};
// Transform price history
const priceHistory: PriceHistoryEntry[] | undefined = zillowData.priceHistory?.map(entry => ({
date: new Date(entry.date).toISOString(),
price: entry.price,
event: this.normalizeZillowPriceEvent(entry.event),
}));
// Extract features from resoFacts
const features: string[] = [];
if (zillowData.resoFacts?.hasView) features.push('View');
if (zillowData.resoFacts?.hasGarage) features.push('Garage');
if (zillowData.resoFacts?.parkingFeatures) {
features.push(...zillowData.resoFacts.parkingFeatures);
}
// Get most recent tax amount
const mostRecentTax = zillowData.taxHistory?.[0]?.taxPaid;
const property: Property = {
id: zillowData.zpid,
address,
price: zillowData.price || undefined,
beds: zillowData.bedrooms || undefined,
baths: zillowData.bathrooms || undefined,
sqft: zillowData.livingArea || undefined,
lotSizeSqft: zillowData.lotAreaValue || undefined,
propertyType: this.normalizePropertyType(zillowData.propertyType),
yearBuilt: zillowData.yearBuilt || undefined,
url: zillowData.homeDetailsUrl || undefined,
source: 'zillow',
mlsId: zillowData.mlsid || undefined,
daysOnMarket: zillowData.daysOnZillow || undefined,
hoaMonthly: zillowData.monthlyHoaFee || undefined,
taxesAnnual: mostRecentTax || undefined,
estimate: zillowData.zestimate || undefined,
description: zillowData.description || undefined,
photos: zillowData.photos && zillowData.photos.length > 0 ? zillowData.photos : undefined,
features: features.length > 0 ? features : undefined,
priceHistory,
};
return this.validateProperty(property);
} catch (error) {
throw new ValidationError(`Failed to transform Zillow property: ${error}`);
}
}
/**
* Transform RentCast API response to Property interface
*/
transformRentCastProperty(rentcastData: RentCastPropertyResponse): Property {
try {
const address: Address = {
street: rentcastData.address,
city: rentcastData.city,
state: rentcastData.state,
postalCode: rentcastData.zipCode,
country: 'US',
latitude: rentcastData.latitude,
longitude: rentcastData.longitude,
};
const property: Property = {
id: rentcastData.id,
address,
price: rentcastData.price || rentcastData.avm?.value,
beds: rentcastData.bedrooms,
baths: rentcastData.bathrooms,
sqft: rentcastData.squareFootage,
lotSizeSqft: rentcastData.lotSize,
propertyType: this.normalizePropertyType(rentcastData.propertyType),
yearBuilt: rentcastData.yearBuilt,
source: 'rentcast',
estimate: rentcastData.avm?.value,
};
return this.validateProperty(property);
} catch (error) {
throw new ValidationError(`Failed to transform RentCast property: ${error}`);
}
}
/**
* Validate property against schema
*/
private validateProperty(property: Property): Property {
try {
return PropertySchema.parse(property);
} catch (error) {
if (error instanceof z.ZodError) {
const issues = error.issues.map(issue =>
`${issue.path.join('.')}: ${issue.message}`
).join(', ');
throw new ValidationError(`Property validation failed: ${issues}`);
}
throw error;
}
}
/**
* Normalize property types across different sources
*/
private normalizePropertyType(type?: string): string | undefined {
if (!type) return undefined;
const normalized = type.toLowerCase().replace(/[_\s-]/g, '_');
const typeMap: Record<string, string> = {
'single_family': 'single_family',
'singlefamily': 'single_family',
'single_family_home': 'single_family',
'house': 'single_family',
'condo': 'condo',
'condominium': 'condo',
'townhouse': 'townhouse',
'townhome': 'townhouse',
'apartment': 'apartment',
'multi_family': 'multi_family',
'multifamily': 'multi_family',
'duplex': 'duplex',
'land': 'land',
'vacant_land': 'land',
'manufactured': 'manufactured',
'mobile': 'manufactured',
};
return typeMap[normalized] || type;
}
/**
* Normalize Zillow price history events
*/
private normalizeZillowPriceEvent(event: string): PriceHistoryEntry['event'] {
const eventLower = event.toLowerCase();
if (eventLower.includes('list') || eventLower.includes('market')) {
return 'listed';
}
if (eventLower.includes('price') || eventLower.includes('change')) {
return 'price_change';
}
if (eventLower.includes('sold')) {
return 'sold';
}
if (eventLower.includes('delist') || eventLower.includes('withdraw')) {
return 'delisted';
}
return 'price_change'; // default
}
/**
* Standardize address format
*/
standardizeAddress(address: Address): Address {
const standardized = { ...address };
// Capitalize state abbreviations
if (standardized.state && standardized.state.length === 2) {
standardized.state = standardized.state.toUpperCase();
}
// Format postal code
if (standardized.postalCode) {
// Remove non-digits and format as XXXXX or XXXXX-XXXX
const digits = standardized.postalCode.replace(/\D/g, '');
if (digits.length >= 5) {
standardized.postalCode = digits.length > 5
? `${digits.slice(0, 5)}-${digits.slice(5, 9)}`
: digits.slice(0, 5);
}
}
// Ensure country is set
if (!standardized.country) {
standardized.country = 'US';
}
return standardized;
}
/**
* Convert units (e.g., square feet to square meters)
*/
convertUnits(value: number, from: string, to: string): number {
const conversions: Record<string, Record<string, number>> = {
sqft: {
sqm: 0.092903,
},
sqm: {
sqft: 10.7639,
},
};
const conversion = conversions[from]?.[to];
if (!conversion) {
throw new ValidationError(`Unit conversion not supported: ${from} to ${to}`);
}
return Math.round(value * conversion * 100) / 100; // Round to 2 decimal places
}
/**
* Merge properties from different sources
*/
mergeProperties(properties: Property[]): Property {
if (properties.length === 0) {
throw new ValidationError('Cannot merge empty property list');
}
if (properties.length === 1) {
return properties[0];
}
// Use the first property as base and merge others
const merged = { ...properties[0] };
for (let i = 1; i < properties.length; i++) {
const property = properties[i];
// Merge fields, preferring non-null values
Object.keys(property).forEach(key => {
const typedKey = key as keyof Property;
if (property[typedKey] && !merged[typedKey]) {
(merged as any)[typedKey] = property[typedKey];
}
});
// Merge photos arrays
if (property.photos && merged.photos) {
merged.photos = [...new Set([...merged.photos, ...property.photos])];
} else if (property.photos) {
merged.photos = property.photos;
}
// Merge features arrays
if (property.features && merged.features) {
merged.features = [...new Set([...merged.features, ...property.features])];
} else if (property.features) {
merged.features = property.features;
}
}
return this.validateProperty(merged);
}
/**
* Calculate property quality score based on available data
*/
calculateDataQualityScore(property: Property): number {
let score = 0;
let maxScore = 0;
// Essential fields (higher weight)
const essentialFields = ['price', 'beds', 'baths', 'sqft', 'address'];
essentialFields.forEach(field => {
maxScore += 2;
if (property[field as keyof Property]) score += 2;
});
// Important fields (medium weight)
const importantFields = ['yearBuilt', 'propertyType', 'lotSizeSqft', 'estimate'];
importantFields.forEach(field => {
maxScore += 1.5;
if (property[field as keyof Property]) score += 1.5;
});
// Nice-to-have fields (lower weight)
const optionalFields = ['photos', 'description', 'features', 'priceHistory'];
optionalFields.forEach(field => {
maxScore += 1;
if (property[field as keyof Property]) score += 1;
});
return Math.round((score / maxScore) * 100) / 100; // 0-1 score
}
}
// Singleton instance
export const dataTransformer = new DataTransformerService();