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:
@@ -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;
|
||||
|
||||
188
web/src/server/api/routers/hometitle.test.ts
Normal file
188
web/src/server/api/routers/hometitle.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
53
web/src/server/api/routers/hometitle.ts
Normal file
53
web/src/server/api/routers/hometitle.ts
Normal 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);
|
||||
}),
|
||||
});
|
||||
23
web/src/server/api/schemas/hometitle.ts
Normal file
23
web/src/server/api/schemas/hometitle.ts
Normal 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({});
|
||||
@@ -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"]);
|
||||
|
||||
458
web/src/server/services/hometitle.service.ts
Normal file
458
web/src/server/services/hometitle.service.ts
Normal 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;
|
||||
}
|
||||
139
web/src/server/services/hometitle/change.detector.ts
Normal file
139
web/src/server/services/hometitle/change.detector.ts
Normal 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;
|
||||
}
|
||||
99
web/src/server/services/hometitle/scanner.ts
Normal file
99
web/src/server/services/hometitle/scanner.ts
Normal 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user