FRE-4471: Scaffold DarkWatch MVP — monorepo, schema, services, API routes, tests
- Turborepo monorepo structure (packages: api, db, types, jobs; services: darkwatch) - Prisma schema: User, WatchListItem, Exposure, Alert, ScanJob models - WatchListService: CRUD with normalization, dedup, tier-based limits - HIBPService: API integration with severity scoring - MatchingEngine: exact-match with content hash dedup - AlertPipeline: dedup window, email notifications - ScanService: orchestrates watch list -> HIBP -> match -> alert flow - BullMQ job workers for scan and alert processing - Fastify API routes: watchlist, exposures, alerts, scan - Docker Compose: PostgreSQL 16 + Redis 7 - 15 unit tests passing - Implementation plan document uploaded
This commit is contained in:
94
services/darkwatch/src/watchlist/WatchListService.ts
Normal file
94
services/darkwatch/src/watchlist/WatchListService.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import prisma from "@shieldai/db";
|
||||
import { IdentifierType, WatchListStatus } from "@shieldai/types";
|
||||
import { createHash, randomUUID } from "crypto";
|
||||
|
||||
export class WatchListService {
|
||||
async addItem(userId: string, identifierType: IdentifierType, identifierValue: string) {
|
||||
const normalized = this.normalize(identifierType, identifierValue);
|
||||
const hash = this.computeHash(normalized);
|
||||
|
||||
const existing = await prisma.watchListItem.findFirst({
|
||||
where: { userId, identifierHash: hash },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
throw new Error(`Identifier already watched: ${normalized}`);
|
||||
}
|
||||
|
||||
const tier = await this.getUserTier(userId);
|
||||
await this.enforceLimit(userId, tier);
|
||||
|
||||
return prisma.watchListItem.create({
|
||||
data: {
|
||||
userId,
|
||||
identifierType,
|
||||
identifierValue: normalized,
|
||||
identifierHash: hash,
|
||||
status: WatchListStatus.ACTIVE,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async listItems(userId: string) {
|
||||
return prisma.watchListItem.findMany({
|
||||
where: { userId },
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
}
|
||||
|
||||
async removeItem(userId: string, itemId: string) {
|
||||
return prisma.watchListItem.deleteMany({
|
||||
where: { id: itemId, userId },
|
||||
});
|
||||
}
|
||||
|
||||
async getActiveItems(userId: string) {
|
||||
return prisma.watchListItem.findMany({
|
||||
where: { userId, status: WatchListStatus.ACTIVE },
|
||||
});
|
||||
}
|
||||
|
||||
async getById(itemId: string) {
|
||||
return prisma.watchListItem.findUnique({ where: { id: itemId } });
|
||||
}
|
||||
|
||||
normalize(type: IdentifierType, value: string): string {
|
||||
const trimmed = value.trim();
|
||||
if (type === IdentifierType.EMAIL) {
|
||||
return trimmed.toLowerCase();
|
||||
}
|
||||
if (type === IdentifierType.PHONE) {
|
||||
return this.normalizePhone(trimmed);
|
||||
}
|
||||
return trimmed.replace(/\s/g, "");
|
||||
}
|
||||
|
||||
normalizePhone(phone: string): string {
|
||||
const digits = phone.replace(/\D/g, "");
|
||||
if (digits.length === 10 && digits[0] !== "1") {
|
||||
return `+1${digits}`;
|
||||
}
|
||||
if (digits.length === 11 && digits[0] === "1") {
|
||||
return `+${digits}`;
|
||||
}
|
||||
return `+${digits}`;
|
||||
}
|
||||
|
||||
computeHash(value: string): string {
|
||||
return createHash("sha256").update(value).digest("hex");
|
||||
}
|
||||
|
||||
private async getUserTier(userId: string) {
|
||||
const user = await prisma.user.findUnique({ where: { id: userId } });
|
||||
return user?.subscriptionTier ?? "BASIC";
|
||||
}
|
||||
|
||||
private async enforceLimit(userId: string, tier: string) {
|
||||
const count = await prisma.watchListItem.count({ where: { userId } });
|
||||
const limits: Record<string, number> = { BASIC: 2, PLUS: 10, PREMIUM: 999 };
|
||||
const limit = limits[tier] ?? 2;
|
||||
if (count >= limit) {
|
||||
throw new Error(`Watch list limit reached (${count}/${limit}) for tier ${tier}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user