Add Info Broker Removal service (FRE-5402)

New service for helping clients remove personal listings from data broker sites.

Service features:
- BrokerRegistry: Catalog of 20+ data brokers with removal methods
- RemoveBrokersService: Core service for scanning, creating removal requests,
  submitting removals, and verifying completions
- RemoveBrokersScheduler: Automated processing of pending removals and
  verification of completed removals
- BrokerAlertPipeline: Alert integration for listing discoveries and removal status

API endpoints (/removebrokers):
- GET /brokers - List available data brokers
- GET /status - Get removal request status and stats
- POST /scan - Scan for personal listings across brokers
- POST /request - Create a new removal request
- GET /request/:id - Get specific removal request details
- DELETE /request/:id - Cancel a removal request
- POST /process - Trigger processing of pending removals
- POST /verify/:id - Manually verify a removal completion

DB models: InfoBroker, RemovalRequest, BrokerListing
Types: BrokerStatus, RemovalStatus, RemovalMethod, and related interfaces
This commit is contained in:
Founding Engineer
2026-05-17 00:58:23 -04:00
committed by Michael Freno
parent 590e15e66e
commit bd881045f4
14 changed files with 1808 additions and 0 deletions

View File

@@ -8,6 +8,7 @@ import { subscriptionRoutes } from './subscription.routes';
import { deviceRoutes } from './device.routes';
import { notificationRoutes } from './notifications.routes';
import { hometitleRoutes } from './hometitle.routes';
import { removebrokersRoutes } from './removebrokers.routes';
export async function routes(fastify: FastifyInstance) {
// Authenticated routes group
@@ -179,4 +180,15 @@ export async function routes(fastify: FastifyInstance) {
},
{ prefix: '/hometitle' }
);
// Info Broker Removal service routes
fastify.register(
async (removebrokersRouter) => {
removebrokersRouter.addHook('onRequest', async (request: FastifyRequest, reply: FastifyReply) => {
await fastify.requireAuth(request as AuthRequest);
});
await removebrokersRoutes(removebrokersRouter);
},
{ prefix: '/removebrokers' }
);
}

View File

@@ -0,0 +1,378 @@
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
import { prisma } from '@shieldai/db';
import { RemovalStatus, Severity } from '@shieldai/types';
import {
removeBrokersService,
removeBrokersScheduler,
brokerAlertPipeline,
type PersonalInfo,
} from '@shieldai/removebrokers';
import { AuthRequest } from '../middleware/auth.middleware';
const REMOVAL_REQUEST_LIMITS: Record<string, number> = {
basic: 5,
plus: 20,
premium: 999,
};
async function getSubscription(
request: FastifyRequest,
reply: FastifyReply,
): Promise<{ subscriptionId: string; tier: string } | null> {
const authReq = request as AuthRequest;
const userId = authReq.user?.id;
if (!userId) {
await reply.code(401).send({ error: 'User not authenticated' });
return null;
}
const subscription = await prisma.subscription.findFirst({
where: { userId, status: 'active' },
select: { id: true, tier: true },
});
if (!subscription) {
await reply.code(402).send({
error: 'Subscription required',
message: 'An active subscription is required for data broker removal',
});
return null;
}
return { subscriptionId: subscription.id, tier: subscription.tier };
}
export async function removebrokersRoutes(fastify: FastifyInstance) {
// GET /removebrokers/brokers - List available data brokers
fastify.get('/brokers', async (request: FastifyRequest, reply: FastifyReply) => {
const authReq = request as AuthRequest;
if (!authReq.user?.id) {
return reply.code(401).send({ error: 'User not authenticated' });
}
const query = request.query as { category?: string };
let brokers = await removeBrokersService.getAvailableBrokers();
if (query.category) {
brokers = brokers.filter((b) => b.category === query.category);
}
return reply.send({
brokers: brokers.map((b) => ({
id: b.id,
name: b.name,
domain: b.domain,
category: b.category,
removalMethod: b.removalMethod,
requiresAccount: b.requiresAccount,
requiresVerification: b.requiresVerification,
estimatedDays: b.estimatedDays,
removalUrl: b.removalUrl,
})),
});
});
// GET /removebrokers/status - Get removal request status for user
fastify.get('/status', async (request: FastifyRequest, reply: FastifyReply) => {
const sub = await getSubscription(request, reply);
if (!sub) return;
try {
const status = await removeBrokersService.getRemovalStatus(sub.subscriptionId);
const total = status.length;
const pending = status.filter((s) => s.status === RemovalStatus.PENDING).length;
const submitted = status.filter((s) => s.status === RemovalStatus.SUBMITTED).length;
const completed = status.filter((s) => s.status === RemovalStatus.COMPLETED).length;
const failed = status.filter((s) => s.status === RemovalStatus.FAILED).length;
const limit = REMOVAL_REQUEST_LIMITS[sub.tier] ?? 5;
const remaining = Math.max(0, limit - total);
return reply.send({
stats: { total, pending, submitted, completed, failed },
limit,
remaining,
tier: sub.tier,
requests: status,
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to fetch status';
return reply.code(500).send({ error: message });
}
});
// POST /removebrokers/scan - Scan for personal listings across brokers
fastify.post('/scan', async (request: FastifyRequest, reply: FastifyReply) => {
const sub = await getSubscription(request, reply);
if (!sub) return;
const body = request.body as { fullName?: string; email?: string; phone?: string; address?: string };
if (!body.fullName) {
return reply.code(400).send({
error: 'Invalid request',
message: 'fullName is required for scanning',
});
}
try {
const user = await prisma.user.findUnique({
where: { id: (request as AuthRequest).user!.id },
});
const personalInfo: PersonalInfo = {
fullName: body.fullName,
email: body.email || user?.email,
phone: body.phone,
address: body.address
? { street: body.address }
: undefined,
};
const results = await removeBrokersService.scanForListings(
sub.subscriptionId,
personalInfo,
);
const found = results.filter((r) => r.found);
for (const listing of found) {
try {
await brokerAlertPipeline.sendListingFoundAlert({
userId: (request as AuthRequest).user!.id,
brokerName: listing.brokerName,
brokerId: listing.brokerId,
category: 'INFO_BROKER_LISTING' as any,
severity: Severity.MEDIUM,
title: `Personal listing found on ${listing.brokerName}`,
description: `Your personal information was found on ${listing.brokerName} (${listing.brokerId}). Consider submitting a removal request.`,
entities: [
{ type: 'USER_ID' as any, value: (request as AuthRequest).user!.id },
],
metadata: { url: listing.url },
});
} catch {
// Alert failure is non-critical
}
}
return reply.send({
brokersScanned: results.length,
listingsFound: found.length,
results,
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Scan failed';
return reply.code(500).send({ error: message });
}
});
// POST /removebrokers/request - Create a new removal request
fastify.post('/request', async (request: FastifyRequest, reply: FastifyReply) => {
const sub = await getSubscription(request, reply);
if (!sub) return;
const body = request.body as {
brokerId: string;
fullName: string;
email?: string;
phone?: string;
address?: {
street?: string;
city?: string;
state?: string;
zip?: string;
};
dob?: string;
notes?: string;
};
if (!body.brokerId) {
return reply.code(400).send({
error: 'Invalid request',
message: 'brokerId is required',
});
}
if (!body.fullName) {
return reply.code(400).send({
error: 'Invalid request',
message: 'fullName is required',
});
}
const limit = REMOVAL_REQUEST_LIMITS[sub.tier] ?? 5;
const currentCount = await prisma.removalRequest.count({
where: { subscriptionId: sub.subscriptionId },
});
if (currentCount >= limit) {
return reply.code(400).send({
error: 'Request limit reached',
message: `You have reached the maximum of ${limit} removal requests for your ${sub.tier} tier.`,
currentCount,
limit,
upgradeTo: 'plus',
});
}
try {
const personalInfo: PersonalInfo = {
fullName: body.fullName,
email: body.email,
phone: body.phone,
address: body.address,
dob: body.dob,
};
const req = await removeBrokersService.createRemovalRequest(
sub.subscriptionId,
body.brokerId,
personalInfo,
body.notes,
);
return reply.code(201).send({
request: {
id: req.id,
brokerId: req.brokerId,
status: req.status,
method: req.method,
createdAt: req.createdAt,
},
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to create removal request';
return reply.code(422).send({ error: message });
}
});
// GET /removebrokers/request/:id - Get specific removal request
fastify.get('/request/:id', async (request: FastifyRequest, reply: FastifyReply) => {
const sub = await getSubscription(request, reply);
if (!sub) return;
const id = (request.params as { id: string }).id;
try {
const req = await prisma.removalRequest.findFirst({
where: { id, subscriptionId: sub.subscriptionId },
include: { broker: true },
});
if (!req) {
return reply.code(404).send({ error: 'Removal request not found' });
}
return reply.send({
request: {
id: req.id,
brokerId: req.brokerId,
brokerName: req.broker.name,
status: req.status,
method: req.method,
attempts: req.attempts,
submittedAt: req.submittedAt,
completedAt: req.completedAt,
error: req.error,
notes: req.notes,
createdAt: req.createdAt,
updatedAt: req.updatedAt,
},
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to fetch request';
return reply.code(500).send({ error: message });
}
});
// DELETE /removebrokers/request/:id - Cancel a removal request
fastify.delete('/request/:id', async (request: FastifyRequest, reply: FastifyReply) => {
const sub = await getSubscription(request, reply);
if (!sub) return;
const id = (request.params as { id: string }).id;
try {
const req = await prisma.removalRequest.findFirst({
where: { id, subscriptionId: sub.subscriptionId },
});
if (!req) {
return reply.code(404).send({ error: 'Removal request not found' });
}
if (req.status === RemovalStatus.COMPLETED) {
return reply.code(400).send({
error: 'Cannot cancel',
message: 'Cannot cancel a completed removal request',
});
}
await prisma.removalRequest.update({
where: { id },
data: { status: RemovalStatus.REJECTED },
});
return reply.send({
request: {
id: req.id,
status: 'cancelled',
},
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to cancel request';
return reply.code(500).send({ error: message });
}
});
// POST /removebrokers/process - Trigger processing of pending removals (admin)
fastify.post('/process', async (request: FastifyRequest, reply: FastifyReply) => {
const authReq = request as AuthRequest;
if (!authReq.user?.id) {
return reply.code(401).send({ error: 'User not authenticated' });
}
try {
const results = await removeBrokersService.processPendingRequests();
return reply.send({
processed: results.length,
results,
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Processing failed';
return reply.code(500).send({ error: message });
}
});
// POST /removebrokers/verify/:id - Manually verify a removal
fastify.post('/verify/:id', async (request: FastifyRequest, reply: FastifyReply) => {
const sub = await getSubscription(request, reply);
if (!sub) return;
const id = (request.params as { id: string }).id;
try {
const req = await prisma.removalRequest.findFirst({
where: { id, subscriptionId: sub.subscriptionId },
});
if (!req) {
return reply.code(404).send({ error: 'Removal request not found' });
}
const result = await removeBrokersService.verifyRemoval(id);
return reply.send({
requestId: id,
...result,
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Verification failed';
return reply.code(500).send({ error: message });
}
});
}

View File

@@ -25,6 +25,7 @@ model User {
// Relationships
accounts Account[]
sessions Session[]
deviceTokens DeviceToken[]
familyGroups FamilyGroupMember[]
familyGroupOwned FamilyGroup[] @relation("FamilyGroupOwner")
subscriptions Subscription[]
@@ -37,6 +38,8 @@ model User {
correlationGroups CorrelationGroup[]
securityReports SecurityReport[]
analysisJobs AnalysisJob[]
removalRequests RemovalRequest[]
brokerListings BrokerListing[]
// Audit
createdAt DateTime @default(now())
@@ -107,6 +110,42 @@ model Session {
@@index([userId])
}
model DeviceToken {
id String @id @default(uuid())
userId String
deviceType DeviceType
token String @unique
platform Platform
appName String?
appVersion String?
osVersion String?
model String?
isActive Boolean @default(true)
lastUsedAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([userId])
@@index([deviceType])
@@index([platform])
@@index([isActive])
}
enum DeviceType {
mobile
web
desktop
}
enum Platform {
ios
android
web
}
// ============================================
// Family & Subscription Models
// ============================================
@@ -167,6 +206,9 @@ model Subscription {
watchlistItems WatchlistItem[]
exposures Exposure[]
alerts Alert[]
propertyWatchlistItems PropertyWatchlistItem[]
removalRequests RemovalRequest[]
brokerListings BrokerListing[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -506,6 +548,8 @@ enum AlertSource {
SPAMSHIELD
VOICEPRINT
CALL_ANALYSIS
HOME_TITLE
INFO_BROKER
}
enum AlertCategory {
@@ -517,6 +561,9 @@ enum AlertCategory {
CALL_ANOMALY
CALL_QUALITY
CALL_EVENT
HOME_TITLE
INFO_BROKER_LISTING
INFO_BROKER_REMOVAL
}
enum NormalizedAlertSeverity {
@@ -588,6 +635,7 @@ model CorrelationGroup {
enum ReportType {
MONTHLY_PLUS
ANNUAL_PREMIUM
WEEKLY_DIGEST
}
enum ReportStatus {
@@ -676,3 +724,192 @@ model BlogPost {
@@index([published, publishedAt])
@@index([tags])
}
// ============================================
// Home Title Service Models
// ============================================
enum PropertyChangeType {
tax_change
deed_change
ownership_transfer
lien_filing
metadata_change
}
enum PropertyChangeSeverity {
info
warning
critical
}
model PropertyWatchlistItem {
id String @id @default(uuid())
subscriptionId String
address String
parcelId String?
ownerName String?
streetAddress String
city String? @default("")
state String? @default("")
zipCode String? @default("")
latitude Float?
longitude Float?
isActive Boolean @default(true)
subscription Subscription @relation(fields: [subscriptionId], references: [id], onDelete: Cascade)
snapshots PropertySnapshot[]
changes PropertyChange[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([subscriptionId, parcelId])
@@index([subscriptionId])
@@index([parcelId])
@@index([address])
}
model PropertySnapshot {
id String @id @default(uuid())
propertyWatchlistItemId String
subscriptionId String
capturedAt DateTime
ownerName String
address Json // { streetNumber, streetName, streetType, unit, city, state, zip, latitude?, longitude? }
deedDate String?
taxId String?
propertyType String @default("residential")
taxAmount Float?
lienCount Int @default(0)
propertyWatchlistItem PropertyWatchlistItem @relation(fields: [propertyWatchlistItemId], references: [id], onDelete: Cascade)
changes PropertyChange[] @relation("SnapshotChanges")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([propertyWatchlistItemId])
@@index([subscriptionId])
@@index([capturedAt])
}
model PropertyChange {
id String @id @default(uuid())
propertyWatchlistItemId String
snapshotId String?
changeType PropertyChangeType
severity PropertyChangeSeverity @default(info)
details Json?
detectedAt DateTime @default(now())
propertyWatchlistItem PropertyWatchlistItem @relation(fields: [propertyWatchlistItemId], references: [id], onDelete: Cascade)
snapshot PropertySnapshot? @relation("SnapshotChanges", fields: [snapshotId], references: [id])
@@index([propertyWatchlistItemId])
@@index([snapshotId])
@@index([changeType])
}
// ============================================
// Info Broker Removal Models
// ============================================
enum BrokerCategory {
people_search
background_check
public_records
reverse_lookup
social_media
}
enum RemovalMethod {
automated
manual_form
email
phone
mail
none
}
enum RemovalStatus {
pending
submitted
in_progress
completed
failed
rejected
}
model InfoBroker {
id String @id @default(uuid())
name String
domain String @unique
category BrokerCategory
removalMethod RemovalMethod
removalUrl String?
requiresAccount Boolean @default(false)
requiresVerification Boolean @default(false)
estimatedDays Int @default(14)
isActive Boolean @default(true)
removalRequests RemovalRequest[]
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
@@index([category])
@@index([isActive])
@@index([removalMethod])
}
model RemovalRequest {
id String @id @default(uuid())
subscriptionId String
brokerId String
status RemovalStatus @default(pending)
personalInfo Json // { fullName, email?, phone?, address?, dob? }
method RemovalMethod
attempts Int @default(0)
nextRetryAt DateTime?
submittedAt DateTime?
completedAt DateTime?
error String?
notes String?
metadata Json? // Broker response data, tracking info
broker InfoBroker @relation(fields: [brokerId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
@@index([subscriptionId])
@@index([brokerId])
@@index([status])
@@index([submittedAt])
@@index([subscriptionId, status])
}
model BrokerListing {
id String @id @default(uuid())
subscriptionId String
brokerId String
removalRequestId String?
url String
dataFound Json // Fields found on the listing
screenshotUrl String?
isRemoved Boolean @default(false)
removedAt DateTime?
removalRequest RemovalRequest? @relation(fields: [removalRequestId], references: [id])
scannedAt DateTime @default(now())
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
@@index([subscriptionId])
@@index([brokerId])
@@index([removalRequestId])
@@index([isRemoved])
@@index([subscriptionId, isRemoved])
}

View File

@@ -27,6 +27,7 @@ export const AlertSource = {
SPAMSHIELD: "SPAMSHIELD",
VOICEPRINT: "VOICEPRINT",
CALL_ANALYSIS: "CALL_ANALYSIS",
INFO_BROKER: "INFO_BROKER",
} as const;
export type AlertSource = (typeof AlertSource)[keyof typeof AlertSource];
@@ -39,6 +40,9 @@ export const AlertCategory = {
CALL_QUALITY: "CALL_QUALITY",
CALL_ANOMALY: "CALL_ANOMALY",
CALL_EVENT: "CALL_EVENT",
HOME_TITLE: "HOME_TITLE",
INFO_BROKER_LISTING: "INFO_BROKER_LISTING",
INFO_BROKER_REMOVAL: "INFO_BROKER_REMOVAL",
} as const;
export type AlertCategory = (typeof AlertCategory)[keyof typeof AlertCategory];
@@ -280,6 +284,7 @@ export { generateRequestId, extractOrGenerateRequestId } from "./requestId";
export const ReportType = {
MONTHLY_PLUS: "MONTHLY_PLUS",
ANNUAL_PREMIUM: "ANNUAL_PREMIUM",
WEEKLY_DIGEST: "WEEKLY_DIGEST",
} as const;
export type ReportType = (typeof ReportType)[keyof typeof ReportType];
@@ -365,3 +370,93 @@ export interface SecurityReportOutput {
createdAt: Date;
deliveredAt?: Date;
}
// ============================================
// Info Broker Removal Types
// ============================================
export const BrokerStatus = {
ACTIVE: "ACTIVE",
INACTIVE: "INACTIVE",
UNKNOWN: "UNKNOWN",
} as const;
export type BrokerStatus = (typeof BrokerStatus)[keyof typeof BrokerStatus];
export const RemovalStatus = {
PENDING: "PENDING",
SUBMITTED: "SUBMITTED",
IN_PROGRESS: "IN_PROGRESS",
COMPLETED: "COMPLETED",
FAILED: "FAILED",
REJECTED: "REJECTED",
} as const;
export type RemovalStatus = (typeof RemovalStatus)[keyof typeof RemovalStatus];
export const RemovalMethod = {
AUTOMATED: "AUTOMATED",
MANUAL_FORM: "MANUAL_FORM",
EMAIL: "EMAIL",
PHONE: "PHONE",
MAIL: "MAIL",
NONE: "NONE",
} as const;
export type RemovalMethod = (typeof RemovalMethod)[keyof typeof RemovalMethod];
export interface BrokerDefinition {
id: string;
name: string;
domain: string;
category: string;
removalMethod: RemovalMethod;
removalUrl?: string;
requiresAccount: boolean;
requiresVerification: boolean;
estimatedDays: number;
isActive: boolean;
}
export interface RemovalRequestInput {
brokerId: string;
personalInfo: {
fullName: string;
email?: string;
phone?: string;
address?: {
street?: string;
city?: string;
state?: string;
zip?: string;
};
dob?: string;
};
notes?: string;
}
export interface RemovalRequestOutput {
id: string;
brokerId: string;
brokerName: string;
status: RemovalStatus;
submittedAt?: Date;
completedAt?: Date;
method: RemovalMethod;
attempts: number;
nextRetryAt?: Date;
notes?: string;
error?: string;
createdAt: Date;
updatedAt: Date;
}
export interface BrokerScanResult {
brokerId: string;
brokerName: string;
listingsFound: number;
listings: Array<{
id: string;
url: string;
dataFound: Record<string, string>;
screenshotUrl?: string;
}>;
scannedAt: Date;
}