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:
2026-05-25 16:47:31 -04:00
parent a3fee924d8
commit d84595bf72
8 changed files with 1584 additions and 0 deletions

View File

@@ -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;

View 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);
});
});

View 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);
}),
});

View 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),
});

View 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);
});
});

View 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;
}

View 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);
}

View 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" };
}