From d84595bf721715dbc96c1d5b0620db0a5c9a2f27 Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Mon, 25 May 2026 16:47:31 -0400 Subject: [PATCH] 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) --- web/src/server/api/root.ts | 2 + .../server/api/routers/removebrokers.test.ts | 197 +++++++ web/src/server/api/routers/removebrokers.ts | 50 ++ web/src/server/api/schemas/removebrokers.ts | 37 ++ .../services/removebrokers.service.test.ts | 237 ++++++++ .../server/services/removebrokers.service.ts | 457 +++++++++++++++ .../services/removebrokers/broker.registry.ts | 528 ++++++++++++++++++ .../services/removebrokers/removal.engine.ts | 76 +++ 8 files changed, 1584 insertions(+) create mode 100644 web/src/server/api/routers/removebrokers.test.ts create mode 100644 web/src/server/api/routers/removebrokers.ts create mode 100644 web/src/server/api/schemas/removebrokers.ts create mode 100644 web/src/server/services/removebrokers.service.test.ts create mode 100644 web/src/server/services/removebrokers.service.ts create mode 100644 web/src/server/services/removebrokers/broker.registry.ts create mode 100644 web/src/server/services/removebrokers/removal.engine.ts diff --git a/web/src/server/api/root.ts b/web/src/server/api/root.ts index 64ec1ee..d130ef6 100644 --- a/web/src/server/api/root.ts +++ b/web/src/server/api/root.ts @@ -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; diff --git a/web/src/server/api/routers/removebrokers.test.ts b/web/src/server/api/routers/removebrokers.test.ts new file mode 100644 index 0000000..a959444 --- /dev/null +++ b/web/src/server/api/routers/removebrokers.test.ts @@ -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().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 { + 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); + }); +}); diff --git a/web/src/server/api/routers/removebrokers.ts b/web/src/server/api/routers/removebrokers.ts new file mode 100644 index 0000000..7943d6a --- /dev/null +++ b/web/src/server/api/routers/removebrokers.ts @@ -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); + }), +}); diff --git a/web/src/server/api/schemas/removebrokers.ts b/web/src/server/api/schemas/removebrokers.ts new file mode 100644 index 0000000..35ad7bc --- /dev/null +++ b/web/src/server/api/schemas/removebrokers.ts @@ -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), +}); diff --git a/web/src/server/services/removebrokers.service.test.ts b/web/src/server/services/removebrokers.service.test.ts new file mode 100644 index 0000000..a5a25ec --- /dev/null +++ b/web/src/server/services/removebrokers.service.test.ts @@ -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); + }); +}); diff --git a/web/src/server/services/removebrokers.service.ts b/web/src/server/services/removebrokers.service.ts new file mode 100644 index 0000000..44d96de --- /dev/null +++ b/web/src/server/services/removebrokers.service.ts @@ -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, + 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 = {}; + + 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, +) { + const updateData: Record = { + 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; +} diff --git a/web/src/server/services/removebrokers/broker.registry.ts b/web/src/server/services/removebrokers/broker.registry.ts new file mode 100644 index 0000000..54d7394 --- /dev/null +++ b/web/src/server/services/removebrokers/broker.registry.ts @@ -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); +} diff --git a/web/src/server/services/removebrokers/removal.engine.ts b/web/src/server/services/removebrokers/removal.engine.ts new file mode 100644 index 0000000..aa29630 --- /dev/null +++ b/web/src/server/services/removebrokers/removal.engine.ts @@ -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; + 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 = { + 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" }; +}