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({});
|
||||
Reference in New Issue
Block a user