Auto-commit 2026-05-02 09:37
This commit is contained in:
@@ -10,6 +10,8 @@
|
||||
"dev": "tsx watch src/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@shieldsai/shared-analytics": "*",
|
||||
"@shieldsai/shared-billing": "*",
|
||||
"@shieldsai/shared-db": "*",
|
||||
"@shieldsai/shared-utils": "*",
|
||||
"bullmq": "^5.1.0",
|
||||
|
||||
173
packages/jobs/src/darkwatch.jobs.ts
Normal file
173
packages/jobs/src/darkwatch.jobs.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import { prisma, SubscriptionTier } from '@shieldsai/shared-db';
|
||||
import { Queue, Worker, Job } from 'bullmq';
|
||||
import { Redis } from 'ioredis';
|
||||
import { tierConfig, getTierFeatures } from '@shieldsai/shared-billing';
|
||||
import { mixpanelService, EventType } from '@shieldsai/shared-analytics';
|
||||
|
||||
const redisHost = process.env.REDIS_HOST || 'localhost';
|
||||
const redisPort = parseInt(process.env.REDIS_PORT || '6379', 10);
|
||||
|
||||
const connection = new Redis({
|
||||
host: redisHost,
|
||||
port: redisPort,
|
||||
retryStrategy: (times: number) => Math.min(times * 50, 2000),
|
||||
});
|
||||
|
||||
const QUEUE_CONFIG = {
|
||||
darkwatchScan: {
|
||||
name: 'darkwatch-scan',
|
||||
concurrency: parseInt(process.env.DARKWATCH_CONCURRENCY || '5', 10),
|
||||
defaultJobTimeout: parseInt(process.env.DARKWATCH_JOB_TIMEOUT || '120000', 10),
|
||||
maxAttempts: parseInt(process.env.DARKWATCH_MAX_ATTEMPTS || '3', 10),
|
||||
},
|
||||
};
|
||||
|
||||
export const darkwatchScanQueue = new Queue(
|
||||
QUEUE_CONFIG.darkwatchScan.name,
|
||||
{ connection }
|
||||
);
|
||||
|
||||
async function processDarkwatchScan(
|
||||
job: Job<{
|
||||
subscriptionId: string;
|
||||
tier: string;
|
||||
scanType: 'scheduled' | 'on-demand' | 'realtime';
|
||||
sourceData?: Record<string, unknown>;
|
||||
}>
|
||||
) {
|
||||
const { subscriptionId, tier, scanType, sourceData } = job.data;
|
||||
|
||||
const { scanService } = await import(
|
||||
'../../../apps/api/src/services/darkwatch/scan.service'
|
||||
);
|
||||
const { alertPipeline } = await import(
|
||||
'../../../apps/api/src/services/darkwatch/alert.pipeline'
|
||||
);
|
||||
|
||||
job.updateProgress(10);
|
||||
console.log(
|
||||
`[DarkWatch:Scan] Starting ${scanType} scan for subscription ${subscriptionId} (tier: ${tier})`
|
||||
);
|
||||
|
||||
try {
|
||||
const subscription = await prisma.subscription.findUnique({
|
||||
where: { id: subscriptionId },
|
||||
select: { userId: true, tier: true },
|
||||
});
|
||||
|
||||
if (!subscription) {
|
||||
job.updateProgress(100);
|
||||
return { status: 'skipped', reason: 'subscription_not_found' };
|
||||
}
|
||||
|
||||
await mixpanelService.track(
|
||||
EventType.DARK_WEB_SCAN_STARTED,
|
||||
subscription.userId,
|
||||
{
|
||||
scanType,
|
||||
subscriptionTier: subscription.tier,
|
||||
}
|
||||
);
|
||||
|
||||
job.updateProgress(25);
|
||||
|
||||
const watchlistItems = await scanService.getWatchlistItems(subscriptionId);
|
||||
|
||||
if (watchlistItems.length === 0) {
|
||||
job.updateProgress(100);
|
||||
return { status: 'completed', exposuresCreated: 0, exposuresUpdated: 0 };
|
||||
}
|
||||
|
||||
job.updateProgress(50);
|
||||
|
||||
const { exposuresCreated, exposuresUpdated } =
|
||||
await scanService.processSubscriptionScan(subscriptionId, watchlistItems);
|
||||
|
||||
job.updateProgress(80);
|
||||
|
||||
const newExposureIds = await prisma.exposure.findMany({
|
||||
where: {
|
||||
subscriptionId,
|
||||
isFirstTime: true,
|
||||
detectedAt: { gte: new Date(Date.now() - 5 * 60 * 1000) },
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (newExposureIds.length > 0) {
|
||||
await alertPipeline.processNewExposures(newExposureIds.map((e) => e.id));
|
||||
}
|
||||
|
||||
await alertPipeline.dispatchScanCompleteAlert(
|
||||
subscriptionId,
|
||||
subscription.userId,
|
||||
exposuresCreated
|
||||
);
|
||||
|
||||
job.updateProgress(95);
|
||||
|
||||
await mixpanelService.track(
|
||||
EventType.DARK_WEB_SCAN_COMPLETED,
|
||||
subscription.userId,
|
||||
{
|
||||
scanType,
|
||||
subscriptionTier: subscription.tier,
|
||||
exposuresCreated,
|
||||
exposuresUpdated,
|
||||
watchlistItemsScanned: watchlistItems.length,
|
||||
}
|
||||
);
|
||||
|
||||
job.updateProgress(100);
|
||||
|
||||
return {
|
||||
status: 'completed',
|
||||
exposuresCreated,
|
||||
exposuresUpdated,
|
||||
watchlistItemsScanned: watchlistItems.length,
|
||||
};
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Scan failed';
|
||||
console.error(`[DarkWatch:Scan] Job ${job.id} failed:`, message);
|
||||
job.updateProgress(100);
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
export const darkwatchScanWorker = new Worker(
|
||||
QUEUE_CONFIG.darkwatchScan.name,
|
||||
processDarkwatchScan,
|
||||
{
|
||||
connection,
|
||||
concurrency: QUEUE_CONFIG.darkwatchScan.concurrency,
|
||||
limiter: {
|
||||
max: 20,
|
||||
duration: 1000,
|
||||
},
|
||||
removeOnComplete: {
|
||||
age: 7 * 24 * 60 * 60,
|
||||
count: 1000,
|
||||
},
|
||||
removeOnFail: {
|
||||
age: 30 * 24 * 60 * 60,
|
||||
count: 100,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
darkwatchScanWorker.on('completed', (job, result) => {
|
||||
console.log(`[DarkWatch:Scan] Job ${job.id} completed:`, result);
|
||||
});
|
||||
|
||||
darkwatchScanWorker.on('failed', (job, err) => {
|
||||
console.error(`[DarkWatch:Scan] Job ${job?.id} failed:`, err.message);
|
||||
});
|
||||
|
||||
darkwatchScanWorker.on('error', (err) => {
|
||||
console.error('[DarkWatch:Scan] Worker error:', err.message);
|
||||
});
|
||||
|
||||
export default {
|
||||
darkwatchScanQueue,
|
||||
darkwatchScanWorker,
|
||||
};
|
||||
@@ -2,3 +2,8 @@ export {
|
||||
voiceprintAnalysisQueue,
|
||||
voiceprintAnalysisWorker,
|
||||
} from './voiceprint.jobs';
|
||||
|
||||
export {
|
||||
darkwatchScanQueue,
|
||||
darkwatchScanWorker,
|
||||
} from './darkwatch.jobs';
|
||||
|
||||
12
packages/jobs/tsconfig.json
Normal file
12
packages/jobs/tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Analytics } from '@segment/analytics-node';
|
||||
import { analyticsEnv, EventType, eventPropertiesSchema } from '../config/analytics.config';
|
||||
import { hashPhoneNumber } from '../utils/phone-hash';
|
||||
|
||||
// Mixpanel service
|
||||
export class MixpanelService {
|
||||
@@ -97,7 +98,7 @@ export class MixpanelService {
|
||||
*/
|
||||
async spamBlocked(userId: string, phoneNumber: string, confidence: number, method: string): Promise<void> {
|
||||
await this.track(EventType.SPAM_BLOCKED, userId, {
|
||||
phoneNumber,
|
||||
phoneNumber: hashPhoneNumber(phoneNumber),
|
||||
confidence,
|
||||
method,
|
||||
timestamp: new Date(),
|
||||
|
||||
12
packages/shared-analytics/src/utils/phone-hash.ts
Normal file
12
packages/shared-analytics/src/utils/phone-hash.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Hash a phone number for analytics purposes
|
||||
* Uses a consistent hashing algorithm to create a deterministic hash
|
||||
*/
|
||||
export function hashPhoneNumber(phoneNumber: string): string {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < phoneNumber.length; i++) {
|
||||
hash = ((hash << 5) - hash) + phoneNumber.charCodeAt(i);
|
||||
hash |= 0;
|
||||
}
|
||||
return `hash_${Math.abs(hash)}`;
|
||||
}
|
||||
15
packages/shared-billing/package.json
Normal file
15
packages/shared-billing/package.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "@shieldsai/shared-billing",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"dependencies": {
|
||||
"stripe": "^14.0.0",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.3.3"
|
||||
}
|
||||
}
|
||||
@@ -26,12 +26,13 @@ model User {
|
||||
accounts Account[]
|
||||
sessions Session[]
|
||||
familyGroups FamilyGroupMember[]
|
||||
subscriptions Subscription[]
|
||||
watchlist WatchlistItem[]
|
||||
exposures Exposure[]
|
||||
alerts Alert[]
|
||||
familyGroupOwned FamilyGroup[] @relation("FamilyGroupOwner")
|
||||
subscriptions Subscription[]
|
||||
alerts Alert[]
|
||||
voiceEnrollments VoiceEnrollment[]
|
||||
spamFeedback SpamFeedback[]
|
||||
voiceAnalyses VoiceAnalysis[]
|
||||
spamFeedback SpamFeedback[]
|
||||
spamRules SpamRule[]
|
||||
|
||||
// Audit
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { PrismaClient } from './generated/client';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
// Singleton pattern for Prisma Client
|
||||
const globalForPrisma = globalThis as unknown as {
|
||||
@@ -45,6 +45,6 @@ export type {
|
||||
FeedbackType,
|
||||
RuleType,
|
||||
RuleAction,
|
||||
} from './generated/client';
|
||||
} from '@prisma/client';
|
||||
|
||||
export * as PrismaModels from './generated/client';
|
||||
export * as PrismaModels from '@prisma/client';
|
||||
|
||||
@@ -25,6 +25,7 @@ export class EmailService {
|
||||
subject: string,
|
||||
htmlBody: string,
|
||||
textBody?: string,
|
||||
templateId?: string,
|
||||
attachments?: Array<{
|
||||
filename: string;
|
||||
content: Buffer | string;
|
||||
@@ -35,7 +36,7 @@ export class EmailService {
|
||||
id: `email_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
||||
userId: recipient.userId,
|
||||
channel: NotificationChannel.EMAIL,
|
||||
templateId: 'custom', // Can be updated to use actual template
|
||||
templateId: templateId || 'custom',
|
||||
priority: NotificationPriority.NORMAL,
|
||||
status: NotificationStatus.PENDING,
|
||||
to: recipient.email!,
|
||||
|
||||
@@ -15,23 +15,27 @@ export class PushService {
|
||||
|
||||
constructor(fcmConfig?: FCMConfig, apnsConfig?: APNsConfig) {
|
||||
if (fcmConfig) {
|
||||
if (!admin.apps.length && fcmConfig.keyPath) {
|
||||
// Use named app instance for multi-tenant support
|
||||
const appName = fcmConfig.keyPath
|
||||
? `fcm_${fcmConfig.projectId}`
|
||||
: 'fcm_default';
|
||||
|
||||
// Check if app with this name already exists
|
||||
const existingApp = admin.app(appName);
|
||||
|
||||
if (!existingApp) {
|
||||
this.fcm = admin.initializeApp({
|
||||
credential: admin.credential.cert({
|
||||
projectId: fcmConfig.projectId,
|
||||
privateKey: fcmConfig.privateKey.replace(/\\n/g, '\n'),
|
||||
clientEmail: fcmConfig.clientEmail,
|
||||
}),
|
||||
storageBucket: `${fcmConfig.projectId}.appspot.com`,
|
||||
});
|
||||
} else if (!admin.apps.length) {
|
||||
this.fcm = admin.initializeApp({
|
||||
credential: admin.credential.cert({
|
||||
projectId: fcmConfig.projectId,
|
||||
privateKey: fcmConfig.privateKey.replace(/\\n/g, '\n'),
|
||||
clientEmail: fcmConfig.clientEmail,
|
||||
...(fcmConfig.keyPath && {
|
||||
storageBucket: `${fcmConfig.projectId}.appspot.com`,
|
||||
}),
|
||||
});
|
||||
}, appName);
|
||||
} else {
|
||||
this.fcm = existingApp;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,8 +152,9 @@ export class PushService {
|
||||
return notification;
|
||||
}
|
||||
|
||||
// APNs implementation would go here
|
||||
// For now, we'll use FCM for iOS as well (FCM supports APNs)
|
||||
// FCM supports sending to APNs tokens (iOS devices)
|
||||
// This leverages FCM's unified push infrastructure for iOS
|
||||
// APNs token format: device-specific token from iOS
|
||||
if (this.fcm && recipient.apnsToken) {
|
||||
const message: admin.messaging.Message = {
|
||||
token: recipient.apnsToken,
|
||||
@@ -246,12 +251,12 @@ export class PushService {
|
||||
return !!this.fcm;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shutdown FCM app
|
||||
*/
|
||||
async shutdown(): Promise<void> {
|
||||
if (this.fcm) {
|
||||
await this.fcm.terminate();
|
||||
/**
|
||||
* Shutdown FCM app
|
||||
*/
|
||||
async shutdown(): Promise<void> {
|
||||
if (this.fcm) {
|
||||
await this.fcm.delete();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user