Files
Kordant/services/hometitle/src/watchlist.service.ts
Michael Freno d6f574ff8e FRE-5350: Add property scanner service with ATTOM, USPS, county data integration
- ATTOM Property API integration for structured property data
- USPS address standardization via API
- County clerk/recorder feed scraping for deed changes and liens
- Rate limiting, caching, and retry logic
- Unit tests for each data source adapter
- PropertyRecord, CountyDeedRecord, DataSourceType types in types.ts
- Consolidated type exports in index.ts

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-16 09:50:21 -04:00

132 lines
3.4 KiB
TypeScript

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<string, number> = {
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<number> {
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();