feat(hometitle): add Backend Router — HomeTitle (Property Monitoring)

- Add hometitle schema (Valibot input schemas)
- Add change detector (fuzzy matching, severity, change detection)
- Add scanner module (geocoding, county records placeholder)
- Add hometitle service (property CRUD, scan, alert pipeline)
- Add hometitle router (7 tRPC procedures)
- Wire into api root
- Add alert type 'property_change' to enum
- Write unit tests (10 tests, all passing)
This commit is contained in:
2026-05-25 16:38:34 -04:00
parent fc9a5c4fb2
commit a3fee924d8
8 changed files with 963 additions and 1 deletions

View File

@@ -5,6 +5,7 @@ import { notificationRouter } from "./routers/notification";
import { darkwatchRouter } from "./routers/darkwatch";
import { voiceprintRouter } from "./routers/voiceprint";
import { spamshieldRouter } from "./routers/spamshield";
import { hometitleRouter } from "./routers/hometitle";
import { createTRPCRouter } from "./utils";
export const appRouter = createTRPCRouter({
@@ -15,6 +16,7 @@ export const appRouter = createTRPCRouter({
darkwatch: darkwatchRouter,
voiceprint: voiceprintRouter,
spamshield: spamshieldRouter,
hometitle: hometitleRouter,
});
export type AppRouter = typeof appRouter;

View File

@@ -0,0 +1,188 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { initTRPC, TRPCError } from "@trpc/server";
import { wrap } from "@typeschema/valibot";
import {
AddPropertySchema,
RemovePropertySchema,
GetSnapshotsSchema,
GetChangesSchema,
RunScanSchema,
} from "../schemas/hometitle";
vi.mock("~/server/services/hometitle.service", () => ({
getProperties: vi.fn(),
addProperty: vi.fn(),
removeProperty: vi.fn(),
getSnapshots: vi.fn(),
getChanges: vi.fn(),
runScan: vi.fn(),
getAlerts: vi.fn(),
}));
import * as hometitleService from "~/server/services/hometitle.service";
const mockGetProperties = vi.mocked(hometitleService.getProperties);
const mockAddProperty = vi.mocked(hometitleService.addProperty);
const mockRemoveProperty = vi.mocked(hometitleService.removeProperty);
const mockGetSnapshots = vi.mocked(hometitleService.getSnapshots);
const mockGetChanges = vi.mocked(hometitleService.getChanges);
const mockRunScan = vi.mocked(hometitleService.runScan);
const mockGetAlerts = vi.mocked(hometitleService.getAlerts);
type User = {
id: string; email: string; name: string | null; image: string | null;
role: string; emailVerified: Date | null; deletedAt: Date | null;
stripeCustomerId: string | null;
createdAt: Date; updatedAt: Date;
};
type Ctx = { db: object; user: User | null; apiKey: string | null };
function createCaller(user: User | null) {
const t = initTRPC.context<Ctx>().create();
const isAuthed = t.middleware(({ ctx, next }) => {
if (!ctx.user) throw new TRPCError({ code: "UNAUTHORIZED" });
return next({ ctx: { ...ctx, user: ctx.user } });
});
const router = t.router({
getProperties: t.procedure.use(isAuthed).query(async ({ ctx }) => {
return mockGetProperties(ctx.user.id);
}),
addProperty: t.procedure.use(isAuthed)
.input(wrap(AddPropertySchema))
.mutation(async ({ ctx, input }) => {
return mockAddProperty(ctx.user.id, input.address, input.parcelId, input.ownerName);
}),
removeProperty: t.procedure.use(isAuthed)
.input(wrap(RemovePropertySchema))
.mutation(async ({ ctx, input }) => {
return mockRemoveProperty(ctx.user.id, input.propertyId);
}),
getSnapshots: t.procedure.use(isAuthed)
.input(wrap(GetSnapshotsSchema))
.query(async ({ ctx, input }) => {
return mockGetSnapshots(ctx.user.id, input.propertyId);
}),
getChanges: t.procedure.use(isAuthed)
.input(wrap(GetChangesSchema))
.query(async ({ ctx, input }) => {
return mockGetChanges(ctx.user.id, input.propertyId, {
severity: input.severity,
changeType: input.changeType,
});
}),
runScan: t.procedure.use(isAuthed)
.input(wrap(RunScanSchema))
.mutation(async ({ ctx }) => {
return mockRunScan(ctx.user.id);
}),
getAlerts: t.procedure.use(isAuthed).query(async ({ ctx }) => {
return mockGetAlerts(ctx.user.id);
}),
});
const caller = t.createCallerFactory(router);
return caller({ db: {} as never, user, apiKey: null });
}
const baseUser: User = {
id: "user-1", email: "a@b.com", name: "Test", image: null,
role: "user", emailVerified: null, deletedAt: null,
stripeCustomerId: null,
createdAt: new Date(), updatedAt: new Date(),
};
function makeUser(overrides: Partial<User> = {}): User {
return { ...baseUser, ...overrides };
}
beforeEach(() => {
vi.clearAllMocks();
});
describe("hometitle.getProperties", () => {
it("returns properties for authenticated user", async () => {
const items = [{ id: "p1", address: "123 Main St" }];
mockGetProperties.mockResolvedValue(items as never);
const api = createCaller(makeUser());
expect(await api.getProperties()).toEqual(items);
});
it("rejects unauthenticated", async () => {
const api = createCaller(null);
await expect(api.getProperties()).rejects.toThrow(TRPCError);
});
});
describe("hometitle.addProperty", () => {
it("adds a property", async () => {
const prop = { id: "p1", address: "123 Main St, Springfield, IL 62701" };
mockAddProperty.mockResolvedValue(prop as never);
const api = createCaller(makeUser());
const result = await api.addProperty({ address: "123 Main St, Springfield, IL 62701" });
expect(result).toEqual(prop);
});
it("rejects empty address", async () => {
const api = createCaller(makeUser());
await expect(
api.addProperty({ address: "" }),
).rejects.toThrow();
});
});
describe("hometitle.removeProperty", () => {
it("removes a property", async () => {
mockRemoveProperty.mockResolvedValue({ id: "p1", isActive: false } as never);
const api = createCaller(makeUser());
const result = await api.removeProperty({ propertyId: "p1" });
expect(result.isActive).toBe(false);
});
});
describe("hometitle.getSnapshots", () => {
it("returns snapshots for a property", async () => {
const snapshots = [{ id: "s1", ownerName: "John Doe" }];
mockGetSnapshots.mockResolvedValue(snapshots as never);
const api = createCaller(makeUser());
const result = await api.getSnapshots({ propertyId: "p1" });
expect(result).toEqual(snapshots);
});
});
describe("hometitle.getChanges", () => {
it("returns changes for a property", async () => {
const changes = [{ id: "c1", changeType: "ownership_transfer" }];
mockGetChanges.mockResolvedValue(changes as never);
const api = createCaller(makeUser());
const result = await api.getChanges({ propertyId: "p1" });
expect(result).toEqual(changes);
});
it("passes severity filter", async () => {
const changes = [{ id: "c1", severity: "critical" }];
mockGetChanges.mockResolvedValue(changes as never);
const api = createCaller(makeUser());
await api.getChanges({ propertyId: "p1", severity: "critical" });
expect(mockGetChanges).toHaveBeenCalledWith("user-1", "p1", { severity: "critical", changeType: undefined });
});
});
describe("hometitle.runScan", () => {
it("triggers a scan", async () => {
mockRunScan.mockResolvedValue({ scanId: "scan-1" });
const api = createCaller(makeUser());
const result = await api.runScan({});
expect(result.scanId).toBe("scan-1");
});
});
describe("hometitle.getAlerts", () => {
it("returns alerts", async () => {
const alerts = [{ id: "a1", propertyAddress: "123 Main St" }];
mockGetAlerts.mockResolvedValue(alerts as never);
const api = createCaller(makeUser());
const result = await api.getAlerts();
expect(result).toEqual(alerts);
});
});

View File

@@ -0,0 +1,53 @@
import { wrap } from "@typeschema/valibot";
import { createTRPCRouter, protectedProcedure } from "../utils";
import {
AddPropertySchema,
RemovePropertySchema,
GetSnapshotsSchema,
GetChangesSchema,
RunScanSchema,
} from "../schemas/hometitle";
import * as hometitleService from "~/server/services/hometitle.service";
export const hometitleRouter = createTRPCRouter({
getProperties: protectedProcedure.query(async ({ ctx }) => {
return hometitleService.getProperties(ctx.user.id);
}),
addProperty: protectedProcedure
.input(wrap(AddPropertySchema))
.mutation(async ({ ctx, input }) => {
return hometitleService.addProperty(ctx.user.id, input.address, input.parcelId, input.ownerName);
}),
removeProperty: protectedProcedure
.input(wrap(RemovePropertySchema))
.mutation(async ({ ctx, input }) => {
return hometitleService.removeProperty(ctx.user.id, input.propertyId);
}),
getSnapshots: protectedProcedure
.input(wrap(GetSnapshotsSchema))
.query(async ({ ctx, input }) => {
return hometitleService.getSnapshots(ctx.user.id, input.propertyId);
}),
getChanges: protectedProcedure
.input(wrap(GetChangesSchema))
.query(async ({ ctx, input }) => {
return hometitleService.getChanges(ctx.user.id, input.propertyId, {
severity: input.severity,
changeType: input.changeType,
});
}),
runScan: protectedProcedure
.input(wrap(RunScanSchema))
.mutation(async ({ ctx }) => {
return hometitleService.runScan(ctx.user.id);
}),
getAlerts: protectedProcedure.query(async ({ ctx }) => {
return hometitleService.getAlerts(ctx.user.id);
}),
});

View File

@@ -0,0 +1,23 @@
import { object, string, minLength, optional, number, picklist } from "valibot";
export const AddPropertySchema = object({
address: string([minLength(1)]),
parcelId: optional(string()),
ownerName: optional(string()),
});
export const RemovePropertySchema = object({
propertyId: string([minLength(1)]),
});
export const GetSnapshotsSchema = object({
propertyId: string([minLength(1)]),
});
export const GetChangesSchema = object({
propertyId: string([minLength(1)]),
severity: optional(picklist(["info", "warning", "critical"])),
changeType: optional(picklist(["tax_change", "deed_change", "ownership_transfer", "lien_filing", "metadata_change"])),
});
export const RunScanSchema = object({});

View File

@@ -9,7 +9,7 @@ export const subscriptionStatus = pgEnum("subscription_status", ["active", "past
export const watchlistType = pgEnum("watchlist_type", ["email", "phoneNumber", "ssn", "address", "domain"]);
export const exposureSource = pgEnum("exposure_source", ["hibp", "securityTrails", "censys", "darkWebForum", "shodan", "honeypot"]);
export const exposureSeverity = pgEnum("exposure_severity", ["info", "warning", "critical"]);
export const alertType = pgEnum("alert_type", ["exposure_detected", "exposure_resolved", "scan_complete", "subscription_changed", "system_warning"]);
export const alertType = pgEnum("alert_type", ["exposure_detected", "exposure_resolved", "scan_complete", "subscription_changed", "system_warning", "property_change"]);
export const alertSeverity = pgEnum("alert_severity", ["info", "warning", "critical"]);
export const alertChannel = pgEnum("alert_channel", ["email", "push", "sms"]);
export const detectionVerdict = pgEnum("detection_verdict", ["NATURAL", "SYNTHETIC", "UNCERTAIN"]);

View File

@@ -0,0 +1,458 @@
import { TRPCError } from "@trpc/server";
import { eq, and, desc, count, gte } from "drizzle-orm";
import { db } from "~/server/db";
import {
subscriptions,
propertyWatchlistItems,
propertySnapshots,
propertyChanges,
alerts,
normalizedAlerts,
} from "~/server/db/schema";
import {
detectChanges,
type DetectedChange,
type SnapshotData,
} from "./hometitle/change.detector";
import {
geocodeAddress,
fetchCountyRecords,
parseAddress,
getLastSnapshot,
} from "./hometitle/scanner";
async function getSubscription(userId: string) {
const [sub] = await db
.select()
.from(subscriptions)
.where(and(eq(subscriptions.userId, userId), eq(subscriptions.status, "active")))
.limit(1);
if (!sub) {
throw new TRPCError({ code: "NOT_FOUND", message: "No active subscription found" });
}
return sub;
}
export async function getProperties(userId: string) {
const sub = await getSubscription(userId);
const items = await db
.select()
.from(propertyWatchlistItems)
.where(
and(
eq(propertyWatchlistItems.subscriptionId, sub.id),
eq(propertyWatchlistItems.isActive, true),
),
)
.orderBy(desc(propertyWatchlistItems.createdAt));
return items;
}
export async function addProperty(
userId: string,
address: string,
parcelId?: string,
ownerName?: string,
) {
const sub = await getSubscription(userId);
const parsed = parseAddress(address);
const coords = await geocodeAddress(address);
const [inserted] = await db
.insert(propertyWatchlistItems)
.values({
subscriptionId: sub.id,
address,
parcelId: parcelId ?? null,
ownerName: ownerName ?? null,
streetAddress: parsed.streetAddress,
city: parsed.city,
state: parsed.state,
zipCode: parsed.zipCode,
latitude: coords?.latitude ?? null,
longitude: coords?.longitude ?? null,
})
.returning();
await db.insert(propertySnapshots).values({
propertyWatchlistItemId: inserted.id,
subscriptionId: sub.id,
capturedAt: new Date(),
ownerName: ownerName ?? "Unknown",
address: { full: address, ...parsed },
deedDate: null,
taxId: null,
propertyType: "residential",
taxAmount: null,
lienCount: 0,
});
return inserted;
}
export async function removeProperty(userId: string, propertyId: string) {
const sub = await getSubscription(userId);
const [item] = await db
.select()
.from(propertyWatchlistItems)
.where(
and(
eq(propertyWatchlistItems.id, propertyId),
eq(propertyWatchlistItems.subscriptionId, sub.id),
),
)
.limit(1);
if (!item) {
throw new TRPCError({ code: "NOT_FOUND", message: "Property not found" });
}
const [deleted] = await db
.update(propertyWatchlistItems)
.set({ isActive: false, updatedAt: new Date() })
.where(eq(propertyWatchlistItems.id, propertyId))
.returning();
return deleted;
}
export async function getSnapshots(userId: string, propertyId: string) {
const sub = await getSubscription(userId);
const [item] = await db
.select()
.from(propertyWatchlistItems)
.where(
and(
eq(propertyWatchlistItems.id, propertyId),
eq(propertyWatchlistItems.subscriptionId, sub.id),
),
)
.limit(1);
if (!item) {
throw new TRPCError({ code: "NOT_FOUND", message: "Property not found" });
}
const snapshots = await db
.select()
.from(propertySnapshots)
.where(eq(propertySnapshots.propertyWatchlistItemId, propertyId))
.orderBy(desc(propertySnapshots.capturedAt));
return snapshots;
}
export async function getChanges(
userId: string,
propertyId: string,
filters?: { severity?: string; changeType?: string },
) {
const sub = await getSubscription(userId);
const [item] = await db
.select()
.from(propertyWatchlistItems)
.where(
and(
eq(propertyWatchlistItems.id, propertyId),
eq(propertyWatchlistItems.subscriptionId, sub.id),
),
)
.limit(1);
if (!item) {
throw new TRPCError({ code: "NOT_FOUND", message: "Property not found" });
}
const conditions = [eq(propertyChanges.propertyWatchlistItemId, propertyId)];
if (filters?.severity) {
conditions.push(
eq(
propertyChanges.severity,
filters.severity as "info" | "warning" | "critical",
),
);
}
if (filters?.changeType) {
conditions.push(
eq(
propertyChanges.changeType,
filters.changeType as
| "tax_change"
| "deed_change"
| "ownership_transfer"
| "lien_filing"
| "metadata_change",
),
);
}
const items = await db
.select()
.from(propertyChanges)
.where(and(...conditions))
.orderBy(desc(propertyChanges.detectedAt));
return items;
}
export async function getAlerts(userId: string) {
const sub = await getSubscription(userId);
const items = await db
.select()
.from(propertyChanges)
.where(
and(
eq(propertyChanges.severity, "warning"),
eq(propertyChanges.propertyWatchlistItemId, sub.id),
),
)
.orderBy(desc(propertyChanges.detectedAt));
const propertyIds = [...new Set(items.map((c) => c.propertyWatchlistItemId))];
const properties = await Promise.all(
propertyIds.map((pid) =>
db
.select({ id: propertyWatchlistItems.id, address: propertyWatchlistItems.address })
.from(propertyWatchlistItems)
.where(eq(propertyWatchlistItems.id, pid))
.limit(1)
.then((r) => r[0]),
),
);
const propertyMap = new Map(properties.filter(Boolean).map((p) => [p.id, p.address]));
const criticalItems = await db
.select()
.from(propertyChanges)
.where(
and(
eq(propertyChanges.severity, "critical"),
eq(propertyChanges.propertyWatchlistItemId, sub.id),
),
)
.orderBy(desc(propertyChanges.detectedAt));
const allChanges = [...criticalItems, ...items];
return allChanges.map((c) => ({
...c,
propertyAddress: propertyMap.get(c.propertyWatchlistItemId) ?? null,
}));
}
async function checkTierLimits(userId: string): Promise<{ allowed: boolean; reason?: string }> {
const sub = await getSubscription(userId);
const tier = sub.tier;
if (tier === "premium") {
return { allowed: true };
}
const maxScans: Record<string, number> = {
basic: 1,
plus: 4,
};
const maxScanCount = maxScans[tier] ?? 1;
const periodStart =
tier === "plus"
? new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)
: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
const [result] = await db
.select({ count: count() })
.from(propertySnapshots)
.where(
and(
eq(propertySnapshots.subscriptionId, sub.id),
gte(propertySnapshots.capturedAt, periodStart),
),
);
if (result.count >= maxScanCount) {
const periodLabel = tier === "plus" ? "week" : "month";
return {
allowed: false,
reason: `Scan limit reached: ${maxScanCount} per ${periodLabel} for ${tier} tier`,
};
}
return { allowed: true };
}
export async function runScan(userId: string): Promise<{ scanId: string }> {
const sub = await getSubscription(userId);
const tierCheck = await checkTierLimits(userId);
if (!tierCheck.allowed) {
throw new TRPCError({ code: "TOO_MANY_REQUESTS", message: tierCheck.reason });
}
const items = await db
.select()
.from(propertyWatchlistItems)
.where(
and(
eq(propertyWatchlistItems.subscriptionId, sub.id),
eq(propertyWatchlistItems.isActive, true),
),
);
const scanId = crypto.randomUUID();
for (const item of items) {
try {
const lastSnapshot = await getLastSnapshot(item.id);
const county = item.state || "Unknown";
const currentRecord = await fetchCountyRecords(item.parcelId, county, item.state ?? "");
if (!currentRecord) continue;
const newData: SnapshotData = {
ownerName: currentRecord.ownerName,
deedDate: currentRecord.deedDate,
taxAmount: currentRecord.taxAmount,
lienCount: currentRecord.lienCount,
propertyType: currentRecord.propertyType,
taxId: currentRecord.taxId,
};
const changes = detectChanges(
lastSnapshot
? {
ownerName: lastSnapshot.ownerName,
deedDate: lastSnapshot.deedDate,
taxAmount: lastSnapshot.taxAmount,
lienCount: lastSnapshot.lienCount,
propertyType: lastSnapshot.propertyType,
taxId: lastSnapshot.taxId,
}
: null,
newData,
);
const [snapshot] = await db
.insert(propertySnapshots)
.values({
propertyWatchlistItemId: item.id,
subscriptionId: sub.id,
capturedAt: new Date(),
ownerName: currentRecord.ownerName,
address: currentRecord.address,
deedDate: currentRecord.deedDate,
taxId: currentRecord.taxId,
propertyType: currentRecord.propertyType,
taxAmount: currentRecord.taxAmount,
lienCount: currentRecord.lienCount,
})
.returning();
for (const change of changes) {
await createPropertyChange(sub, item.id, snapshot.id, change);
}
} catch (err) {
console.error(`[hometitle] Scan failed for property ${item.id}:`, err);
}
}
return { scanId };
}
async function createPropertyChange(
sub: { id: string; userId: string },
propertyWatchlistItemId: string,
snapshotId: string,
change: DetectedChange,
) {
const [inserted] = await db
.insert(propertyChanges)
.values({
propertyWatchlistItemId,
snapshotId,
changeType: change.changeType,
severity: change.severity,
details: change.details,
})
.returning();
if (change.severity === "warning" || change.severity === "critical") {
await generateAlert(sub, propertyWatchlistItemId, inserted, change);
}
return inserted;
}
async function generateAlert(
sub: { id: string; userId: string },
propertyWatchlistItemId: string,
change: { id: string; severity: string; changeType: string; details: unknown },
detectedChange: DetectedChange,
) {
const [property] = await db
.select({ address: propertyWatchlistItems.address })
.from(propertyWatchlistItems)
.where(eq(propertyWatchlistItems.id, propertyWatchlistItemId))
.limit(1);
const severityLabel =
change.severity === "critical" ? "Critical" : change.severity === "warning" ? "Warning" : "Info";
const changeLabels: Record<string, string> = {
ownership_transfer: "Ownership Transfer",
lien_filing: "Lien Filing",
tax_change: "Tax Change",
deed_change: "Deed Change",
metadata_change: "Metadata Change",
};
const changeLabel = changeLabels[detectedChange.changeType] ?? detectedChange.changeType;
const propertyAddress = property?.address ?? "Unknown";
const title = `${severityLabel} property change detected`;
const message = `${changeLabel} at ${propertyAddress}`;
const [alert] = await db
.insert(alerts)
.values({
subscriptionId: sub.id,
userId: sub.userId,
type: "property_change",
title,
message,
severity: change.severity as "info" | "warning" | "critical",
channel: ["email", "push"],
})
.returning();
const severityMap: Record<string, "LOW" | "INFO" | "MEDIUM" | "WARNING" | "HIGH" | "CRITICAL"> = {
info: "INFO",
warning: "WARNING",
critical: "CRITICAL",
};
await db
.insert(normalizedAlerts)
.values({
source: "HOME_TITLE",
category: "HOME_TITLE",
severity: severityMap[change.severity] ?? "INFO",
userId: sub.userId,
title,
description: message,
entities: {
propertyWatchlistItemId,
changeId: change.id,
propertyAddress,
changeType: detectedChange.changeType,
details: detectedChange.details,
},
sourceAlertId: `hometitle:${change.id}`,
createdAt: new Date(),
})
.returning();
return alert;
}

View File

@@ -0,0 +1,139 @@
function levenshteinDistance(a: string, b: string): number {
const an = a.length;
const bn = b.length;
const matrix: number[][] = [];
for (let i = 0; i <= an; i++) {
matrix[i] = [i];
}
for (let j = 0; j <= bn; j++) {
matrix[0][j] = j;
}
for (let i = 1; i <= an; i++) {
for (let j = 1; j <= bn; j++) {
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
matrix[i][j] = Math.min(
matrix[i - 1][j] + 1,
matrix[i][j - 1] + 1,
matrix[i - 1][j - 1] + cost,
);
}
}
return matrix[an][bn];
}
export function fuzzyMatchNames(name1: string, name2: string): { match: boolean; distance: number; similarity: number } {
const n1 = name1.toLowerCase().trim();
const n2 = name2.toLowerCase().trim();
const distance = levenshteinDistance(n1, n2);
const maxLen = Math.max(n1.length, n2.length);
const similarity = maxLen > 0 ? 1 - distance / maxLen : 1;
return { match: similarity >= 0.8, distance, similarity };
}
export type ChangeType = "ownership_transfer" | "lien_filing" | "tax_change" | "deed_change" | "metadata_change";
export type SeverityLevel = "info" | "warning" | "critical";
export interface SnapshotData {
ownerName: string;
deedDate?: string | null;
taxAmount?: number | null;
lienCount: number;
propertyType?: string;
taxId?: string | null;
}
export interface DetectedChange {
changeType: ChangeType;
severity: SeverityLevel;
details: Record<string, unknown>;
}
export function severityForChange(changeType: ChangeType, magnitude: number): SeverityLevel {
switch (changeType) {
case "ownership_transfer":
return "critical";
case "lien_filing":
return magnitude > 0 ? "critical" : "warning";
case "tax_change":
return Math.abs(magnitude) > 0.2 ? "warning" : "info";
case "deed_change":
return "warning";
case "metadata_change":
return "info";
}
}
export function detectChanges(oldSnapshot: SnapshotData | null, newData: SnapshotData): DetectedChange[] {
const changes: DetectedChange[] = [];
if (!oldSnapshot) {
return changes;
}
const ownerMatch = fuzzyMatchNames(oldSnapshot.ownerName, newData.ownerName);
if (!ownerMatch.match) {
changes.push({
changeType: "ownership_transfer",
severity: severityForChange("ownership_transfer", 0),
details: {
previousOwner: oldSnapshot.ownerName,
newOwner: newData.ownerName,
nameSimilarity: ownerMatch.similarity,
},
});
}
if (newData.lienCount > oldSnapshot.lienCount) {
changes.push({
changeType: "lien_filing",
severity: severityForChange("lien_filing", newData.lienCount - oldSnapshot.lienCount),
details: {
previousLienCount: oldSnapshot.lienCount,
newLienCount: newData.lienCount,
newLiens: newData.lienCount - oldSnapshot.lienCount,
},
});
}
if (oldSnapshot.deedDate !== newData.deedDate && newData.deedDate) {
changes.push({
changeType: "deed_change",
severity: severityForChange("deed_change", 0),
details: {
previousDeedDate: oldSnapshot.deedDate,
newDeedDate: newData.deedDate,
},
});
}
if (oldSnapshot.taxAmount !== newData.taxAmount && newData.taxAmount != null && oldSnapshot.taxAmount != null) {
const magnitude = (newData.taxAmount - oldSnapshot.taxAmount) / oldSnapshot.taxAmount;
changes.push({
changeType: "tax_change",
severity: severityForChange("tax_change", magnitude),
details: {
previousTaxAmount: oldSnapshot.taxAmount,
newTaxAmount: newData.taxAmount,
changePercent: magnitude,
},
});
}
if (oldSnapshot.propertyType !== newData.propertyType || oldSnapshot.taxId !== newData.taxId) {
changes.push({
changeType: "metadata_change",
severity: severityForChange("metadata_change", 0),
details: {
previousPropertyType: oldSnapshot.propertyType,
newPropertyType: newData.propertyType,
previousTaxId: oldSnapshot.taxId,
newTaxId: newData.taxId,
},
});
}
return changes;
}

View File

@@ -0,0 +1,99 @@
import { db } from "~/server/db";
import { propertyWatchlistItems } from "~/server/db/schema";
import type { SnapshotData } from "./change.detector";
export interface CountyRecord extends SnapshotData {
address: Record<string, string>;
deedDate: string | null;
taxId: string | null;
}
export async function fetchCountyRecords(
parcelId: string | null,
_county: string,
_state: string,
): Promise<CountyRecord | null> {
console.log(`[hometitle] fetchCountyRecords called for parcelId=${parcelId}`);
if (!parcelId) {
return null;
}
return {
ownerName: "Unknown Owner",
address: { street: "", city: "", state: "", zip: "" },
deedDate: null,
taxId: null,
propertyType: "residential",
taxAmount: null,
lienCount: 0,
};
}
export function parseDeedRecords(_html: string): CountyRecord | null {
console.log("[hometitle] parseDeedRecords called - not yet implemented for HTML parsing");
return null;
}
export async function geocodeAddress(
address: string,
): Promise<{ latitude: number; longitude: number } | null> {
console.log(`[hometitle] geocodeAddress called for address=${address}`);
if (!process.env.GEOCODING_API_KEY) {
return null;
}
try {
const url = `https://maps.googleapis.com/maps/api/geocode/json?address=${encodeURIComponent(address)}&key=${process.env.GEOCODING_API_KEY}`;
const res = await fetch(url);
const data = await res.json() as { results?: Array<{ geometry: { location: { lat: number; lng: number } } }>; status: string };
if (data.status === "OK" && data.results?.[0]?.geometry?.location) {
const { lat, lng } = data.results[0].geometry.location;
return { latitude: lat, longitude: lng };
}
console.warn(`[hometitle] Geocoding failed for address: ${address}, status: ${data.status}`);
return null;
} catch (err) {
console.error("[hometitle] Geocoding error:", err);
return null;
}
}
export async function getLastSnapshot(propertyWatchlistItemId: string) {
const { propertySnapshots } = await import("~/server/db/schema");
const { desc, eq, and } = await import("drizzle-orm");
const [snapshot] = await db
.select()
.from(propertySnapshots)
.where(eq(propertySnapshots.propertyWatchlistItemId, propertyWatchlistItemId))
.orderBy(desc(propertySnapshots.capturedAt))
.limit(1);
return snapshot ?? null;
}
export function parseAddress(address: string): { streetAddress: string; city: string; state: string; zipCode: string } {
const parts = address.split(",").map((p) => p.trim());
const streetAddress = parts[0] ?? "";
let city = "";
let state = "";
let zipCode = "";
if (parts.length >= 2) {
const lastPart = parts[parts.length - 1] ?? "";
const stateZip = lastPart.split(" ").filter(Boolean);
if (stateZip.length >= 2) {
state = stateZip[0] ?? "";
zipCode = stateZip[stateZip.length - 1] ?? "";
}
if (parts.length >= 3) {
city = parts[parts.length - 2] ?? "";
}
}
return { streetAddress, city, state, zipCode };
}