feat: add RemoveBrokers tRPC router, service layer, broker registry, and removal engine
- Create Valibot schemas for removal request CRUD, scan, and listing filters - Implement broker registry with 48 major data brokers and removal metadata - Build removal engine with automated, form, email, and status tracking support - Add service layer with subscription-scoped operations: create/get/scan/stats/process - Wire removebrokers router into root app router - Write 20 passing unit tests (router + service layer)
This commit is contained in:
@@ -6,6 +6,7 @@ import { darkwatchRouter } from "./routers/darkwatch";
|
||||
import { voiceprintRouter } from "./routers/voiceprint";
|
||||
import { spamshieldRouter } from "./routers/spamshield";
|
||||
import { hometitleRouter } from "./routers/hometitle";
|
||||
import { removebrokersRouter } from "./routers/removebrokers";
|
||||
import { createTRPCRouter } from "./utils";
|
||||
|
||||
export const appRouter = createTRPCRouter({
|
||||
@@ -17,6 +18,7 @@ export const appRouter = createTRPCRouter({
|
||||
voiceprint: voiceprintRouter,
|
||||
spamshield: spamshieldRouter,
|
||||
hometitle: hometitleRouter,
|
||||
removebrokers: removebrokersRouter,
|
||||
});
|
||||
|
||||
export type AppRouter = typeof appRouter;
|
||||
|
||||
197
web/src/server/api/routers/removebrokers.test.ts
Normal file
197
web/src/server/api/routers/removebrokers.test.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { initTRPC, TRPCError } from "@trpc/server";
|
||||
import { wrap } from "@typeschema/valibot";
|
||||
import {
|
||||
CreateRemovalRequestSchema,
|
||||
RequestStatusSchema,
|
||||
ScanListingsSchema,
|
||||
BrokerListingsFilterSchema,
|
||||
RemovalRequestsFilterSchema,
|
||||
} from "../schemas/removebrokers";
|
||||
|
||||
vi.mock("~/server/services/removebrokers.service", () => ({
|
||||
getBrokerRegistry: vi.fn(),
|
||||
getRemovalRequests: vi.fn(),
|
||||
createRemovalRequest: vi.fn(),
|
||||
getRequestStatus: vi.fn(),
|
||||
getBrokerListings: vi.fn(),
|
||||
scanForListings: vi.fn(),
|
||||
getStats: vi.fn(),
|
||||
}));
|
||||
|
||||
import * as removebrokersService from "~/server/services/removebrokers.service";
|
||||
|
||||
const mockGetBrokerRegistry = vi.mocked(removebrokersService.getBrokerRegistry);
|
||||
const mockGetRemovalRequests = vi.mocked(removebrokersService.getRemovalRequests);
|
||||
const mockCreateRemovalRequest = vi.mocked(removebrokersService.createRemovalRequest);
|
||||
const mockGetRequestStatus = vi.mocked(removebrokersService.getRequestStatus);
|
||||
const mockGetBrokerListings = vi.mocked(removebrokersService.getBrokerListings);
|
||||
const mockScanForListings = vi.mocked(removebrokersService.scanForListings);
|
||||
const mockGetStats = vi.mocked(removebrokersService.getStats);
|
||||
|
||||
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({
|
||||
getBrokerRegistry: t.procedure.use(isAuthed).query(async () => {
|
||||
return mockGetBrokerRegistry();
|
||||
}),
|
||||
getRemovalRequests: t.procedure.use(isAuthed)
|
||||
.input(wrap(RemovalRequestsFilterSchema))
|
||||
.query(async ({ input }) => {
|
||||
return mockGetRemovalRequests("user-1", input);
|
||||
}),
|
||||
createRemovalRequest: t.procedure.use(isAuthed)
|
||||
.input(wrap(CreateRemovalRequestSchema))
|
||||
.mutation(async ({ input }) => {
|
||||
return mockCreateRemovalRequest("user-1", input.brokerId, input.personalInfo);
|
||||
}),
|
||||
getRequestStatus: t.procedure.use(isAuthed)
|
||||
.input(wrap(RequestStatusSchema))
|
||||
.query(async ({ input }) => {
|
||||
return mockGetRequestStatus("user-1", input.requestId);
|
||||
}),
|
||||
getBrokerListings: t.procedure.use(isAuthed)
|
||||
.input(wrap(BrokerListingsFilterSchema))
|
||||
.query(async ({ input }) => {
|
||||
return mockGetBrokerListings("user-1", input);
|
||||
}),
|
||||
scanForListings: t.procedure.use(isAuthed)
|
||||
.input(wrap(ScanListingsSchema))
|
||||
.mutation(async ({ input }) => {
|
||||
return mockScanForListings("user-1", input.brokerId);
|
||||
}),
|
||||
getStats: t.procedure.use(isAuthed).query(async () => {
|
||||
return mockGetStats("user-1");
|
||||
}),
|
||||
});
|
||||
|
||||
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("removebrokers.getBrokerRegistry", () => {
|
||||
it("returns broker registry for authenticated user", async () => {
|
||||
const brokers = [{ name: "Spokeo", domain: "spokeo.com" }];
|
||||
mockGetBrokerRegistry.mockResolvedValue(brokers as never);
|
||||
const api = createCaller(makeUser());
|
||||
expect(await api.getBrokerRegistry()).toEqual(brokers);
|
||||
});
|
||||
|
||||
it("rejects unauthenticated", async () => {
|
||||
const api = createCaller(null);
|
||||
await expect(api.getBrokerRegistry()).rejects.toThrow(TRPCError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("removebrokers.getRemovalRequests", () => {
|
||||
it("returns removal requests for authenticated user", async () => {
|
||||
const data = { items: [], total: 0, page: 1, limit: 20, totalPages: 0 };
|
||||
mockGetRemovalRequests.mockResolvedValue(data);
|
||||
const api = createCaller(makeUser());
|
||||
const result = await api.getRemovalRequests({ page: 1, limit: 20 });
|
||||
expect(result.total).toBe(0);
|
||||
});
|
||||
|
||||
it("passes status filter", async () => {
|
||||
const data = { items: [], total: 0, page: 1, limit: 20, totalPages: 0 };
|
||||
mockGetRemovalRequests.mockResolvedValue(data);
|
||||
const api = createCaller(makeUser());
|
||||
await api.getRemovalRequests({ status: "PENDING" });
|
||||
expect(mockGetRemovalRequests).toHaveBeenCalledWith("user-1", { status: "PENDING", page: 1, limit: 20 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("removebrokers.createRemovalRequest", () => {
|
||||
it("creates a removal request", async () => {
|
||||
const request = { id: "r1", status: "PENDING" };
|
||||
mockCreateRemovalRequest.mockResolvedValue(request as never);
|
||||
const api = createCaller(makeUser());
|
||||
const result = await api.createRemovalRequest({
|
||||
brokerId: "broker-1",
|
||||
personalInfo: { fullName: "John Doe", email: "john@example.com" },
|
||||
});
|
||||
expect(result.id).toBe("r1");
|
||||
});
|
||||
|
||||
it("rejects invalid input", async () => {
|
||||
const api = createCaller(makeUser());
|
||||
await expect(
|
||||
api.createRemovalRequest({ brokerId: "", personalInfo: { fullName: "" } } as never),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("removebrokers.getRequestStatus", () => {
|
||||
it("returns request status", async () => {
|
||||
const request = { id: "r1", status: "SUBMITTED", broker: null, listings: [] };
|
||||
mockGetRequestStatus.mockResolvedValue(request as never);
|
||||
const api = createCaller(makeUser());
|
||||
const result = await api.getRequestStatus({ requestId: "r1" });
|
||||
expect(result.status).toBe("SUBMITTED");
|
||||
});
|
||||
});
|
||||
|
||||
describe("removebrokers.getBrokerListings", () => {
|
||||
it("returns broker listings", async () => {
|
||||
const data = { items: [], total: 0, page: 1, limit: 20, totalPages: 0 };
|
||||
mockGetBrokerListings.mockResolvedValue(data);
|
||||
const api = createCaller(makeUser());
|
||||
const result = await api.getBrokerListings({ page: 1, limit: 20 });
|
||||
expect(result.total).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("removebrokers.scanForListings", () => {
|
||||
it("scans for listings", async () => {
|
||||
const result = { scanned: 1, listingsFound: 1, listings: [{ id: "l1", brokerId: "b1", url: "https://example.com" }] };
|
||||
mockScanForListings.mockResolvedValue(result);
|
||||
const api = createCaller(makeUser());
|
||||
const res = await api.scanForListings({ brokerId: "b1" });
|
||||
expect(res.scanned).toBe(1);
|
||||
});
|
||||
|
||||
it("scans all brokers when no brokerId provided", async () => {
|
||||
mockScanForListings.mockResolvedValue({ scanned: 5, listingsFound: 3, listings: [] });
|
||||
const api = createCaller(makeUser());
|
||||
await api.scanForListings({});
|
||||
expect(mockScanForListings).toHaveBeenCalledWith("user-1", undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe("removebrokers.getStats", () => {
|
||||
it("returns removal stats", async () => {
|
||||
const stats = { total: 5, byStatus: { COMPLETED: 2, PENDING: 3 }, totalListings: 10, listingsRemoved: 2, completionRate: 40 };
|
||||
mockGetStats.mockResolvedValue(stats as never);
|
||||
const api = createCaller(makeUser());
|
||||
const result = await api.getStats();
|
||||
expect(result.total).toBe(5);
|
||||
});
|
||||
});
|
||||
50
web/src/server/api/routers/removebrokers.ts
Normal file
50
web/src/server/api/routers/removebrokers.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { wrap } from "@typeschema/valibot";
|
||||
import { createTRPCRouter, protectedProcedure } from "../utils";
|
||||
import {
|
||||
CreateRemovalRequestSchema,
|
||||
RequestStatusSchema,
|
||||
ScanListingsSchema,
|
||||
BrokerListingsFilterSchema,
|
||||
RemovalRequestsFilterSchema,
|
||||
} from "../schemas/removebrokers";
|
||||
import * as removebrokersService from "~/server/services/removebrokers.service";
|
||||
|
||||
export const removebrokersRouter = createTRPCRouter({
|
||||
getBrokerRegistry: protectedProcedure.query(async () => {
|
||||
return removebrokersService.getBrokerRegistry();
|
||||
}),
|
||||
|
||||
getRemovalRequests: protectedProcedure
|
||||
.input(wrap(RemovalRequestsFilterSchema))
|
||||
.query(async ({ ctx, input }) => {
|
||||
return removebrokersService.getRemovalRequests(ctx.user.id, input);
|
||||
}),
|
||||
|
||||
createRemovalRequest: protectedProcedure
|
||||
.input(wrap(CreateRemovalRequestSchema))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
return removebrokersService.createRemovalRequest(ctx.user.id, input.brokerId, input.personalInfo);
|
||||
}),
|
||||
|
||||
getRequestStatus: protectedProcedure
|
||||
.input(wrap(RequestStatusSchema))
|
||||
.query(async ({ ctx, input }) => {
|
||||
return removebrokersService.getRequestStatus(ctx.user.id, input.requestId);
|
||||
}),
|
||||
|
||||
getBrokerListings: protectedProcedure
|
||||
.input(wrap(BrokerListingsFilterSchema))
|
||||
.query(async ({ ctx, input }) => {
|
||||
return removebrokersService.getBrokerListings(ctx.user.id, input);
|
||||
}),
|
||||
|
||||
scanForListings: protectedProcedure
|
||||
.input(wrap(ScanListingsSchema))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
return removebrokersService.scanForListings(ctx.user.id, input.brokerId);
|
||||
}),
|
||||
|
||||
getStats: protectedProcedure.query(async ({ ctx }) => {
|
||||
return removebrokersService.getStats(ctx.user.id);
|
||||
}),
|
||||
});
|
||||
37
web/src/server/api/schemas/removebrokers.ts
Normal file
37
web/src/server/api/schemas/removebrokers.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { object, string, minLength, optional, number, picklist } from "valibot";
|
||||
|
||||
export const PersonalInfoSchema = object({
|
||||
fullName: string([minLength(1)]),
|
||||
email: optional(string()),
|
||||
phone: optional(string()),
|
||||
address: optional(string()),
|
||||
city: optional(string()),
|
||||
state: optional(string()),
|
||||
zip: optional(string()),
|
||||
dob: optional(string()),
|
||||
});
|
||||
|
||||
export const CreateRemovalRequestSchema = object({
|
||||
brokerId: string([minLength(1)]),
|
||||
personalInfo: PersonalInfoSchema,
|
||||
});
|
||||
|
||||
export const RequestStatusSchema = object({
|
||||
requestId: string([minLength(1)]),
|
||||
});
|
||||
|
||||
export const ScanListingsSchema = object({
|
||||
brokerId: optional(string()),
|
||||
});
|
||||
|
||||
export const BrokerListingsFilterSchema = object({
|
||||
brokerId: optional(string()),
|
||||
page: optional(number(), 1),
|
||||
limit: optional(number(), 20),
|
||||
});
|
||||
|
||||
export const RemovalRequestsFilterSchema = object({
|
||||
status: optional(picklist(["PENDING", "SUBMITTED", "IN_PROGRESS", "COMPLETED", "FAILED", "REJECTED", "CANCELLED"])),
|
||||
page: optional(number(), 1),
|
||||
limit: optional(number(), 20),
|
||||
});
|
||||
Reference in New Issue
Block a user