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),
|
||||
});
|
||||
237
web/src/server/services/removebrokers.service.test.ts
Normal file
237
web/src/server/services/removebrokers.service.test.ts
Normal file
@@ -0,0 +1,237 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
const mockInsertValuesReturning = vi.fn();
|
||||
const mockSelectFromWhereLimit = vi.fn();
|
||||
const mockSelectFromWhereOrderByLimitOffset = vi.fn();
|
||||
const mockSelectFromWhereOrderBy = vi.fn();
|
||||
const mockCountSelectFromWhere = vi.fn();
|
||||
const mockSubFindFirst = vi.fn();
|
||||
const mockUpdateSetWhereReturning = vi.fn();
|
||||
|
||||
vi.mock("~/server/db", () => ({
|
||||
db: {
|
||||
query: {
|
||||
subscriptions: {
|
||||
findFirst: mockSubFindFirst,
|
||||
},
|
||||
},
|
||||
select: vi.fn((...args: unknown[]) => {
|
||||
const firstArg = args[0] as { count?: unknown } | undefined;
|
||||
const isCount = args.length > 0 && firstArg?.count;
|
||||
|
||||
if (isCount) {
|
||||
return {
|
||||
from: vi.fn(() => ({
|
||||
where: vi.fn(() => mockCountSelectFromWhere()),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
from: vi.fn(() => ({
|
||||
where: vi.fn(() => ({
|
||||
limit: mockSelectFromWhereLimit,
|
||||
orderBy: vi.fn(() => ({
|
||||
limit: vi.fn(() => ({
|
||||
offset: mockSelectFromWhereOrderByLimitOffset,
|
||||
})),
|
||||
})),
|
||||
innerJoin: vi.fn(() => ({
|
||||
where: vi.fn(() => ({
|
||||
limit: vi.fn().mockResolvedValue([]),
|
||||
})),
|
||||
})),
|
||||
})),
|
||||
orderBy: mockSelectFromWhereOrderBy,
|
||||
})),
|
||||
};
|
||||
}),
|
||||
insert: vi.fn(() => ({
|
||||
values: vi.fn(() => ({
|
||||
returning: mockInsertValuesReturning,
|
||||
})),
|
||||
})),
|
||||
update: vi.fn(() => ({
|
||||
set: vi.fn(() => ({
|
||||
where: vi.fn(() => ({
|
||||
returning: mockUpdateSetWhereReturning,
|
||||
})),
|
||||
})),
|
||||
})),
|
||||
},
|
||||
}));
|
||||
|
||||
const mockSub = {
|
||||
id: "sub-1",
|
||||
userId: "user-1",
|
||||
tier: "premium" as const,
|
||||
status: "active" as const,
|
||||
stripeId: null as string | null,
|
||||
familyGroupId: null as string | null,
|
||||
currentPeriodStart: new Date(),
|
||||
currentPeriodEnd: new Date(Date.now() + 86400000),
|
||||
cancelAtPeriodEnd: false,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockSelectFromWhereLimit.mockResolvedValue([mockSub]);
|
||||
});
|
||||
|
||||
describe("getBrokerRegistry", () => {
|
||||
it("returns active brokers with metadata", async () => {
|
||||
const { getBrokerRegistry } = await import("./removebrokers.service");
|
||||
const brokers = await getBrokerRegistry();
|
||||
expect(brokers.length).toBeGreaterThan(20);
|
||||
expect(brokers[0]).toHaveProperty("name");
|
||||
expect(brokers[0]).toHaveProperty("domain");
|
||||
expect(brokers[0]).toHaveProperty("removalMethod");
|
||||
expect(brokers[0]).toHaveProperty("removalUrl");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createRemovalRequest", () => {
|
||||
it("creates a removal request with PENDING status", async () => {
|
||||
mockSubFindFirst.mockResolvedValue(mockSub);
|
||||
mockSelectFromWhereLimit
|
||||
.mockResolvedValueOnce([mockSub])
|
||||
.mockResolvedValueOnce([{ id: "broker-1", name: "Spokeo", domain: "spokeo.com", removalMethod: "MANUAL_FORM", category: "PEOPLE_SEARCH", removalUrl: "https://spokeo.com/optout", requiresAccount: false, requiresVerification: true, estimatedDays: 7, isActive: true }])
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([{
|
||||
id: "req-1", subscriptionId: "sub-1", brokerId: "broker-1",
|
||||
status: "PENDING", personalInfo: {}, method: "MANUAL_FORM",
|
||||
attempts: 0, createdAt: new Date(), updatedAt: new Date(),
|
||||
}]);
|
||||
mockInsertValuesReturning.mockResolvedValue([{
|
||||
id: "req-1", subscriptionId: "sub-1", brokerId: "broker-1",
|
||||
status: "PENDING", personalInfo: {}, method: "MANUAL_FORM",
|
||||
attempts: 0, createdAt: new Date(), updatedAt: new Date(),
|
||||
}]);
|
||||
|
||||
const { createRemovalRequest } = await import("./removebrokers.service");
|
||||
const result = await createRemovalRequest("user-1", "broker-1", { fullName: "John Doe" });
|
||||
expect(result.status).toBe("PENDING");
|
||||
});
|
||||
|
||||
it("throws conflict if active request exists", async () => {
|
||||
mockSubFindFirst.mockResolvedValue(mockSub);
|
||||
mockSelectFromWhereLimit
|
||||
.mockResolvedValueOnce([mockSub])
|
||||
.mockResolvedValueOnce([{ id: "broker-1", name: "Spokeo", domain: "spokeo.com", removalMethod: "MANUAL_FORM", category: "PEOPLE_SEARCH", removalUrl: "https://spokeo.com/optout", requiresAccount: false, requiresVerification: true, estimatedDays: 7, isActive: true }])
|
||||
.mockResolvedValueOnce([{ id: "existing-req", status: "PENDING" }]);
|
||||
|
||||
const { createRemovalRequest } = await import("./removebrokers.service");
|
||||
await expect(
|
||||
createRemovalRequest("user-1", "broker-1", { fullName: "John Doe" }),
|
||||
).rejects.toThrow(TRPCError);
|
||||
});
|
||||
|
||||
it("throws not found for inactive broker", async () => {
|
||||
mockSubFindFirst.mockResolvedValue(mockSub);
|
||||
mockSelectFromWhereLimit
|
||||
.mockResolvedValueOnce([mockSub])
|
||||
.mockResolvedValueOnce([]);
|
||||
|
||||
const { createRemovalRequest } = await import("./removebrokers.service");
|
||||
await expect(
|
||||
createRemovalRequest("user-1", "broker-1", { fullName: "John Doe" }),
|
||||
).rejects.toThrow(TRPCError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getRemovalRequests", () => {
|
||||
it("returns paginated removal requests", async () => {
|
||||
mockSubFindFirst.mockResolvedValue(mockSub);
|
||||
mockSelectFromWhereLimit.mockResolvedValue([mockSub]);
|
||||
mockCountSelectFromWhere.mockResolvedValue([{ count: 1 }]);
|
||||
mockSelectFromWhereOrderByLimitOffset.mockResolvedValue([{
|
||||
id: "req-1", status: "PENDING",
|
||||
createdAt: new Date(), updatedAt: new Date(),
|
||||
}]);
|
||||
|
||||
const { getRemovalRequests } = await import("./removebrokers.service");
|
||||
const result = await getRemovalRequests("user-1", { page: 1, limit: 20 });
|
||||
expect(result.total).toBe(1);
|
||||
expect(result.items).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("scanForListings", () => {
|
||||
it("scans specified broker and creates listing", async () => {
|
||||
mockSubFindFirst.mockResolvedValue(mockSub);
|
||||
mockSelectFromWhereLimit
|
||||
.mockResolvedValueOnce([mockSub])
|
||||
.mockResolvedValueOnce([{ id: "broker-1", name: "Spokeo", domain: "spokeo.com", removalMethod: "MANUAL_FORM", category: "PEOPLE_SEARCH", removalUrl: "https://spokeo.com/optout", requiresAccount: false, requiresVerification: true, estimatedDays: 7, isActive: true }])
|
||||
.mockResolvedValueOnce([]);
|
||||
mockInsertValuesReturning.mockResolvedValue([{
|
||||
id: "listing-1", brokerId: "broker-1", url: "https://spokeo.com/profile/sub-1",
|
||||
dataFound: {}, isRemoved: false,
|
||||
}]);
|
||||
|
||||
const { scanForListings } = await import("./removebrokers.service");
|
||||
const result = await scanForListings("user-1", "broker-1");
|
||||
expect(result.scanned).toBe(1);
|
||||
expect(result.listingsFound).toBe(1);
|
||||
});
|
||||
|
||||
it("scans all active brokers when no brokerId", async () => {
|
||||
mockSubFindFirst.mockResolvedValue(mockSub);
|
||||
mockSelectFromWhereLimit
|
||||
.mockResolvedValueOnce([mockSub])
|
||||
.mockResolvedValueOnce([{ id: "broker-1", name: "Spokeo", domain: "spokeo.com", removalMethod: "MANUAL_FORM", category: "PEOPLE_SEARCH", removalUrl: "https://spokeo.com/optout", requiresAccount: false, requiresVerification: true, estimatedDays: 7, isActive: true }]);
|
||||
mockInsertValuesReturning.mockResolvedValue([{
|
||||
id: "listing-1", brokerId: "broker-1", url: "https://spokeo.com/profile/sub-1",
|
||||
dataFound: {}, isRemoved: false,
|
||||
}]);
|
||||
|
||||
const { scanForListings } = await import("./removebrokers.service");
|
||||
const result = await scanForListings("user-1");
|
||||
expect(result.scanned).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("processRemovals", () => {
|
||||
it("processes pending removal requests", async () => {
|
||||
mockSelectFromWhereLimit
|
||||
.mockResolvedValueOnce([{
|
||||
id: "req-1", subscriptionId: "sub-1", brokerId: "broker-1",
|
||||
status: "PENDING", personalInfo: { fullName: "John" },
|
||||
method: "MANUAL_FORM", attempts: 0,
|
||||
createdAt: new Date(), updatedAt: new Date(),
|
||||
}]);
|
||||
mockUpdateSetWhereReturning
|
||||
.mockResolvedValueOnce([{ id: "req-1", status: "SUBMITTED" }]);
|
||||
|
||||
const { processRemovals } = await import("./removebrokers.service");
|
||||
const result = await processRemovals();
|
||||
expect(result.processed).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getStats", () => {
|
||||
it("returns removal statistics", async () => {
|
||||
mockSubFindFirst.mockResolvedValue(mockSub);
|
||||
mockSelectFromWhereLimit.mockResolvedValue([mockSub]);
|
||||
mockCountSelectFromWhere
|
||||
.mockResolvedValueOnce([{ count: 5 }])
|
||||
.mockResolvedValueOnce([{ count: 2 }])
|
||||
.mockResolvedValueOnce([{ count: 0 }])
|
||||
.mockResolvedValueOnce([{ count: 0 }])
|
||||
.mockResolvedValueOnce([{ count: 2 }])
|
||||
.mockResolvedValueOnce([{ count: 0 }])
|
||||
.mockResolvedValueOnce([{ count: 0 }])
|
||||
.mockResolvedValueOnce([{ count: 1 }])
|
||||
.mockResolvedValueOnce([{ count: 10 }])
|
||||
.mockResolvedValueOnce([{ count: 2 }]);
|
||||
|
||||
const { getStats } = await import("./removebrokers.service");
|
||||
const result = await getStats("user-1");
|
||||
expect(result.total).toBe(5);
|
||||
expect(result.completionRate).toBe(40);
|
||||
expect(result.totalListings).toBe(10);
|
||||
expect(result.listingsRemoved).toBe(2);
|
||||
});
|
||||
});
|
||||
457
web/src/server/services/removebrokers.service.ts
Normal file
457
web/src/server/services/removebrokers.service.ts
Normal file
@@ -0,0 +1,457 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { eq, and, desc, count, inArray, or, isNull, lt } from "drizzle-orm";
|
||||
import { db } from "~/server/db";
|
||||
import { infoBrokers, removalRequests, brokerListings, subscriptions } from "~/server/db/schema";
|
||||
import { getActiveBrokers } from "./removebrokers/broker.registry";
|
||||
import { submitAutomatedRemoval, sendRemovalEmail } from "./removebrokers/removal.engine";
|
||||
import type { PersonalInfo } from "./removebrokers/removal.engine";
|
||||
import type { RemovalMethod } from "./removebrokers/broker.registry";
|
||||
|
||||
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 getBrokerRegistry() {
|
||||
const registry = getActiveBrokers();
|
||||
return registry;
|
||||
}
|
||||
|
||||
export async function getRemovalRequests(
|
||||
userId: string,
|
||||
filters?: { status?: string; page?: number; limit?: number },
|
||||
) {
|
||||
const sub = await getSubscription(userId);
|
||||
const page = filters?.page ?? 1;
|
||||
const limit = filters?.limit ?? 20;
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const conditions = [eq(removalRequests.subscriptionId, sub.id)];
|
||||
if (filters?.status) {
|
||||
conditions.push(eq(removalRequests.status, filters.status as "PENDING" | "SUBMITTED" | "IN_PROGRESS" | "COMPLETED" | "FAILED" | "REJECTED" | "CANCELLED"));
|
||||
}
|
||||
|
||||
const [totalResult] = await db
|
||||
.select({ count: count() })
|
||||
.from(removalRequests)
|
||||
.where(and(...conditions));
|
||||
|
||||
const items = await db
|
||||
.select()
|
||||
.from(removalRequests)
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(removalRequests.createdAt))
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
|
||||
return {
|
||||
items,
|
||||
total: totalResult.count,
|
||||
page,
|
||||
limit,
|
||||
totalPages: Math.ceil(totalResult.count / limit),
|
||||
};
|
||||
}
|
||||
|
||||
export async function createRemovalRequest(
|
||||
userId: string,
|
||||
brokerId: string,
|
||||
personalInfo: PersonalInfo,
|
||||
) {
|
||||
const sub = await getSubscription(userId);
|
||||
|
||||
const [broker] = await db
|
||||
.select()
|
||||
.from(infoBrokers)
|
||||
.where(and(eq(infoBrokers.id, brokerId), eq(infoBrokers.isActive, true)))
|
||||
.limit(1);
|
||||
|
||||
if (!broker) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Broker not found or inactive" });
|
||||
}
|
||||
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(removalRequests)
|
||||
.where(
|
||||
and(
|
||||
eq(removalRequests.subscriptionId, sub.id),
|
||||
eq(removalRequests.brokerId, brokerId),
|
||||
inArray(removalRequests.status, ["PENDING", "SUBMITTED", "IN_PROGRESS"]),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (existing) {
|
||||
throw new TRPCError({
|
||||
code: "CONFLICT",
|
||||
message: "An active removal request already exists for this broker",
|
||||
});
|
||||
}
|
||||
|
||||
const [request] = await db
|
||||
.insert(removalRequests)
|
||||
.values({
|
||||
subscriptionId: sub.id,
|
||||
brokerId: broker.id,
|
||||
status: "PENDING",
|
||||
personalInfo: personalInfo as unknown as Record<string, unknown>,
|
||||
method: broker.removalMethod as RemovalMethod,
|
||||
})
|
||||
.returning();
|
||||
|
||||
try {
|
||||
if (broker.removalMethod === "AUTOMATED") {
|
||||
const result = await submitAutomatedRemoval(
|
||||
{ name: broker.name, domain: broker.domain, category: broker.category, removalMethod: broker.removalMethod, removalUrl: broker.removalUrl ?? "", requiresAccount: broker.requiresAccount, requiresVerification: broker.requiresVerification, estimatedDays: broker.estimatedDays },
|
||||
personalInfo,
|
||||
);
|
||||
if (result.success) {
|
||||
await updateRequestStatus(request.id, "SUBMITTED", { automatedRequestId: result.requestId });
|
||||
}
|
||||
} else if (broker.removalMethod === "EMAIL") {
|
||||
const result = await sendRemovalEmail(
|
||||
{ name: broker.name, domain: broker.domain, category: broker.category, removalMethod: broker.removalMethod, removalUrl: broker.removalUrl ?? "", requiresAccount: broker.requiresAccount, requiresVerification: broker.requiresVerification, estimatedDays: broker.estimatedDays },
|
||||
personalInfo,
|
||||
);
|
||||
if (result.success) {
|
||||
await updateRequestStatus(request.id, "SUBMITTED", { emailSent: true });
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// Removal initiation failed but request is already created
|
||||
await updateRequestStatus(request.id, "FAILED", {
|
||||
error: err instanceof Error ? err.message : "Failed to initiate removal",
|
||||
});
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.select()
|
||||
.from(removalRequests)
|
||||
.where(eq(removalRequests.id, request.id))
|
||||
.limit(1);
|
||||
|
||||
return updated ?? request;
|
||||
}
|
||||
|
||||
export async function getRequestStatus(userId: string, requestId: string) {
|
||||
const sub = await getSubscription(userId);
|
||||
|
||||
const [request] = await db
|
||||
.select()
|
||||
.from(removalRequests)
|
||||
.where(and(eq(removalRequests.id, requestId), eq(removalRequests.subscriptionId, sub.id)))
|
||||
.limit(1);
|
||||
|
||||
if (!request) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Removal request not found" });
|
||||
}
|
||||
|
||||
const [broker] = await db
|
||||
.select()
|
||||
.from(infoBrokers)
|
||||
.where(eq(infoBrokers.id, request.brokerId))
|
||||
.limit(1);
|
||||
|
||||
const listings = await db
|
||||
.select()
|
||||
.from(brokerListings)
|
||||
.where(
|
||||
or(
|
||||
eq(brokerListings.removalRequestId, requestId),
|
||||
and(
|
||||
eq(brokerListings.subscriptionId, sub.id),
|
||||
eq(brokerListings.brokerId, request.brokerId),
|
||||
),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(brokerListings.scannedAt));
|
||||
|
||||
return {
|
||||
...request,
|
||||
broker: broker ?? null,
|
||||
listings,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getBrokerListings(
|
||||
userId: string,
|
||||
filters?: { brokerId?: string; page?: number; limit?: number },
|
||||
) {
|
||||
const sub = await getSubscription(userId);
|
||||
const page = filters?.page ?? 1;
|
||||
const limit = filters?.limit ?? 20;
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const conditions = [eq(brokerListings.subscriptionId, sub.id)];
|
||||
if (filters?.brokerId) {
|
||||
conditions.push(eq(brokerListings.brokerId, filters.brokerId));
|
||||
}
|
||||
|
||||
const [totalResult] = await db
|
||||
.select({ count: count() })
|
||||
.from(brokerListings)
|
||||
.where(and(...conditions));
|
||||
|
||||
const items = await db
|
||||
.select()
|
||||
.from(brokerListings)
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(brokerListings.scannedAt))
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
|
||||
return {
|
||||
items,
|
||||
total: totalResult.count,
|
||||
page,
|
||||
limit,
|
||||
totalPages: Math.ceil(totalResult.count / limit),
|
||||
};
|
||||
}
|
||||
|
||||
export async function scanForListings(userId: string, brokerId?: string) {
|
||||
const sub = await getSubscription(userId);
|
||||
|
||||
const brokers = brokerId
|
||||
? await db
|
||||
.select()
|
||||
.from(infoBrokers)
|
||||
.where(and(eq(infoBrokers.id, brokerId), eq(infoBrokers.isActive, true)))
|
||||
.limit(1)
|
||||
: await db
|
||||
.select()
|
||||
.from(infoBrokers)
|
||||
.where(eq(infoBrokers.isActive, true))
|
||||
.limit(200);
|
||||
|
||||
if (brokerId && brokers.length === 0) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Broker not found" });
|
||||
}
|
||||
|
||||
const createdListings: Array<{ id: string; brokerId: string; url: string }> = [];
|
||||
|
||||
for (const broker of brokers) {
|
||||
const [existingReq] = await db
|
||||
.select()
|
||||
.from(removalRequests)
|
||||
.where(
|
||||
and(
|
||||
eq(removalRequests.subscriptionId, sub.id),
|
||||
eq(removalRequests.brokerId, broker.id),
|
||||
inArray(removalRequests.status, ["PENDING", "SUBMITTED", "IN_PROGRESS"]),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
const mockUrl = `https://${broker.domain}/profile/${encodeURIComponent(sub.id.slice(0, 8))}`;
|
||||
|
||||
const [listing] = await db
|
||||
.insert(brokerListings)
|
||||
.values({
|
||||
subscriptionId: sub.id,
|
||||
brokerId: broker.id,
|
||||
removalRequestId: existingReq?.id ?? null,
|
||||
url: mockUrl,
|
||||
dataFound: {
|
||||
name: true,
|
||||
email: true,
|
||||
phone: false,
|
||||
address: false,
|
||||
scannedAt: new Date().toISOString(),
|
||||
},
|
||||
})
|
||||
.returning();
|
||||
|
||||
createdListings.push({ id: listing.id, brokerId: listing.brokerId, url: listing.url });
|
||||
}
|
||||
|
||||
return {
|
||||
scanned: brokers.length,
|
||||
listingsFound: createdListings.length,
|
||||
listings: createdListings,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getStats(userId: string) {
|
||||
const sub = await getSubscription(userId);
|
||||
|
||||
const [totalResult] = await db
|
||||
.select({ count: count() })
|
||||
.from(removalRequests)
|
||||
.where(eq(removalRequests.subscriptionId, sub.id));
|
||||
|
||||
const statuses = ["PENDING", "SUBMITTED", "IN_PROGRESS", "COMPLETED", "FAILED", "REJECTED", "CANCELLED"] as const;
|
||||
const statusCounts: Record<string, number> = {};
|
||||
|
||||
for (const status of statuses) {
|
||||
const [result] = await db
|
||||
.select({ count: count() })
|
||||
.from(removalRequests)
|
||||
.where(
|
||||
and(
|
||||
eq(removalRequests.subscriptionId, sub.id),
|
||||
eq(removalRequests.status, status),
|
||||
),
|
||||
);
|
||||
statusCounts[status] = result.count;
|
||||
}
|
||||
|
||||
const [listingsResult] = await db
|
||||
.select({ count: count() })
|
||||
.from(brokerListings)
|
||||
.where(eq(brokerListings.subscriptionId, sub.id));
|
||||
|
||||
const [listingsRemovedResult] = await db
|
||||
.select({ count: count() })
|
||||
.from(brokerListings)
|
||||
.where(
|
||||
and(
|
||||
eq(brokerListings.subscriptionId, sub.id),
|
||||
eq(brokerListings.isRemoved, true),
|
||||
),
|
||||
);
|
||||
|
||||
return {
|
||||
total: totalResult.count,
|
||||
byStatus: statusCounts,
|
||||
totalListings: listingsResult.count,
|
||||
listingsRemoved: listingsRemovedResult.count,
|
||||
completionRate:
|
||||
totalResult.count > 0
|
||||
? Math.round((statusCounts["COMPLETED"] / totalResult.count) * 100)
|
||||
: 0,
|
||||
};
|
||||
}
|
||||
|
||||
export async function processRemovals() {
|
||||
const pending = await db
|
||||
.select()
|
||||
.from(removalRequests)
|
||||
.where(
|
||||
and(
|
||||
inArray(removalRequests.status, ["PENDING", "FAILED"]),
|
||||
or(
|
||||
isNull(removalRequests.nextRetryAt),
|
||||
lt(removalRequests.nextRetryAt, new Date()),
|
||||
),
|
||||
),
|
||||
)
|
||||
.limit(10);
|
||||
|
||||
const results: Array<{ id: string; status: string }> = [];
|
||||
|
||||
for (const request of pending) {
|
||||
try {
|
||||
const [broker] = await db
|
||||
.select()
|
||||
.from(infoBrokers)
|
||||
.where(eq(infoBrokers.id, request.brokerId))
|
||||
.limit(1);
|
||||
|
||||
if (!broker) {
|
||||
await updateRequestStatus(request.id, "FAILED", { error: "Broker not found" });
|
||||
results.push({ id: request.id, status: "FAILED" });
|
||||
continue;
|
||||
}
|
||||
|
||||
const personalInfo = request.personalInfo as unknown as PersonalInfo;
|
||||
|
||||
if (broker.removalMethod === "AUTOMATED") {
|
||||
const result = await submitAutomatedRemoval(
|
||||
{ name: broker.name, domain: broker.domain, category: broker.category, removalMethod: broker.removalMethod, removalUrl: broker.removalUrl ?? "", requiresAccount: broker.requiresAccount, requiresVerification: broker.requiresVerification, estimatedDays: broker.estimatedDays },
|
||||
personalInfo,
|
||||
);
|
||||
if (result.success) {
|
||||
await updateRequestStatus(request.id, "SUBMITTED", { automatedRequestId: result.requestId });
|
||||
results.push({ id: request.id, status: "SUBMITTED" });
|
||||
} else {
|
||||
await incrementRetry(request.id);
|
||||
results.push({ id: request.id, status: "RETRY" });
|
||||
}
|
||||
} else if (broker.removalMethod === "EMAIL") {
|
||||
const result = await sendRemovalEmail(
|
||||
{ name: broker.name, domain: broker.domain, category: broker.category, removalMethod: broker.removalMethod, removalUrl: broker.removalUrl ?? "", requiresAccount: broker.requiresAccount, requiresVerification: broker.requiresVerification, estimatedDays: broker.estimatedDays },
|
||||
personalInfo,
|
||||
);
|
||||
if (result.success) {
|
||||
await updateRequestStatus(request.id, "SUBMITTED", { emailSent: true });
|
||||
results.push({ id: request.id, status: "SUBMITTED" });
|
||||
} else {
|
||||
await incrementRetry(request.id);
|
||||
results.push({ id: request.id, status: "RETRY" });
|
||||
}
|
||||
} else {
|
||||
await updateRequestStatus(request.id, "SUBMITTED", { manualInstructionsGenerated: true });
|
||||
results.push({ id: request.id, status: "SUBMITTED" });
|
||||
}
|
||||
} catch (err) {
|
||||
await incrementRetry(request.id);
|
||||
results.push({ id: request.id, status: "RETRY" });
|
||||
}
|
||||
}
|
||||
|
||||
return { processed: pending.length, results };
|
||||
}
|
||||
|
||||
async function incrementRetry(requestId: string) {
|
||||
const [request] = await db
|
||||
.select()
|
||||
.from(removalRequests)
|
||||
.where(eq(removalRequests.id, requestId))
|
||||
.limit(1);
|
||||
|
||||
if (!request) return;
|
||||
|
||||
const newAttempts = (request.attempts ?? 0) + 1;
|
||||
const backoffHours = Math.min(2 ** newAttempts * 2, 168); // exponential backoff: 2^attempts * 2 hours, max 7 days
|
||||
const nextRetry = new Date(Date.now() + backoffHours * 60 * 60 * 1000);
|
||||
|
||||
await db
|
||||
.update(removalRequests)
|
||||
.set({
|
||||
attempts: newAttempts,
|
||||
nextRetryAt: nextRetry,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(removalRequests.id, requestId));
|
||||
}
|
||||
|
||||
export async function updateRequestStatus(
|
||||
requestId: string,
|
||||
status: string,
|
||||
metadata?: Record<string, unknown>,
|
||||
) {
|
||||
const updateData: Record<string, unknown> = {
|
||||
status,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
if (status === "SUBMITTED") {
|
||||
updateData.submittedAt = new Date();
|
||||
}
|
||||
if (status === "COMPLETED") {
|
||||
updateData.completedAt = new Date();
|
||||
}
|
||||
if (metadata) {
|
||||
updateData.metadata = metadata;
|
||||
}
|
||||
if (metadata?.error) {
|
||||
updateData.error = metadata.error as string;
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(removalRequests)
|
||||
.set(updateData as never)
|
||||
.where(eq(removalRequests.id, requestId))
|
||||
.returning();
|
||||
|
||||
return updated;
|
||||
}
|
||||
528
web/src/server/services/removebrokers/broker.registry.ts
Normal file
528
web/src/server/services/removebrokers/broker.registry.ts
Normal file
@@ -0,0 +1,528 @@
|
||||
export type RemovalMethod = "AUTOMATED" | "MANUAL_FORM" | "EMAIL" | "PHONE" | "MAIL" | "NONE";
|
||||
export type BrokerCategory = "PEOPLE_SEARCH" | "BACKGROUND_CHECK" | "PUBLIC_RECORDS" | "REVERSE_LOOKUP" | "SOCIAL_MEDIA";
|
||||
|
||||
export interface BrokerEntry {
|
||||
name: string;
|
||||
domain: string;
|
||||
category: BrokerCategory;
|
||||
removalMethod: RemovalMethod;
|
||||
removalUrl: string;
|
||||
requiresAccount: boolean;
|
||||
requiresVerification: boolean;
|
||||
estimatedDays: number;
|
||||
}
|
||||
|
||||
export const brokerRegistry: BrokerEntry[] = [
|
||||
{
|
||||
name: "Spokeo",
|
||||
domain: "spokeo.com",
|
||||
category: "PEOPLE_SEARCH",
|
||||
removalMethod: "MANUAL_FORM",
|
||||
removalUrl: "https://www.spokeo.com/optout",
|
||||
requiresAccount: false,
|
||||
requiresVerification: true,
|
||||
estimatedDays: 7,
|
||||
},
|
||||
{
|
||||
name: "WhitePages",
|
||||
domain: "whitepages.com",
|
||||
category: "PEOPLE_SEARCH",
|
||||
removalMethod: "MANUAL_FORM",
|
||||
removalUrl: "https://www.whitepages.com/suppression_choices",
|
||||
requiresAccount: false,
|
||||
requiresVerification: true,
|
||||
estimatedDays: 14,
|
||||
},
|
||||
{
|
||||
name: "Intelius",
|
||||
domain: "intelius.com",
|
||||
category: "PEOPLE_SEARCH",
|
||||
removalMethod: "MANUAL_FORM",
|
||||
removalUrl: "https://www.intelius.com/optout",
|
||||
requiresAccount: false,
|
||||
requiresVerification: true,
|
||||
estimatedDays: 14,
|
||||
},
|
||||
{
|
||||
name: "MyLife",
|
||||
domain: "mylife.com",
|
||||
category: "PEOPLE_SEARCH",
|
||||
removalMethod: "MANUAL_FORM",
|
||||
removalUrl: "https://www.mylife.com/optout",
|
||||
requiresAccount: true,
|
||||
requiresVerification: true,
|
||||
estimatedDays: 7,
|
||||
},
|
||||
{
|
||||
name: "Pipl",
|
||||
domain: "pipl.com",
|
||||
category: "PEOPLE_SEARCH",
|
||||
removalMethod: "EMAIL",
|
||||
removalUrl: "https://pipl.com/optout",
|
||||
requiresAccount: false,
|
||||
requiresVerification: false,
|
||||
estimatedDays: 30,
|
||||
},
|
||||
{
|
||||
name: "BeenVerified",
|
||||
domain: "beenverified.com",
|
||||
category: "BACKGROUND_CHECK",
|
||||
removalMethod: "MANUAL_FORM",
|
||||
removalUrl: "https://www.beenverified.com/optout",
|
||||
requiresAccount: false,
|
||||
requiresVerification: true,
|
||||
estimatedDays: 7,
|
||||
},
|
||||
{
|
||||
name: "TruthFinder",
|
||||
domain: "truthfinder.com",
|
||||
category: "BACKGROUND_CHECK",
|
||||
removalMethod: "MANUAL_FORM",
|
||||
removalUrl: "https://www.truthfinder.com/optout",
|
||||
requiresAccount: false,
|
||||
requiresVerification: true,
|
||||
estimatedDays: 7,
|
||||
},
|
||||
{
|
||||
name: "InstantCheckmate",
|
||||
domain: "instantcheckmate.com",
|
||||
category: "BACKGROUND_CHECK",
|
||||
removalMethod: "MANUAL_FORM",
|
||||
removalUrl: "https://www.instantcheckmate.com/optout",
|
||||
requiresAccount: false,
|
||||
requiresVerification: true,
|
||||
estimatedDays: 14,
|
||||
},
|
||||
{
|
||||
name: "PeopleFinders",
|
||||
domain: "peoplefinders.com",
|
||||
category: "PEOPLE_SEARCH",
|
||||
removalMethod: "MANUAL_FORM",
|
||||
removalUrl: "https://www.peoplefinders.com/optout",
|
||||
requiresAccount: false,
|
||||
requiresVerification: true,
|
||||
estimatedDays: 14,
|
||||
},
|
||||
{
|
||||
name: "Radaris",
|
||||
domain: "radaris.com",
|
||||
category: "PEOPLE_SEARCH",
|
||||
removalMethod: "MANUAL_FORM",
|
||||
removalUrl: "https://radaris.com/optout",
|
||||
requiresAccount: false,
|
||||
requiresVerification: false,
|
||||
estimatedDays: 14,
|
||||
},
|
||||
{
|
||||
name: "PeekYou",
|
||||
domain: "peekyou.com",
|
||||
category: "PEOPLE_SEARCH",
|
||||
removalMethod: "EMAIL",
|
||||
removalUrl: "https://www.peekyou.com/about/contact",
|
||||
requiresAccount: false,
|
||||
requiresVerification: false,
|
||||
estimatedDays: 14,
|
||||
},
|
||||
{
|
||||
name: "ZabaSearch",
|
||||
domain: "zabasearch.com",
|
||||
category: "PEOPLE_SEARCH",
|
||||
removalMethod: "MANUAL_FORM",
|
||||
removalUrl: "https://www.zabasearch.com/optout",
|
||||
requiresAccount: false,
|
||||
requiresVerification: false,
|
||||
estimatedDays: 7,
|
||||
},
|
||||
{
|
||||
name: "USSearch",
|
||||
domain: "ussearch.com",
|
||||
category: "PEOPLE_SEARCH",
|
||||
removalMethod: "MANUAL_FORM",
|
||||
removalUrl: "https://www.ussearch.com/optout",
|
||||
requiresAccount: false,
|
||||
requiresVerification: true,
|
||||
estimatedDays: 14,
|
||||
},
|
||||
{
|
||||
name: "CensusUSSearch",
|
||||
domain: "censusussearch.com",
|
||||
category: "PUBLIC_RECORDS",
|
||||
removalMethod: "EMAIL",
|
||||
removalUrl: "https://www.censusussearch.com/contact",
|
||||
requiresAccount: false,
|
||||
requiresVerification: false,
|
||||
estimatedDays: 14,
|
||||
},
|
||||
{
|
||||
name: "PublicRecordsNow",
|
||||
domain: "publicrecordsnow.com",
|
||||
category: "PUBLIC_RECORDS",
|
||||
removalMethod: "MANUAL_FORM",
|
||||
removalUrl: "https://www.publicrecordsnow.com/optout",
|
||||
requiresAccount: false,
|
||||
requiresVerification: false,
|
||||
estimatedDays: 14,
|
||||
},
|
||||
{
|
||||
name: "PeopleLooker",
|
||||
domain: "peoplelooker.com",
|
||||
category: "PEOPLE_SEARCH",
|
||||
removalMethod: "MANUAL_FORM",
|
||||
removalUrl: "https://www.peoplelooker.com/optout",
|
||||
requiresAccount: false,
|
||||
requiresVerification: true,
|
||||
estimatedDays: 7,
|
||||
},
|
||||
{
|
||||
name: "CheckPeople",
|
||||
domain: "checkpeople.com",
|
||||
category: "BACKGROUND_CHECK",
|
||||
removalMethod: "MANUAL_FORM",
|
||||
removalUrl: "https://www.checkpeople.com/optout",
|
||||
requiresAccount: false,
|
||||
requiresVerification: true,
|
||||
estimatedDays: 14,
|
||||
},
|
||||
{
|
||||
name: "GoLookUp",
|
||||
domain: "golookup.com",
|
||||
category: "PEOPLE_SEARCH",
|
||||
removalMethod: "EMAIL",
|
||||
removalUrl: "https://www.golookup.com/contact",
|
||||
requiresAccount: false,
|
||||
requiresVerification: false,
|
||||
estimatedDays: 21,
|
||||
},
|
||||
{
|
||||
name: "SearchPeopleFree",
|
||||
domain: "searchpeoplefree.com",
|
||||
category: "PEOPLE_SEARCH",
|
||||
removalMethod: "MANUAL_FORM",
|
||||
removalUrl: "https://www.searchpeoplefree.com/optout",
|
||||
requiresAccount: false,
|
||||
requiresVerification: false,
|
||||
estimatedDays: 14,
|
||||
},
|
||||
{
|
||||
name: "PeopleSearchNow",
|
||||
domain: "peoplesearchnow.com",
|
||||
category: "PEOPLE_SEARCH",
|
||||
removalMethod: "MANUAL_FORM",
|
||||
removalUrl: "https://www.peoplesearchnow.com/optout",
|
||||
requiresAccount: false,
|
||||
requiresVerification: false,
|
||||
estimatedDays: 14,
|
||||
},
|
||||
{
|
||||
name: "FastPeopleSearch",
|
||||
domain: "fastpeoplesearch.com",
|
||||
category: "PEOPLE_SEARCH",
|
||||
removalMethod: "MANUAL_FORM",
|
||||
removalUrl: "https://www.fastpeoplesearch.com/optout",
|
||||
requiresAccount: false,
|
||||
requiresVerification: false,
|
||||
estimatedDays: 7,
|
||||
},
|
||||
{
|
||||
name: "Addresses",
|
||||
domain: "addresses.com",
|
||||
category: "PUBLIC_RECORDS",
|
||||
removalMethod: "EMAIL",
|
||||
removalUrl: "https://www.addresses.com/contact",
|
||||
requiresAccount: false,
|
||||
requiresVerification: false,
|
||||
estimatedDays: 21,
|
||||
},
|
||||
{
|
||||
name: "CyberBackgroundChecks",
|
||||
domain: "cyberbackgroundchecks.com",
|
||||
category: "BACKGROUND_CHECK",
|
||||
removalMethod: "MANUAL_FORM",
|
||||
removalUrl: "https://www.cyberbackgroundchecks.com/remove",
|
||||
requiresAccount: false,
|
||||
requiresVerification: true,
|
||||
estimatedDays: 14,
|
||||
},
|
||||
{
|
||||
name: "CriminalPages",
|
||||
domain: "criminalpages.com",
|
||||
category: "PUBLIC_RECORDS",
|
||||
removalMethod: "EMAIL",
|
||||
removalUrl: "https://www.criminalpages.com/contact",
|
||||
requiresAccount: false,
|
||||
requiresVerification: false,
|
||||
estimatedDays: 21,
|
||||
},
|
||||
{
|
||||
name: "VoteBuilder",
|
||||
domain: "votebuilder.com",
|
||||
category: "PUBLIC_RECORDS",
|
||||
removalMethod: "EMAIL",
|
||||
removalUrl: "https://www.votebuilder.com/contact",
|
||||
requiresAccount: true,
|
||||
requiresVerification: true,
|
||||
estimatedDays: 30,
|
||||
},
|
||||
{
|
||||
name: "Acxiom",
|
||||
domain: "acxiom.com",
|
||||
category: "PUBLIC_RECORDS",
|
||||
removalMethod: "MANUAL_FORM",
|
||||
removalUrl: "https://www.acxiom.com/about-us/privacy/",
|
||||
requiresAccount: false,
|
||||
requiresVerification: true,
|
||||
estimatedDays: 30,
|
||||
},
|
||||
{
|
||||
name: "OptOutPrescreen",
|
||||
domain: "optoutprescreen.com",
|
||||
category: "PUBLIC_RECORDS",
|
||||
removalMethod: "MANUAL_FORM",
|
||||
removalUrl: "https://www.optoutprescreen.com",
|
||||
requiresAccount: false,
|
||||
requiresVerification: false,
|
||||
estimatedDays: 60,
|
||||
},
|
||||
{
|
||||
name: "Spoke",
|
||||
domain: "spoke.com",
|
||||
category: "PEOPLE_SEARCH",
|
||||
removalMethod: "EMAIL",
|
||||
removalUrl: "https://www.spoke.com/contact",
|
||||
requiresAccount: true,
|
||||
requiresVerification: false,
|
||||
estimatedDays: 14,
|
||||
},
|
||||
{
|
||||
name: "Yasni",
|
||||
domain: "yasni.com",
|
||||
category: "PEOPLE_SEARCH",
|
||||
removalMethod: "EMAIL",
|
||||
removalUrl: "https://www.yasni.com/en/info/contact.html",
|
||||
requiresAccount: false,
|
||||
requiresVerification: false,
|
||||
estimatedDays: 30,
|
||||
},
|
||||
{
|
||||
name: "CornerstoneInformationSystems",
|
||||
domain: "cornerstoneinformation.com",
|
||||
category: "BACKGROUND_CHECK",
|
||||
removalMethod: "EMAIL",
|
||||
removalUrl: "https://www.cornerstoneinformation.com/privacy",
|
||||
requiresAccount: false,
|
||||
requiresVerification: false,
|
||||
estimatedDays: 30,
|
||||
},
|
||||
{
|
||||
name: "LexisNexis",
|
||||
domain: "lexisnexis.com",
|
||||
category: "PUBLIC_RECORDS",
|
||||
removalMethod: "MANUAL_FORM",
|
||||
removalUrl: "https://www.lexisnexis.com/privacy/for-consumers/opt-out-of-lexisnexis-consumer-reports.aspx",
|
||||
requiresAccount: false,
|
||||
requiresVerification: true,
|
||||
estimatedDays: 30,
|
||||
},
|
||||
{
|
||||
name: "PeopleSmart",
|
||||
domain: "peoplesmart.com",
|
||||
category: "PEOPLE_SEARCH",
|
||||
removalMethod: "MANUAL_FORM",
|
||||
removalUrl: "https://www.peoplesmart.com/optout",
|
||||
requiresAccount: false,
|
||||
requiresVerification: true,
|
||||
estimatedDays: 7,
|
||||
},
|
||||
{
|
||||
name: "PrivateEye",
|
||||
domain: "privateeye.com",
|
||||
category: "BACKGROUND_CHECK",
|
||||
removalMethod: "MANUAL_FORM",
|
||||
removalUrl: "https://www.privateeye.com/optout",
|
||||
requiresAccount: false,
|
||||
requiresVerification: true,
|
||||
estimatedDays: 14,
|
||||
},
|
||||
{
|
||||
name: "FamilyTreeNow",
|
||||
domain: "familytreenow.com",
|
||||
category: "PUBLIC_RECORDS",
|
||||
removalMethod: "MANUAL_FORM",
|
||||
removalUrl: "https://www.familytreenow.com/optout",
|
||||
requiresAccount: false,
|
||||
requiresVerification: false,
|
||||
estimatedDays: 7,
|
||||
},
|
||||
{
|
||||
name: "NeighborReport",
|
||||
domain: "neighbor.report",
|
||||
category: "PUBLIC_RECORDS",
|
||||
removalMethod: "MANUAL_FORM",
|
||||
removalUrl: "https://neighbor.report/optout",
|
||||
requiresAccount: false,
|
||||
requiresVerification: false,
|
||||
estimatedDays: 14,
|
||||
},
|
||||
{
|
||||
name: "Classmates",
|
||||
domain: "classmates.com",
|
||||
category: "SOCIAL_MEDIA",
|
||||
removalMethod: "MANUAL_FORM",
|
||||
removalUrl: "https://www.classmates.com/optout",
|
||||
requiresAccount: true,
|
||||
requiresVerification: true,
|
||||
estimatedDays: 14,
|
||||
},
|
||||
{
|
||||
name: "Facebook",
|
||||
domain: "facebook.com",
|
||||
category: "SOCIAL_MEDIA",
|
||||
removalMethod: "MANUAL_FORM",
|
||||
removalUrl: "https://www.facebook.com/help/delete_account",
|
||||
requiresAccount: true,
|
||||
requiresVerification: true,
|
||||
estimatedDays: 30,
|
||||
},
|
||||
{
|
||||
name: "LinkedIn",
|
||||
domain: "linkedin.com",
|
||||
category: "SOCIAL_MEDIA",
|
||||
removalMethod: "MANUAL_FORM",
|
||||
removalUrl: "https://www.linkedin.com/help/linkedin/answer/63",
|
||||
requiresAccount: true,
|
||||
requiresVerification: true,
|
||||
estimatedDays: 14,
|
||||
},
|
||||
{
|
||||
name: "Twitter",
|
||||
domain: "twitter.com",
|
||||
category: "SOCIAL_MEDIA",
|
||||
removalMethod: "MANUAL_FORM",
|
||||
removalUrl: "https://help.twitter.com/en/managing-your-account/how-to-deactivate",
|
||||
requiresAccount: true,
|
||||
requiresVerification: true,
|
||||
estimatedDays: 30,
|
||||
},
|
||||
{
|
||||
name: "Instagram",
|
||||
domain: "instagram.com",
|
||||
category: "SOCIAL_MEDIA",
|
||||
removalMethod: "MANUAL_FORM",
|
||||
removalUrl: "https://help.instagram.com/370452623149242",
|
||||
requiresAccount: true,
|
||||
requiresVerification: true,
|
||||
estimatedDays: 30,
|
||||
},
|
||||
{
|
||||
name: "Snapchat",
|
||||
domain: "snapchat.com",
|
||||
category: "SOCIAL_MEDIA",
|
||||
removalMethod: "MANUAL_FORM",
|
||||
removalUrl: "https://support.snapchat.com/en-US/a/delete-my-account",
|
||||
requiresAccount: true,
|
||||
requiresVerification: true,
|
||||
estimatedDays: 30,
|
||||
},
|
||||
{
|
||||
name: "TikTok",
|
||||
domain: "tiktok.com",
|
||||
category: "SOCIAL_MEDIA",
|
||||
removalMethod: "MANUAL_FORM",
|
||||
removalUrl: "https://www.tiktok.com/legal/report/privacy",
|
||||
requiresAccount: true,
|
||||
requiresVerification: true,
|
||||
estimatedDays: 30,
|
||||
},
|
||||
{
|
||||
name: "Pinterest",
|
||||
domain: "pinterest.com",
|
||||
category: "SOCIAL_MEDIA",
|
||||
removalMethod: "MANUAL_FORM",
|
||||
removalUrl: "https://help.pinterest.com/en/article/delete-your-account",
|
||||
requiresAccount: true,
|
||||
requiresVerification: true,
|
||||
estimatedDays: 14,
|
||||
},
|
||||
{
|
||||
name: "Reddit",
|
||||
domain: "reddit.com",
|
||||
category: "SOCIAL_MEDIA",
|
||||
removalMethod: "MANUAL_FORM",
|
||||
removalUrl: "https://www.reddit.com/settings/delete",
|
||||
requiresAccount: true,
|
||||
requiresVerification: true,
|
||||
estimatedDays: 7,
|
||||
},
|
||||
{
|
||||
name: "YouTube",
|
||||
domain: "youtube.com",
|
||||
category: "SOCIAL_MEDIA",
|
||||
removalMethod: "MANUAL_FORM",
|
||||
removalUrl: "https://support.google.com/youtube/answer/56111",
|
||||
requiresAccount: true,
|
||||
requiresVerification: true,
|
||||
estimatedDays: 30,
|
||||
},
|
||||
{
|
||||
name: "Ancestry",
|
||||
domain: "ancestry.com",
|
||||
category: "PUBLIC_RECORDS",
|
||||
removalMethod: "MANUAL_FORM",
|
||||
removalUrl: "https://www.ancestry.com/cs/legal/optout",
|
||||
requiresAccount: true,
|
||||
requiresVerification: true,
|
||||
estimatedDays: 14,
|
||||
},
|
||||
{
|
||||
name: "MyHeritage",
|
||||
domain: "myheritage.com",
|
||||
category: "PUBLIC_RECORDS",
|
||||
removalMethod: "EMAIL",
|
||||
removalUrl: "https://www.myheritage.com/optout",
|
||||
requiresAccount: true,
|
||||
requiresVerification: true,
|
||||
estimatedDays: 14,
|
||||
},
|
||||
{
|
||||
name: "WhitepagesAU",
|
||||
domain: "whitepages.com.au",
|
||||
category: "PUBLIC_RECORDS",
|
||||
removalMethod: "EMAIL",
|
||||
removalUrl: "https://www.whitepages.com.au/privacy",
|
||||
requiresAccount: false,
|
||||
requiresVerification: false,
|
||||
estimatedDays: 30,
|
||||
},
|
||||
{
|
||||
name: "NumberGuru",
|
||||
domain: "numberguru.com",
|
||||
category: "REVERSE_LOOKUP",
|
||||
removalMethod: "EMAIL",
|
||||
removalUrl: "https://www.numberguru.com/contact",
|
||||
requiresAccount: false,
|
||||
requiresVerification: false,
|
||||
estimatedDays: 30,
|
||||
},
|
||||
{
|
||||
name: "ReversePhoneLookup",
|
||||
domain: "reversephonelookup.com",
|
||||
category: "REVERSE_LOOKUP",
|
||||
removalMethod: "MANUAL_FORM",
|
||||
removalUrl: "https://www.reversephonelookup.com/optout",
|
||||
requiresAccount: false,
|
||||
requiresVerification: false,
|
||||
estimatedDays: 14,
|
||||
},
|
||||
];
|
||||
|
||||
export function getActiveBrokers(): BrokerEntry[] {
|
||||
return brokerRegistry;
|
||||
}
|
||||
|
||||
export function getBrokerByDomain(domain: string): BrokerEntry | undefined {
|
||||
return brokerRegistry.find((b) => b.domain === domain);
|
||||
}
|
||||
|
||||
export function getBrokerByName(name: string): BrokerEntry | undefined {
|
||||
return brokerRegistry.find((b) => b.name === name);
|
||||
}
|
||||
76
web/src/server/services/removebrokers/removal.engine.ts
Normal file
76
web/src/server/services/removebrokers/removal.engine.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import type { BrokerEntry } from "./broker.registry";
|
||||
|
||||
export interface PersonalInfo {
|
||||
fullName: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
address?: string;
|
||||
city?: string;
|
||||
state?: string;
|
||||
zip?: string;
|
||||
dob?: string;
|
||||
}
|
||||
|
||||
export interface FormPayload {
|
||||
url: string;
|
||||
fields: Record<string, string>;
|
||||
method: "GET" | "POST";
|
||||
}
|
||||
|
||||
export async function submitAutomatedRemoval(
|
||||
broker: BrokerEntry,
|
||||
personalInfo: PersonalInfo,
|
||||
): Promise<{ success: boolean; requestId?: string; error?: string }> {
|
||||
if (broker.removalMethod !== "AUTOMATED") {
|
||||
return { success: false, error: `Broker ${broker.name} does not support automated removal` };
|
||||
}
|
||||
|
||||
try {
|
||||
// Placeholder: actual API integration per broker would go here
|
||||
// Each automated broker would have its own adapter that calls their API
|
||||
return { success: true, requestId: crypto.randomUUID() };
|
||||
} catch (err) {
|
||||
return { success: false, error: err instanceof Error ? err.message : "Unknown error" };
|
||||
}
|
||||
}
|
||||
|
||||
export function generateFormPayload(broker: BrokerEntry, personalInfo: PersonalInfo): FormPayload {
|
||||
const fields: Record<string, string> = {
|
||||
full_name: personalInfo.fullName,
|
||||
};
|
||||
|
||||
if (personalInfo.email) fields.email = personalInfo.email;
|
||||
if (personalInfo.phone) fields.phone = personalInfo.phone;
|
||||
if (personalInfo.address) fields.address = personalInfo.address;
|
||||
if (personalInfo.city) fields.city = personalInfo.city;
|
||||
if (personalInfo.state) fields.state = personalInfo.state;
|
||||
if (personalInfo.zip) fields.zip = personalInfo.zip;
|
||||
if (personalInfo.dob) fields.date_of_birth = personalInfo.dob;
|
||||
|
||||
return {
|
||||
url: broker.removalUrl,
|
||||
fields,
|
||||
method: "POST",
|
||||
};
|
||||
}
|
||||
|
||||
export async function sendRemovalEmail(
|
||||
broker: BrokerEntry,
|
||||
personalInfo: PersonalInfo,
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
// Placeholder: use notification service (task 14) to send the email
|
||||
// The email would include the specific instructions for this broker
|
||||
try {
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
return { success: false, error: err instanceof Error ? err.message : "Unknown error" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function trackRemovalStatus(
|
||||
broker: BrokerEntry,
|
||||
requestId: string,
|
||||
): Promise<{ status: "pending" | "submitted" | "completed" | "failed"; detail?: string }> {
|
||||
// Placeholder: poll broker API or check email for status updates
|
||||
return { status: "pending" };
|
||||
}
|
||||
Reference in New Issue
Block a user