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:
Senior Engineer
2026-04-29 09:47:45 -04:00
committed by Michael Freno
parent f8f90502fa
commit 218de3b03b
40 changed files with 5225 additions and 0 deletions

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