FRE-4533: Merge apps/{api,web,mobile} and shared-db into ShieldAI repo
Merge FrenoCorp apps into ShieldAI packages/: - packages/api: merged routes (notifications), middleware (auth, rate-limit, error, logging), config, services (darkwatch, spamshield, voiceprint), tests - packages/web: new SolidJS web app stub - packages/mobile: new SolidJS mobile app stub - packages/shared-db: new Prisma DB package (separate from existing packages/db) - pnpm-workspace.yaml: restored (apps/* removed, already covered by packages/*) Next: reconcile packages/shared-db with packages/db, and fix server.ts correlationRoutes import
This commit is contained in:
@@ -1,29 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "api",
|
|
||||||
"version": "0.1.0",
|
|
||||||
"private": true,
|
|
||||||
"type": "module",
|
|
||||||
"scripts": {
|
|
||||||
"dev": "tsx watch src/index.ts",
|
|
||||||
"build": "tsc",
|
|
||||||
"lint": "eslint src/"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@fastify/cors": "^11.2.0",
|
|
||||||
"@fastify/helmet": "^13.0.2",
|
|
||||||
"@shieldsai/shared-analytics": "*",
|
|
||||||
"@shieldsai/shared-auth": "*",
|
|
||||||
"@shieldsai/shared-billing": "*",
|
|
||||||
"@shieldsai/shared-db": "*",
|
|
||||||
"@shieldsai/shared-notifications": "*",
|
|
||||||
"@shieldsai/shared-utils": "*",
|
|
||||||
"fastify": "^4.25.0",
|
|
||||||
"fastify-plugin": "^4.5.0",
|
|
||||||
"ioredis": "^5.3.0"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@types/node": "^25.6.0",
|
|
||||||
"tsx": "^4.7.1",
|
|
||||||
"typescript": "^5.3.3"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,285 +0,0 @@
|
|||||||
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
|
||||||
import { prisma, SubscriptionTier } from '@shieldsai/shared-db';
|
|
||||||
import { tierConfig, SubscriptionTier as BillingTier } from '@shieldsai/shared-billing';
|
|
||||||
import {
|
|
||||||
watchlistService,
|
|
||||||
scanService,
|
|
||||||
schedulerService,
|
|
||||||
webhookService,
|
|
||||||
} from '../services/darkwatch';
|
|
||||||
|
|
||||||
export async function darkwatchRoutes(fastify: FastifyInstance) {
|
|
||||||
const authed = async (
|
|
||||||
request: FastifyRequest,
|
|
||||||
reply: FastifyReply
|
|
||||||
): Promise<string | null> => {
|
|
||||||
const authReq = request as FastifyRequest & { user?: { id: string } };
|
|
||||||
const userId = authReq.user?.id;
|
|
||||||
if (!userId) {
|
|
||||||
reply.code(401).send({ error: 'User ID required' });
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const subscription = await prisma.subscription.findFirst({
|
|
||||||
where: { userId, status: 'active' },
|
|
||||||
select: { id: true, tier: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!subscription) {
|
|
||||||
reply.code(404).send({ error: 'Active subscription not found' });
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return subscription.id;
|
|
||||||
};
|
|
||||||
|
|
||||||
// GET /darkwatch/watchlist - List watchlist items
|
|
||||||
fastify.get('/watchlist', async (request: FastifyRequest, reply: FastifyReply) => {
|
|
||||||
const subscriptionId = await authed(request, reply);
|
|
||||||
if (!subscriptionId) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const items = await watchlistService.getItems(subscriptionId);
|
|
||||||
return reply.send({ items });
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : 'Failed to list watchlist';
|
|
||||||
return reply.code(500).send({ error: message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// POST /darkwatch/watchlist - Add watchlist item
|
|
||||||
fastify.post('/watchlist', async (request: FastifyRequest, reply: FastifyReply) => {
|
|
||||||
const authReq = request as FastifyRequest & { user?: { id: string } };
|
|
||||||
const userId = authReq.user?.id;
|
|
||||||
if (!userId) {
|
|
||||||
return reply.code(401).send({ error: 'User ID required' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const subscription = await prisma.subscription.findFirst({
|
|
||||||
where: { userId, status: 'active' },
|
|
||||||
select: { id: true, tier: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!subscription) {
|
|
||||||
return reply.code(404).send({ error: 'Active subscription not found' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = request.body as { type: string; value: string };
|
|
||||||
|
|
||||||
if (!body.type || !body.value) {
|
|
||||||
return reply.code(400).send({ error: 'type and value are required' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const maxItems = tierConfig[subscription.tier as BillingTier].features.maxWatchlistItems;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const item = await watchlistService.addItem(
|
|
||||||
subscription.id,
|
|
||||||
body.type,
|
|
||||||
body.value,
|
|
||||||
maxItems
|
|
||||||
);
|
|
||||||
return reply.code(201).send({ item });
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : 'Failed to add watchlist item';
|
|
||||||
return reply.code(422).send({ error: message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// DELETE /darkwatch/watchlist/:id - Remove watchlist item
|
|
||||||
fastify.delete('/watchlist/:id', async (request: FastifyRequest, reply: FastifyReply) => {
|
|
||||||
const subscriptionId = await authed(request, reply);
|
|
||||||
if (!subscriptionId) return;
|
|
||||||
|
|
||||||
const id = (request.params as { id: string }).id;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const item = await watchlistService.removeItem(id, subscriptionId);
|
|
||||||
return reply.send({ item });
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : 'Failed to remove watchlist item';
|
|
||||||
return reply.code(422).send({ error: message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// POST /darkwatch/scan - Trigger on-demand scan
|
|
||||||
fastify.post('/scan', async (request: FastifyRequest, reply: FastifyReply) => {
|
|
||||||
const subscriptionId = await authed(request, reply);
|
|
||||||
if (!subscriptionId) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const job = await schedulerService.enqueueOnDemandScan(subscriptionId);
|
|
||||||
return reply.send({
|
|
||||||
job: {
|
|
||||||
id: job?.id,
|
|
||||||
status: 'queued',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : 'Failed to trigger scan';
|
|
||||||
return reply.code(422).send({ error: message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// GET /darkwatch/scan/schedule - Get scan schedule
|
|
||||||
fastify.get('/scan/schedule', async (request: FastifyRequest, reply: FastifyReply) => {
|
|
||||||
const subscriptionId = await authed(request, reply);
|
|
||||||
if (!subscriptionId) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const schedule = await schedulerService.getScanSchedule(subscriptionId);
|
|
||||||
return reply.send({ schedule });
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : 'Failed to get schedule';
|
|
||||||
return reply.code(500).send({ error: message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// GET /darkwatch/exposures - List exposures
|
|
||||||
fastify.get('/exposures', async (request: FastifyRequest, reply: FastifyReply) => {
|
|
||||||
const subscriptionId = await authed(request, reply);
|
|
||||||
if (!subscriptionId) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const exposures = await prisma.exposure.findMany({
|
|
||||||
where: { subscriptionId },
|
|
||||||
orderBy: { detectedAt: 'desc' },
|
|
||||||
take: 50,
|
|
||||||
include: {
|
|
||||||
watchlistItem: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return reply.send({ exposures });
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : 'Failed to list exposures';
|
|
||||||
return reply.code(500).send({ error: message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// GET /darkwatch/alerts - List alerts
|
|
||||||
fastify.get('/alerts', async (request: FastifyRequest, reply: FastifyReply) => {
|
|
||||||
const authReq = request as FastifyRequest & { user?: { id: string } };
|
|
||||||
const userId = authReq.user?.id;
|
|
||||||
if (!userId) {
|
|
||||||
return reply.code(401).send({ error: 'User ID required' });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const alerts = await prisma.alert.findMany({
|
|
||||||
where: { userId },
|
|
||||||
orderBy: { createdAt: 'desc' },
|
|
||||||
take: 50,
|
|
||||||
include: {
|
|
||||||
exposure: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return reply.send({ alerts });
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : 'Failed to list alerts';
|
|
||||||
return reply.code(500).send({ error: message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// PATCH /darkwatch/alerts/:id/read - Mark alert as read
|
|
||||||
fastify.patch('/alerts/:id/read', async (request: FastifyRequest, reply: FastifyReply) => {
|
|
||||||
const authReq = request as FastifyRequest & { user?: { id: string } };
|
|
||||||
const userId = authReq.user?.id;
|
|
||||||
if (!userId) {
|
|
||||||
return reply.code(401).send({ error: 'User ID required' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const id = (request.params as { id: string }).id;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const alert = await prisma.alert.update({
|
|
||||||
where: { id },
|
|
||||||
data: { isRead: true, readAt: new Date() },
|
|
||||||
});
|
|
||||||
return reply.send({ alert });
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : 'Failed to mark alert as read';
|
|
||||||
return reply.code(422).send({ error: message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// POST /darkwatch/webhook - External webhook receiver
|
|
||||||
fastify.post('/webhook', async (request: FastifyRequest, reply: FastifyReply) => {
|
|
||||||
const body = request.body as Record<string, unknown>;
|
|
||||||
|
|
||||||
const source = typeof body.source === 'string' ? body.source : '';
|
|
||||||
const identifier = typeof body.identifier === 'string' ? body.identifier : '';
|
|
||||||
const identifierType = typeof body.identifierType === 'string' ? body.identifierType : '';
|
|
||||||
const metadata = body.metadata as Record<string, unknown> | undefined;
|
|
||||||
const timestamp = typeof body.timestamp === 'string' ? body.timestamp : new Date().toISOString();
|
|
||||||
|
|
||||||
if (!source || !identifier || !identifierType) {
|
|
||||||
return reply.code(400).send({
|
|
||||||
error: 'source, identifier, and identifierType are required',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const signature = request.headers['x-webhook-signature'] as string | undefined;
|
|
||||||
const webhookTimestamp = request.headers['x-webhook-timestamp'] as string | undefined;
|
|
||||||
|
|
||||||
if (!signature || !webhookTimestamp) {
|
|
||||||
return reply.code(401).send({ error: 'Webhook signature and timestamp required' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const valid = await webhookService.verifyWebhookSignature(
|
|
||||||
JSON.stringify(body),
|
|
||||||
signature,
|
|
||||||
webhookTimestamp
|
|
||||||
);
|
|
||||||
if (!valid) {
|
|
||||||
return reply.code(401).send({ error: 'Invalid webhook signature' });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await webhookService.processExternalWebhook({
|
|
||||||
source,
|
|
||||||
identifier,
|
|
||||||
identifierType,
|
|
||||||
metadata,
|
|
||||||
timestamp,
|
|
||||||
});
|
|
||||||
|
|
||||||
return reply.send({
|
|
||||||
processed: true,
|
|
||||||
exposuresCreated: result.exposuresCreated,
|
|
||||||
alertsCreated: result.alertsCreated,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : 'Webhook processing failed';
|
|
||||||
console.error('[DarkWatch:Webhook] Error:', message);
|
|
||||||
return reply.code(500).send({ error: 'Webhook processing failed' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// POST /darkwatch/scheduler/init - Initialize scheduled scans for all subscriptions
|
|
||||||
fastify.post('/scheduler/init', async (request: FastifyRequest, reply: FastifyReply) => {
|
|
||||||
try {
|
|
||||||
const jobsEnqueued = await schedulerService.scheduleSubscriptionScans();
|
|
||||||
return reply.send({
|
|
||||||
scheduled: jobsEnqueued.length,
|
|
||||||
jobs: jobsEnqueued,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : 'Scheduler init failed';
|
|
||||||
return reply.code(500).send({ error: message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// POST /darkwatch/scheduler/reschedule - Reschedule all scans
|
|
||||||
fastify.post('/scheduler/reschedule', async (request: FastifyRequest, reply: FastifyReply) => {
|
|
||||||
try {
|
|
||||||
const jobsEnqueued = await schedulerService.rescheduleAll();
|
|
||||||
return reply.send({
|
|
||||||
rescheduled: jobsEnqueued.length,
|
|
||||||
jobs: jobsEnqueued,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : 'Scheduler reschedule failed';
|
|
||||||
return reply.code(500).send({ error: message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,142 +0,0 @@
|
|||||||
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
|
||||||
import { authMiddleware, AuthRequest } from './auth.middleware';
|
|
||||||
import { voiceprintRoutes } from './voiceprint.routes';
|
|
||||||
import { spamshieldRoutes } from './spamshield.routes';
|
|
||||||
import { darkwatchRoutes } from './darkwatch.routes';
|
|
||||||
|
|
||||||
export async function routes(fastify: FastifyInstance) {
|
|
||||||
// Authenticated routes group
|
|
||||||
fastify.register(
|
|
||||||
async (authenticated) => {
|
|
||||||
// Add auth requirement
|
|
||||||
authenticated.addHook('onRequest', async (request: FastifyRequest, reply: FastifyReply) => {
|
|
||||||
await fastify.requireAuth(request as AuthRequest);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Example authenticated endpoint
|
|
||||||
authenticated.get('/user/me', async (request: FastifyRequest, reply: FastifyReply) => {
|
|
||||||
const authReq = request as AuthRequest;
|
|
||||||
return {
|
|
||||||
user: authReq.user,
|
|
||||||
authType: authReq.authType,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// Example service endpoint
|
|
||||||
authenticated.get('/services', async (request: FastifyRequest, reply: FastifyReply) => {
|
|
||||||
return {
|
|
||||||
services: [
|
|
||||||
{
|
|
||||||
name: 'user-service',
|
|
||||||
url: '/api/v1/services/user',
|
|
||||||
status: 'healthy',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'billing-service',
|
|
||||||
url: '/api/v1/services/billing',
|
|
||||||
status: 'healthy',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'notification-service',
|
|
||||||
url: '/api/v1/services/notifications',
|
|
||||||
status: 'healthy',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
});
|
|
||||||
},
|
|
||||||
{ prefix: '/auth' }
|
|
||||||
);
|
|
||||||
|
|
||||||
// Public API routes
|
|
||||||
fastify.register(
|
|
||||||
async (publicRouter) => {
|
|
||||||
// Version info
|
|
||||||
publicRouter.get('/info', async () => {
|
|
||||||
return {
|
|
||||||
version: '1.0.0',
|
|
||||||
environment: process.env.NODE_ENV || 'development',
|
|
||||||
build: process.env.npm_package_version || 'unknown',
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// API documentation
|
|
||||||
publicRouter.get('/docs', async () => {
|
|
||||||
return {
|
|
||||||
title: 'FrenoCorp API Gateway',
|
|
||||||
version: '1.0.0',
|
|
||||||
endpoints: {
|
|
||||||
public: [
|
|
||||||
{ method: 'GET', path: '/', description: 'Root endpoint' },
|
|
||||||
{ method: 'GET', path: '/health', description: 'Health check' },
|
|
||||||
{ method: 'GET', path: '/api/v1/info', description: 'API version info' },
|
|
||||||
{ method: 'GET', path: '/api/v1/docs', description: 'API documentation' },
|
|
||||||
],
|
|
||||||
authenticated: [
|
|
||||||
{ method: 'GET', path: '/api/v1/auth/user/me', description: 'Get current user' },
|
|
||||||
{ method: 'GET', path: '/api/v1/auth/services', description: 'List available services' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
},
|
|
||||||
{ prefix: '/api/v1' }
|
|
||||||
);
|
|
||||||
|
|
||||||
// Service proxy placeholder (for future microservice routing)
|
|
||||||
fastify.register(
|
|
||||||
async (services) => {
|
|
||||||
services.get('/services/user', async (request, reply) => {
|
|
||||||
// In production, proxy to actual user service
|
|
||||||
return {
|
|
||||||
service: 'user-service',
|
|
||||||
message: 'User service endpoint',
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
services.get('/services/billing', async (request, reply) => {
|
|
||||||
// In production, proxy to actual billing service
|
|
||||||
return {
|
|
||||||
service: 'billing-service',
|
|
||||||
message: 'Billing service endpoint',
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
services.get('/services/notifications', async (request, reply) => {
|
|
||||||
// In production, proxy to actual notification service
|
|
||||||
return {
|
|
||||||
service: 'notification-service',
|
|
||||||
message: 'Notification service endpoint',
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
},
|
|
||||||
{ prefix: '/api/v1/services' }
|
|
||||||
);
|
|
||||||
|
|
||||||
// VoicePrint service routes
|
|
||||||
fastify.register(
|
|
||||||
async (voiceprintRouter) => {
|
|
||||||
await voiceprintRoutes(voiceprintRouter);
|
|
||||||
},
|
|
||||||
{ prefix: '/voiceprint' }
|
|
||||||
);
|
|
||||||
|
|
||||||
// SpamShield service routes
|
|
||||||
fastify.register(
|
|
||||||
async (spamshieldRouter) => {
|
|
||||||
await spamshieldRoutes(spamshieldRouter);
|
|
||||||
},
|
|
||||||
{ prefix: '/spamshield' }
|
|
||||||
);
|
|
||||||
|
|
||||||
// DarkWatch service routes
|
|
||||||
fastify.register(
|
|
||||||
async (darkwatchRouter) => {
|
|
||||||
await darkwatchRoutes(darkwatchRouter);
|
|
||||||
},
|
|
||||||
{ prefix: '/darkwatch' }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,252 +0,0 @@
|
|||||||
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
|
||||||
import {
|
|
||||||
numberReputationService,
|
|
||||||
smsClassifierService,
|
|
||||||
callAnalysisService,
|
|
||||||
spamFeedbackService,
|
|
||||||
} from '../services/spamshield';
|
|
||||||
import { ErrorHandler, SpamErrorCode } from '../services/spamshield/spamshield.error-handler';
|
|
||||||
|
|
||||||
export async function spamshieldRoutes(fastify: FastifyInstance) {
|
|
||||||
// Classify SMS text
|
|
||||||
fastify.post('/sms/classify', async (request: FastifyRequest, reply: FastifyReply) => {
|
|
||||||
const authReq = request as FastifyRequest & { user?: { id: string } };
|
|
||||||
const userId = authReq.user?.id;
|
|
||||||
|
|
||||||
if (!userId) {
|
|
||||||
ErrorHandler.send(reply, SpamErrorCode.UNAUTHORIZED, 'User ID required', { status: 401 });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = request.body as { text: string };
|
|
||||||
|
|
||||||
const textValidation = ErrorHandler.validateRequiredField(body.text, 'text');
|
|
||||||
if (!textValidation.isValid && textValidation.error) {
|
|
||||||
ErrorHandler.send(reply, textValidation.error.code, textValidation.error.message, {
|
|
||||||
field: textValidation.error.field,
|
|
||||||
status: 400,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await smsClassifierService.classify(body.text);
|
|
||||||
return reply.send({
|
|
||||||
classification: {
|
|
||||||
isSpam: result.isSpam,
|
|
||||||
confidence: result.confidence,
|
|
||||||
spamFeatures: result.spamFeatures,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
ErrorHandler.send(reply, SpamErrorCode.CLASSIFICATION_FAILED, 'Classification failed', {
|
|
||||||
status: 422,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Check number reputation
|
|
||||||
fastify.post('/number/reputation', async (request: FastifyRequest, reply: FastifyReply) => {
|
|
||||||
const authReq = request as FastifyRequest & { user?: { id: string } };
|
|
||||||
const userId = authReq.user?.id;
|
|
||||||
|
|
||||||
if (!userId) {
|
|
||||||
ErrorHandler.send(reply, SpamErrorCode.UNAUTHORIZED, 'User ID required', { status: 401 });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = request.body as { phoneNumber: string };
|
|
||||||
|
|
||||||
const phoneValidation = ErrorHandler.validateRequiredField(body.phoneNumber, 'phoneNumber');
|
|
||||||
if (!phoneValidation.isValid && phoneValidation.error) {
|
|
||||||
ErrorHandler.send(reply, phoneValidation.error.code, phoneValidation.error.message, {
|
|
||||||
field: phoneValidation.error.field,
|
|
||||||
status: 400,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await numberReputationService.checkReputation(body.phoneNumber);
|
|
||||||
return reply.send({
|
|
||||||
reputation: {
|
|
||||||
isSpam: result.isSpam,
|
|
||||||
confidence: result.confidence,
|
|
||||||
spamType: result.spamType,
|
|
||||||
reportCount: result.reportCount,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
ErrorHandler.send(reply, SpamErrorCode.REPUTATION_CHECK_FAILED, 'Reputation check failed', {
|
|
||||||
status: 422,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Analyze incoming call
|
|
||||||
fastify.post('/call/analyze', async (request: FastifyRequest, reply: FastifyReply) => {
|
|
||||||
const authReq = request as FastifyRequest & { user?: { id: string } };
|
|
||||||
const userId = authReq.user?.id;
|
|
||||||
|
|
||||||
if (!userId) {
|
|
||||||
ErrorHandler.send(reply, SpamErrorCode.UNAUTHORIZED, 'User ID required', { status: 401 });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = request.body as {
|
|
||||||
phoneNumber: string;
|
|
||||||
duration?: number;
|
|
||||||
callTime: string;
|
|
||||||
isVoip?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
const phoneValidation = ErrorHandler.validateRequiredField(body.phoneNumber, 'phoneNumber');
|
|
||||||
const callTimeValidation = ErrorHandler.validateRequiredField(body.callTime, 'callTime');
|
|
||||||
|
|
||||||
if (!phoneValidation.isValid && phoneValidation.error) {
|
|
||||||
ErrorHandler.send(reply, phoneValidation.error.code, phoneValidation.error.message, {
|
|
||||||
field: phoneValidation.error.field,
|
|
||||||
status: 400,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!callTimeValidation.isValid && callTimeValidation.error) {
|
|
||||||
ErrorHandler.send(reply, callTimeValidation.error.code, callTimeValidation.error.message, {
|
|
||||||
field: callTimeValidation.error.field,
|
|
||||||
status: 400,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await callAnalysisService.analyzeCall({
|
|
||||||
phoneNumber: body.phoneNumber,
|
|
||||||
duration: body.duration,
|
|
||||||
callTime: new Date(body.callTime),
|
|
||||||
isVoip: body.isVoip,
|
|
||||||
});
|
|
||||||
return reply.send({
|
|
||||||
analysis: {
|
|
||||||
decision: result.decision,
|
|
||||||
confidence: result.confidence,
|
|
||||||
reasons: result.reasons,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
ErrorHandler.send(reply, SpamErrorCode.ANALYSIS_FAILED, 'Call analysis failed', {
|
|
||||||
status: 422,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Record spam feedback
|
|
||||||
fastify.post('/feedback', async (request: FastifyRequest, reply: FastifyReply) => {
|
|
||||||
const authReq = request as FastifyRequest & { user?: { id: string } };
|
|
||||||
const userId = authReq.user?.id;
|
|
||||||
|
|
||||||
if (!userId) {
|
|
||||||
ErrorHandler.send(reply, SpamErrorCode.UNAUTHORIZED, 'User ID required', { status: 401 });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = request.body as {
|
|
||||||
phoneNumber: string;
|
|
||||||
isSpam: boolean;
|
|
||||||
confidence?: number;
|
|
||||||
metadata?: Record<string, unknown>;
|
|
||||||
};
|
|
||||||
|
|
||||||
const phoneValidation = ErrorHandler.validateRequiredField(body.phoneNumber, 'phoneNumber');
|
|
||||||
if (!phoneValidation.isValid && phoneValidation.error) {
|
|
||||||
ErrorHandler.send(reply, phoneValidation.error.code, phoneValidation.error.message, {
|
|
||||||
field: phoneValidation.error.field,
|
|
||||||
status: 400,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isSpamValidation = ErrorHandler.validateBooleanField(body.isSpam, 'isSpam');
|
|
||||||
if (!isSpamValidation.isValid && isSpamValidation.error) {
|
|
||||||
ErrorHandler.send(reply, isSpamValidation.error.code, isSpamValidation.error.message, {
|
|
||||||
field: isSpamValidation.error.field,
|
|
||||||
status: 400,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const feedback = await spamFeedbackService.recordFeedback(
|
|
||||||
userId,
|
|
||||||
body.phoneNumber,
|
|
||||||
body.isSpam,
|
|
||||||
body.confidence,
|
|
||||||
body.metadata
|
|
||||||
);
|
|
||||||
return reply.code(201).send({
|
|
||||||
feedback: {
|
|
||||||
id: feedback.id,
|
|
||||||
phoneNumber: feedback.phoneNumber,
|
|
||||||
isSpam: feedback.isSpam,
|
|
||||||
createdAt: feedback.createdAt,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
ErrorHandler.send(reply, SpamErrorCode.FEEDBACK_RECORD_FAILED, 'Feedback recording failed', {
|
|
||||||
status: 422,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get spam history
|
|
||||||
fastify.get('/history', async (request: FastifyRequest, reply: FastifyReply) => {
|
|
||||||
const authReq = request as FastifyRequest & { user?: { id: string } };
|
|
||||||
const userId = authReq.user?.id;
|
|
||||||
|
|
||||||
if (!userId) {
|
|
||||||
ErrorHandler.send(reply, SpamErrorCode.UNAUTHORIZED, 'User ID required', { status: 401 });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const query = request.query as {
|
|
||||||
limit?: string;
|
|
||||||
isSpam?: string;
|
|
||||||
startDate?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const results = await spamFeedbackService.getSpamHistory(userId, {
|
|
||||||
limit: query.limit ? parseInt(query.limit, 10) : undefined,
|
|
||||||
isSpam: query.isSpam !== undefined ? query.isSpam === 'true' : undefined,
|
|
||||||
startDate: query.startDate ? new Date(query.startDate) : undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
return reply.send({
|
|
||||||
history: results.map((r) => ({
|
|
||||||
id: r.id,
|
|
||||||
phoneNumber: r.phoneNumber,
|
|
||||||
isSpam: r.isSpam,
|
|
||||||
createdAt: r.createdAt,
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get spam statistics
|
|
||||||
fastify.get('/statistics', async (request: FastifyRequest, reply: FastifyReply) => {
|
|
||||||
const authReq = request as FastifyRequest & { user?: { id: string } };
|
|
||||||
const userId = authReq.user?.id;
|
|
||||||
|
|
||||||
if (!userId) {
|
|
||||||
ErrorHandler.send(reply, SpamErrorCode.UNAUTHORIZED, 'User ID required', { status: 401 });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const stats = await spamFeedbackService.getStatistics(userId);
|
|
||||||
return reply.send({ statistics: stats });
|
|
||||||
} catch (error) {
|
|
||||||
ErrorHandler.send(reply, SpamErrorCode.ANALYSIS_FAILED, 'Statistics retrieval failed', {
|
|
||||||
status: 422,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,257 +0,0 @@
|
|||||||
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
|
||||||
import {
|
|
||||||
voiceEnrollmentService,
|
|
||||||
analysisService,
|
|
||||||
batchAnalysisService,
|
|
||||||
voicePrintEnv,
|
|
||||||
AnalysisJobStatus,
|
|
||||||
} from '../services/voiceprint';
|
|
||||||
|
|
||||||
export async function voiceprintRoutes(fastify: FastifyInstance) {
|
|
||||||
// Enroll a new voice profile
|
|
||||||
fastify.post('/enroll', async (request: FastifyRequest, reply: FastifyReply) => {
|
|
||||||
const authReq = request as FastifyRequest & { user?: { id: string } };
|
|
||||||
const userId = authReq.user?.id;
|
|
||||||
|
|
||||||
if (!userId) {
|
|
||||||
return reply.code(401).send({ error: 'User ID required' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = request.body as {
|
|
||||||
name: string;
|
|
||||||
audio: Buffer;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!body.name || !body.audio) {
|
|
||||||
return reply.code(400).send({ error: 'name and audio are required' });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const enrollment = await voiceEnrollmentService.enroll(
|
|
||||||
userId,
|
|
||||||
body.name,
|
|
||||||
body.audio
|
|
||||||
);
|
|
||||||
return reply.code(201).send({
|
|
||||||
enrollment: {
|
|
||||||
id: enrollment.id,
|
|
||||||
name: enrollment.name,
|
|
||||||
isActive: enrollment.isActive,
|
|
||||||
createdAt: enrollment.createdAt,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : 'Enrollment failed';
|
|
||||||
return reply.code(422).send({ error: message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// List user's voice enrollments
|
|
||||||
fastify.get('/enrollments', async (request: FastifyRequest, reply: FastifyReply) => {
|
|
||||||
const authReq = request as FastifyRequest & { user?: { id: string } };
|
|
||||||
const userId = authReq.user?.id;
|
|
||||||
|
|
||||||
if (!userId) {
|
|
||||||
return reply.code(401).send({ error: 'User ID required' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const isActive = request.query as { isActive?: string };
|
|
||||||
const limit = request.query as { limit?: string };
|
|
||||||
const offset = request.query as { offset?: string };
|
|
||||||
|
|
||||||
const enrollments = await voiceEnrollmentService.listEnrollments(userId, {
|
|
||||||
isActive: isActive.isActive !== undefined
|
|
||||||
? isActive.isActive === 'true'
|
|
||||||
: undefined,
|
|
||||||
limit: limit.limit ? parseInt(limit.limit, 10) : undefined,
|
|
||||||
offset: offset.offset ? parseInt(offset.offset, 10) : undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
return reply.send({
|
|
||||||
enrollments: enrollments.map((e) => ({
|
|
||||||
id: e.id,
|
|
||||||
name: e.name,
|
|
||||||
isActive: e.isActive,
|
|
||||||
createdAt: e.createdAt,
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Remove an enrollment
|
|
||||||
fastify.delete('/enrollments/:id', async (request: FastifyRequest, reply: FastifyReply) => {
|
|
||||||
const authReq = request as FastifyRequest & { user?: { id: string } };
|
|
||||||
const userId = authReq.user?.id;
|
|
||||||
|
|
||||||
if (!userId) {
|
|
||||||
return reply.code(401).send({ error: 'User ID required' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const enrollmentId = (request.params as { id: string }).id;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const enrollment = await voiceEnrollmentService.removeEnrollment(
|
|
||||||
enrollmentId,
|
|
||||||
userId
|
|
||||||
);
|
|
||||||
return reply.send({
|
|
||||||
enrollment: {
|
|
||||||
id: enrollment.id,
|
|
||||||
name: enrollment.name,
|
|
||||||
isActive: enrollment.isActive,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : 'Removal failed';
|
|
||||||
return reply.code(404).send({ error: message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Analyze a single audio file
|
|
||||||
fastify.post('/analyze', async (request: FastifyRequest, reply: FastifyReply) => {
|
|
||||||
const authReq = request as FastifyRequest & { user?: { id: string } };
|
|
||||||
const userId = authReq.user?.id;
|
|
||||||
|
|
||||||
if (!userId) {
|
|
||||||
return reply.code(401).send({ error: 'User ID required' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = request.body as {
|
|
||||||
audio: Buffer;
|
|
||||||
enrollmentId?: string;
|
|
||||||
audioUrl?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!body.audio) {
|
|
||||||
return reply.code(400).send({ error: 'audio is required' });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await analysisService.analyze(userId, body.audio, {
|
|
||||||
enrollmentId: body.enrollmentId,
|
|
||||||
audioUrl: body.audioUrl,
|
|
||||||
});
|
|
||||||
return reply.code(201).send({
|
|
||||||
analysis: {
|
|
||||||
id: result.id,
|
|
||||||
isSynthetic: result.isSynthetic,
|
|
||||||
confidence: result.confidence,
|
|
||||||
analysisResult: result.analysisResult,
|
|
||||||
createdAt: result.createdAt,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : 'Analysis failed';
|
|
||||||
return reply.code(422).send({ error: message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get analysis result by ID
|
|
||||||
fastify.get('/results/:id', async (request: FastifyRequest, reply: FastifyReply) => {
|
|
||||||
const authReq = request as FastifyRequest & { user?: { id: string } };
|
|
||||||
const userId = authReq.user?.id;
|
|
||||||
|
|
||||||
if (!userId) {
|
|
||||||
return reply.code(401).send({ error: 'User ID required' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const analysisId = (request.params as { id: string }).id;
|
|
||||||
const result = await analysisService.getResult(analysisId, userId);
|
|
||||||
|
|
||||||
if (!result) {
|
|
||||||
return reply.code(404).send({ error: 'Analysis not found' });
|
|
||||||
}
|
|
||||||
|
|
||||||
return reply.send({
|
|
||||||
analysis: {
|
|
||||||
id: result.id,
|
|
||||||
isSynthetic: result.isSynthetic,
|
|
||||||
confidence: result.confidence,
|
|
||||||
analysisResult: result.analysisResult,
|
|
||||||
createdAt: result.createdAt,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get analysis history
|
|
||||||
fastify.get('/history', async (request: FastifyRequest, reply: FastifyReply) => {
|
|
||||||
const authReq = request as FastifyRequest & { user?: { id: string } };
|
|
||||||
const userId = authReq.user?.id;
|
|
||||||
|
|
||||||
if (!userId) {
|
|
||||||
return reply.code(401).send({ error: 'User ID required' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const query = request.query as {
|
|
||||||
limit?: string;
|
|
||||||
offset?: string;
|
|
||||||
isSynthetic?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const results = await analysisService.getHistory(userId, {
|
|
||||||
limit: query.limit ? parseInt(query.limit, 10) : undefined,
|
|
||||||
offset: query.offset ? parseInt(query.offset, 10) : undefined,
|
|
||||||
isSynthetic: query.isSynthetic !== undefined
|
|
||||||
? query.isSynthetic === 'true'
|
|
||||||
: undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
return reply.send({
|
|
||||||
analyses: results.map((r) => ({
|
|
||||||
id: r.id,
|
|
||||||
isSynthetic: r.isSynthetic,
|
|
||||||
confidence: r.confidence,
|
|
||||||
createdAt: r.createdAt,
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Batch analyze multiple audio files
|
|
||||||
fastify.post('/batch', async (request: FastifyRequest, reply: FastifyReply) => {
|
|
||||||
const authReq = request as FastifyRequest & { user?: { id: string } };
|
|
||||||
const userId = authReq.user?.id;
|
|
||||||
|
|
||||||
if (!userId) {
|
|
||||||
return reply.code(401).send({ error: 'User ID required' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = request.body as {
|
|
||||||
files: Array<{
|
|
||||||
name: string;
|
|
||||||
audio: Buffer;
|
|
||||||
audioUrl?: string;
|
|
||||||
}>;
|
|
||||||
enrollmentId?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!body.files || body.files.length === 0) {
|
|
||||||
return reply.code(400).send({ error: 'files array is required' });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await batchAnalysisService.analyzeBatch(
|
|
||||||
userId,
|
|
||||||
body.files.map((f) => ({
|
|
||||||
name: f.name,
|
|
||||||
buffer: f.audio,
|
|
||||||
audioUrl: f.audioUrl,
|
|
||||||
})),
|
|
||||||
{
|
|
||||||
enrollmentId: body.enrollmentId,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
return reply.code(201).send({
|
|
||||||
jobId: result.jobId,
|
|
||||||
results: result.results.map((r) => ({
|
|
||||||
id: r.id,
|
|
||||||
isSynthetic: r.isSynthetic,
|
|
||||||
confidence: r.confidence,
|
|
||||||
})),
|
|
||||||
summary: result.summary,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : 'Batch analysis failed';
|
|
||||||
return reply.code(422).send({ error: message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "../../tsconfig.base.json",
|
|
||||||
"compilerOptions": {
|
|
||||||
"outDir": "./dist",
|
|
||||||
"rootDir": "./src",
|
|
||||||
"declaration": true,
|
|
||||||
"declarationMap": true,
|
|
||||||
"sourceMap": true
|
|
||||||
},
|
|
||||||
"include": ["src/**/*"],
|
|
||||||
"exclude": ["node_modules", "dist"]
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
packages:
|
packages:
|
||||||
- "apps/*"
|
|
||||||
- "packages/*"
|
- "packages/*"
|
||||||
- "services/*"
|
- "services/*"
|
||||||
|
|||||||
Reference in New Issue
Block a user