From a3fee924d86621377c1bf854b209e1d91eb5c202 Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Mon, 25 May 2026 16:38:34 -0400 Subject: [PATCH] =?UTF-8?q?feat(hometitle):=20add=20Backend=20Router=20?= =?UTF-8?q?=E2=80=94=20HomeTitle=20(Property=20Monitoring)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- web/src/server/api/root.ts | 2 + web/src/server/api/routers/hometitle.test.ts | 188 +++++++ web/src/server/api/routers/hometitle.ts | 53 ++ web/src/server/api/schemas/hometitle.ts | 23 + web/src/server/db/schema/enums.ts | 2 +- web/src/server/services/hometitle.service.ts | 458 ++++++++++++++++++ .../services/hometitle/change.detector.ts | 139 ++++++ web/src/server/services/hometitle/scanner.ts | 99 ++++ 8 files changed, 963 insertions(+), 1 deletion(-) create mode 100644 web/src/server/api/routers/hometitle.test.ts create mode 100644 web/src/server/api/routers/hometitle.ts create mode 100644 web/src/server/api/schemas/hometitle.ts create mode 100644 web/src/server/services/hometitle.service.ts create mode 100644 web/src/server/services/hometitle/change.detector.ts create mode 100644 web/src/server/services/hometitle/scanner.ts diff --git a/web/src/server/api/root.ts b/web/src/server/api/root.ts index 2f7f70a..64ec1ee 100644 --- a/web/src/server/api/root.ts +++ b/web/src/server/api/root.ts @@ -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; diff --git a/web/src/server/api/routers/hometitle.test.ts b/web/src/server/api/routers/hometitle.test.ts new file mode 100644 index 0000000..683988a --- /dev/null +++ b/web/src/server/api/routers/hometitle.test.ts @@ -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().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 { + 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); + }); +}); diff --git a/web/src/server/api/routers/hometitle.ts b/web/src/server/api/routers/hometitle.ts new file mode 100644 index 0000000..df278e0 --- /dev/null +++ b/web/src/server/api/routers/hometitle.ts @@ -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); + }), +}); diff --git a/web/src/server/api/schemas/hometitle.ts b/web/src/server/api/schemas/hometitle.ts new file mode 100644 index 0000000..98b3ed9 --- /dev/null +++ b/web/src/server/api/schemas/hometitle.ts @@ -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({}); diff --git a/web/src/server/db/schema/enums.ts b/web/src/server/db/schema/enums.ts index 2dec567..a64986f 100644 --- a/web/src/server/db/schema/enums.ts +++ b/web/src/server/db/schema/enums.ts @@ -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"]); diff --git a/web/src/server/services/hometitle.service.ts b/web/src/server/services/hometitle.service.ts new file mode 100644 index 0000000..4c3e407 --- /dev/null +++ b/web/src/server/services/hometitle.service.ts @@ -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 = { + 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 = { + 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 = { + 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; +} diff --git a/web/src/server/services/hometitle/change.detector.ts b/web/src/server/services/hometitle/change.detector.ts new file mode 100644 index 0000000..99123df --- /dev/null +++ b/web/src/server/services/hometitle/change.detector.ts @@ -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; +} + +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; +} diff --git a/web/src/server/services/hometitle/scanner.ts b/web/src/server/services/hometitle/scanner.ts new file mode 100644 index 0000000..c3f9488 --- /dev/null +++ b/web/src/server/services/hometitle/scanner.ts @@ -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; + deedDate: string | null; + taxId: string | null; +} + +export async function fetchCountyRecords( + parcelId: string | null, + _county: string, + _state: string, +): Promise { + 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 }; +}