- 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>
132 lines
3.4 KiB
TypeScript
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();
|