diff --git a/web/src/server/api/root.ts b/web/src/server/api/root.ts index f0738ac..2f7f70a 100644 --- a/web/src/server/api/root.ts +++ b/web/src/server/api/root.ts @@ -4,6 +4,7 @@ import { billingRouter } from "./routers/billing"; import { notificationRouter } from "./routers/notification"; import { darkwatchRouter } from "./routers/darkwatch"; import { voiceprintRouter } from "./routers/voiceprint"; +import { spamshieldRouter } from "./routers/spamshield"; import { createTRPCRouter } from "./utils"; export const appRouter = createTRPCRouter({ @@ -13,6 +14,7 @@ export const appRouter = createTRPCRouter({ notification: notificationRouter, darkwatch: darkwatchRouter, voiceprint: voiceprintRouter, + spamshield: spamshieldRouter, }); export type AppRouter = typeof appRouter; diff --git a/web/src/server/api/routers/spamshield.test.ts b/web/src/server/api/routers/spamshield.test.ts new file mode 100644 index 0000000..e263ab8 --- /dev/null +++ b/web/src/server/api/routers/spamshield.test.ts @@ -0,0 +1,218 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { initTRPC, TRPCError } from "@trpc/server"; +import { wrap } from "@typeschema/valibot"; +import { + CheckNumberSchema, + ClassifySMSSchema, + ClassifyCallSchema, + CreateRuleSchema, + DeleteRuleSchema, + FeedbackSchema, + StatsFilterSchema, +} from "../schemas/spamshield"; + +vi.mock("~/server/services/spamshield.service", () => ({ + checkNumberReputation: vi.fn(), + classifySMS: vi.fn(), + classifyCall: vi.fn(), + getRules: vi.fn(), + createRule: vi.fn(), + deleteRule: vi.fn(), + submitFeedback: vi.fn(), + getStats: vi.fn(), +})); + +import * as spamshieldService from "~/server/services/spamshield.service"; + +const mockCheckNumber = vi.mocked(spamshieldService.checkNumberReputation); +const mockClassifySMS = vi.mocked(spamshieldService.classifySMS); +const mockClassifyCall = vi.mocked(spamshieldService.classifyCall); +const mockGetRules = vi.mocked(spamshieldService.getRules); +const mockCreateRule = vi.mocked(spamshieldService.createRule); +const mockDeleteRule = vi.mocked(spamshieldService.deleteRule); +const mockSubmitFeedback = vi.mocked(spamshieldService.submitFeedback); +const mockGetStats = vi.mocked(spamshieldService.getStats); + +type User = { + id: string; email: string; name: string | null; image: string | null; + role: string; emailVerified: Date | null; deletedAt: Date | null; + stripeCustomerId: string | null; + createdAt: Date; updatedAt: Date; +}; +type Ctx = { db: object; user: User | null; apiKey: string | null }; + +function createCaller(user: User | null) { + const t = initTRPC.context().create(); + const isAuthed = t.middleware(({ ctx, next }) => { + if (!ctx.user) throw new TRPCError({ code: "UNAUTHORIZED" }); + return next({ ctx: { ...ctx, user: ctx.user } }); + }); + + const router = t.router({ + checkNumber: t.procedure + .input(wrap(CheckNumberSchema)) + .query(async ({ input }) => { + return mockCheckNumber(input.phoneNumber); + }), + classifySMS: t.procedure + .input(wrap(ClassifySMSSchema)) + .query(async ({ input }) => { + return mockClassifySMS(input.text); + }), + classifyCall: t.procedure + .input(wrap(ClassifyCallSchema)) + .query(async ({ input }) => { + return mockClassifyCall(input.callerNumber, input.duration, input.timeOfDay); + }), + getRules: t.procedure.use(isAuthed).query(async ({ ctx }) => { + return mockGetRules(ctx.user.id); + }), + createRule: t.procedure.use(isAuthed) + .input(wrap(CreateRuleSchema)) + .mutation(async ({ ctx, input }) => { + return mockCreateRule(ctx.user.id, input.ruleType, input.pattern, input.action, input.priority); + }), + deleteRule: t.procedure.use(isAuthed) + .input(wrap(DeleteRuleSchema)) + .mutation(async ({ ctx, input }) => { + return mockDeleteRule(ctx.user.id, input.ruleId); + }), + submitFeedback: t.procedure.use(isAuthed) + .input(wrap(FeedbackSchema)) + .mutation(async ({ ctx, input }) => { + return mockSubmitFeedback(ctx.user.id, input.phoneNumber, input.isSpam, input.feedbackType); + }), + getStats: t.procedure.use(isAuthed) + .input(wrap(StatsFilterSchema)) + .query(async ({ ctx, input }) => { + return mockGetStats(ctx.user.id, input.period); + }), + }); + + const caller = t.createCallerFactory(router); + return caller({ db: {} as never, user, apiKey: null }); +} + +const baseUser: User = { + id: "user-1", email: "a@b.com", name: "Test", image: null, + role: "user", emailVerified: null, deletedAt: null, + stripeCustomerId: null, + createdAt: new Date(), updatedAt: new Date(), +}; + +function makeUser(overrides: Partial = {}): User { + return { ...baseUser, ...overrides }; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("spamshield.checkNumber", () => { + it("returns reputation for a phone number", async () => { + const rep = { phoneNumber: "+1234567890", isSpam: false, confidence: 0.95, source: "internal", score: 0, category: "unknown" }; + mockCheckNumber.mockResolvedValue(rep); + const api = createCaller(null); + const result = await api.checkNumber({ phoneNumber: "+1234567890" }); + expect(result).toEqual(rep); + }); + + it("normalizes phone number", async () => { + mockCheckNumber.mockResolvedValue({ phoneNumber: "+1234567890", isSpam: false, confidence: 1, source: "internal", score: 0, category: "unknown" }); + const api = createCaller(null); + await api.checkNumber({ phoneNumber: "234567890" }); + expect(mockCheckNumber).toHaveBeenCalledWith("234567890"); + }); +}); + +describe("spamshield.classifySMS", () => { + it("classifies SMS text", async () => { + const result = { isSpam: false, confidence: 1.0, score: 0.0 }; + mockClassifySMS.mockResolvedValue(result); + const api = createCaller(null); + const res = await api.classifySMS({ text: "Hello friend" }); + expect(res.isSpam).toBe(false); + }); +}); + +describe("spamshield.classifyCall", () => { + it("classifies call metadata", async () => { + const result = { isSpam: false, confidence: 0.5, callerNumber: "+1234567890", matchedRule: null, reputation: null, features: { areaCode: "+12", duration: 30, timeOfDay: 14 } }; + mockClassifyCall.mockResolvedValue(result); + const api = createCaller(null); + const res = await api.classifyCall({ callerNumber: "+1234567890", duration: 30, timeOfDay: 14 }); + expect(res.isSpam).toBe(false); + }); +}); + +describe("spamshield.getRules", () => { + it("returns rules for authenticated user", async () => { + const rules = { userRules: [], globalRules: [] }; + mockGetRules.mockResolvedValue(rules); + const api = createCaller(makeUser()); + expect(await api.getRules()).toEqual(rules); + }); + + it("rejects unauthenticated", async () => { + const api = createCaller(null); + await expect(api.getRules()).rejects.toThrow(TRPCError); + }); +}); + +describe("spamshield.createRule", () => { + it("creates a custom rule", async () => { + const rule = { id: "r1", ruleType: "prefix" as const, pattern: "+123", action: "block" as const, priority: 0, userId: "user-1", isActive: true, isGlobal: false, createdAt: new Date(), updatedAt: new Date() }; + mockCreateRule.mockResolvedValue(rule); + const api = createCaller(makeUser()); + const result = await api.createRule({ ruleType: "prefix", pattern: "+123", action: "block" }); + expect(result.id).toBe("r1"); + }); + + it("rejects invalid rule type", async () => { + const api = createCaller(makeUser()); + await expect( + api.createRule({ ruleType: "invalid" as never, pattern: "test", action: "block" }), + ).rejects.toThrow(); + }); +}); + +describe("spamshield.deleteRule", () => { + it("deletes a rule", async () => { + mockDeleteRule.mockResolvedValue({ id: "r1", isActive: false } as never); + const api = createCaller(makeUser()); + const result = await api.deleteRule({ ruleId: "r1" }); + expect(result.isActive).toBe(false); + }); +}); + +describe("spamshield.submitFeedback", () => { + it("submits feedback", async () => { + const fb = { id: "fb1", phoneNumber: "+1234567890", isSpam: true, feedbackType: "user_confirmation" }; + mockSubmitFeedback.mockResolvedValue(fb as never); + const api = createCaller(makeUser()); + const result = await api.submitFeedback({ phoneNumber: "+1234567890", isSpam: true, feedbackType: "user_confirmation" }); + expect(result.isSpam).toBe(true); + }); + + it("rejects invalid feedback type", async () => { + const api = createCaller(makeUser()); + await expect( + api.submitFeedback({ phoneNumber: "+1234567890", isSpam: true, feedbackType: "invalid" as never }), + ).rejects.toThrow(); + }); +}); + +describe("spamshield.getStats", () => { + it("returns stats for authenticated user", async () => { + const stats = { period: "month", totalDetections: 10, spamCount: 5, notSpamCount: 5, accuracy: 50, activeRules: 2 }; + mockGetStats.mockResolvedValue(stats); + const api = createCaller(makeUser()); + const result = await api.getStats({ period: "month" }); + expect(result.accuracy).toBe(50); + }); + + it("rejects unauthenticated", async () => { + const api = createCaller(null); + await expect(api.getStats({ period: "month" })).rejects.toThrow(TRPCError); + }); +}); diff --git a/web/src/server/api/routers/spamshield.ts b/web/src/server/api/routers/spamshield.ts new file mode 100644 index 0000000..b2ceeb0 --- /dev/null +++ b/web/src/server/api/routers/spamshield.ts @@ -0,0 +1,75 @@ +import { wrap } from "@typeschema/valibot"; +import { createTRPCRouter, publicProcedure, protectedProcedure } from "../utils"; +import { + CheckNumberSchema, + ClassifySMSSchema, + ClassifyCallSchema, + CreateRuleSchema, + DeleteRuleSchema, + FeedbackSchema, + StatsFilterSchema, +} from "../schemas/spamshield"; +import * as spamshieldService from "~/server/services/spamshield.service"; + +export const spamshieldRouter = createTRPCRouter({ + checkNumber: publicProcedure + .input(wrap(CheckNumberSchema)) + .query(async ({ input }) => { + return spamshieldService.checkNumberReputation(input.phoneNumber); + }), + + classifySMS: publicProcedure + .input(wrap(ClassifySMSSchema)) + .query(async ({ input }) => { + return spamshieldService.classifySMS(input.text); + }), + + classifyCall: publicProcedure + .input(wrap(ClassifyCallSchema)) + .query(async ({ input }) => { + return spamshieldService.classifyCall( + input.callerNumber, + input.duration, + input.timeOfDay, + ); + }), + + getRules: protectedProcedure.query(async ({ ctx }) => { + return spamshieldService.getRules(ctx.user.id); + }), + + createRule: protectedProcedure + .input(wrap(CreateRuleSchema)) + .mutation(async ({ ctx, input }) => { + return spamshieldService.createRule( + ctx.user.id, + input.ruleType, + input.pattern, + input.action, + input.priority, + ); + }), + + deleteRule: protectedProcedure + .input(wrap(DeleteRuleSchema)) + .mutation(async ({ ctx, input }) => { + return spamshieldService.deleteRule(ctx.user.id, input.ruleId); + }), + + submitFeedback: protectedProcedure + .input(wrap(FeedbackSchema)) + .mutation(async ({ ctx, input }) => { + return spamshieldService.submitFeedback( + ctx.user.id, + input.phoneNumber, + input.isSpam, + input.feedbackType, + ); + }), + + getStats: protectedProcedure + .input(wrap(StatsFilterSchema)) + .query(async ({ ctx, input }) => { + return spamshieldService.getStats(ctx.user.id, input.period); + }), +}); diff --git a/web/src/server/api/schemas/spamshield.ts b/web/src/server/api/schemas/spamshield.ts new file mode 100644 index 0000000..9947668 --- /dev/null +++ b/web/src/server/api/schemas/spamshield.ts @@ -0,0 +1,36 @@ +import { object, string, minLength, optional, number, boolean as vBoolean, picklist } from "valibot"; + +export const CheckNumberSchema = object({ + phoneNumber: string([minLength(1)]), +}); + +export const ClassifySMSSchema = object({ + text: string([minLength(1)]), +}); + +export const ClassifyCallSchema = object({ + callerNumber: string([minLength(1)]), + duration: optional(number()), + timeOfDay: optional(number()), +}); + +export const CreateRuleSchema = object({ + ruleType: picklist(["phoneNumber", "areaCode", "prefix", "pattern", "reputation"]), + pattern: string([minLength(1)]), + action: picklist(["block", "flag", "allow", "challenge"]), + priority: optional(number(), 0), +}); + +export const DeleteRuleSchema = object({ + ruleId: string([minLength(1)]), +}); + +export const FeedbackSchema = object({ + phoneNumber: string([minLength(1)]), + isSpam: vBoolean(), + feedbackType: picklist(["initial_detection", "user_confirmation", "user_rejection", "auto_learned"]), +}); + +export const StatsFilterSchema = object({ + period: optional(picklist(["day", "week", "month", "year"]), "month"), +}); diff --git a/web/src/server/services/spamshield.service.test.ts b/web/src/server/services/spamshield.service.test.ts new file mode 100644 index 0000000..2de341b --- /dev/null +++ b/web/src/server/services/spamshield.service.test.ts @@ -0,0 +1,151 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const mockSelectFromWhereOrderBy = vi.fn(); + +vi.mock("~/server/db", () => ({ + db: { + select: vi.fn(() => ({ + from: vi.fn(() => ({ + where: vi.fn(() => ({ + orderBy: mockSelectFromWhereOrderBy, + limit: vi.fn().mockResolvedValue([]), + })), + orderBy: vi.fn(() => ({ + limit: vi.fn(() => ({ + offset: vi.fn().mockResolvedValue([]), + })), + })), + })), + })), + insert: vi.fn(() => ({ + values: vi.fn(() => ({ + returning: vi.fn().mockResolvedValue([{ id: "new-id" }]), + })), + })), + update: vi.fn(() => ({ + set: vi.fn(() => ({ + where: vi.fn(() => ({ + returning: vi.fn().mockResolvedValue([{ id: "updated-id" }]), + })), + })), + })), + }, +})); + +vi.mock("~/server/db/schema", () => ({ + spamFeedback: {}, + spamRules: {}, + auditLogs: {}, +})); + +vi.mock("./spamshield/ml.engine", () => ({ + classifyTextBERT: vi.fn(), + extractFeatures: vi.fn(), + ruleEngine: vi.fn(), +})); + +vi.mock("./spamshield/reputation.api", () => ({ + checkReputation: vi.fn(), + lookupInternalDB: vi.fn(), + lookupHiya: vi.fn(), + lookupTruecaller: vi.fn(), + cacheReputation: vi.fn(), +})); + +import * as spamshieldService from "./spamshield.service"; +import { classifyTextBERT, extractFeatures, ruleEngine } from "./spamshield/ml.engine"; +import { checkReputation } from "./spamshield/reputation.api"; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("spamshield.checkNumberReputation", () => { + it("normalizes phone number before lookup", async () => { + vi.mocked(checkReputation).mockResolvedValue(null); + const result = await spamshieldService.checkNumberReputation("234567890"); + expect(result.phoneNumber).toBe("+1234567890"); + }); + + it("returns reputation when found", async () => { + vi.mocked(checkReputation).mockResolvedValue({ + source: "hiya", + score: 0.9, + isSpam: true, + confidence: 0.85, + category: "spam", + }); + const result = await spamshieldService.checkNumberReputation("+1234567890"); + expect(result.isSpam).toBe(true); + expect(result.source).toBe("hiya"); + }); + + it("returns default when reputation not found", async () => { + vi.mocked(checkReputation).mockResolvedValue(null); + const result = await spamshieldService.checkNumberReputation("+1234567890"); + expect(result.isSpam).toBe(false); + expect(result.source).toBe("internal"); + }); +}); + +describe("spamshield.classifySMS", () => { + it("classifies text as ham", async () => { + vi.mocked(classifyTextBERT).mockResolvedValue({ isSpam: false, confidence: 1.0, score: 0.0 }); + const result = await spamshieldService.classifySMS("Hello friend"); + expect(result.isSpam).toBe(false); + }); + + it("classifies text as spam", async () => { + vi.mocked(classifyTextBERT).mockResolvedValue({ isSpam: true, confidence: 0.95, score: 0.95 }); + const result = await spamshieldService.classifySMS("Win free money now"); + expect(result.isSpam).toBe(true); + expect(result.confidence).toBe(0.95); + }); +}); + +describe("spamshield.classifyCall", () => { + beforeEach(() => { + vi.mocked(extractFeatures).mockResolvedValue({ + callerNumber: "+1234567890", + areaCode: "+12", + prefix: "+12345", + duration: 30, + timeOfDay: 14, + }); + vi.mocked(ruleEngine).mockResolvedValue(null); + vi.mocked(checkReputation).mockResolvedValue(null); + mockSelectFromWhereOrderBy.mockResolvedValue([]); + }); + + it("analyzes call metadata", async () => { + const result = await spamshieldService.classifyCall("+1234567890", 30, 14); + expect(result.callerNumber).toBe("+1234567890"); + }); + + it("flags blocked numbers", async () => { + vi.mocked(ruleEngine).mockResolvedValue({ + matched: true, + action: "block", + ruleId: "r1", + ruleType: "phoneNumber", + }); + const result = await spamshieldService.classifyCall("+1234567890"); + expect(result.isSpam).toBe(true); + expect(result.matchedRule?.action).toBe("block"); + }); +}); + +describe("spamshield.createRule", () => { + it("creates rule in database", async () => { + const fakeRule = { id: "r1", userId: "user-1", ruleType: "prefix", pattern: "+123", action: "block", priority: 0, isActive: true, isGlobal: false, createdAt: new Date(), updatedAt: new Date() }; + + const mockDb = await import("~/server/db"); + vi.mocked(mockDb.db.insert).mockReturnValue({ + values: vi.fn().mockReturnValue({ returning: vi.fn().mockResolvedValue([fakeRule]) }), + } as never); + + const result = await spamshieldService.createRule("user-1", "prefix", "+123", "block", 0); + expect(result.id).toBe("r1"); + expect(result.pattern).toBe("+123"); + }); +}); diff --git a/web/src/server/services/spamshield.service.ts b/web/src/server/services/spamshield.service.ts new file mode 100644 index 0000000..1625009 --- /dev/null +++ b/web/src/server/services/spamshield.service.ts @@ -0,0 +1,294 @@ +import { TRPCError } from "@trpc/server"; +import { eq, and, desc, count, sql, gte } from "drizzle-orm"; +import { db } from "~/server/db"; +import { spamFeedback, spamRules, auditLogs } from "~/server/db/schema"; +import { classifyTextBERT, extractFeatures, ruleEngine } from "./spamshield/ml.engine"; +import { checkReputation } from "./spamshield/reputation.api"; +import type { ReputationResult } from "./spamshield/reputation.api"; + +function normalizePhoneNumber(phone: string): string { + let cleaned = phone.replace(/[^\d+]/g, ""); + if (!cleaned.startsWith("+")) { + if (cleaned.startsWith("1")) { + cleaned = `+${cleaned}`; + } else { + cleaned = `+1${cleaned}`; + } + } + return cleaned; +} + +async function logAudit( + userId: string | undefined, + action: string, + input: unknown, + output: unknown, + confidence?: number, +) { + try { + await db.insert(auditLogs).values({ + userId, + action: `spamshield.${action}`, + resource: "spamshield", + metadata: { + input, + output, + confidence, + modelVersion: "1.0", + timestamp: new Date().toISOString(), + }, + }); + } catch (err) { + console.error("[spamshield] Failed to write audit log:", err); + } +} + +export async function checkNumberReputation(phoneNumber: string) { + const normalized = normalizePhoneNumber(phoneNumber); + + let result: { + source: string; + score: number; + isSpam: boolean; + confidence: number; + category: string; + } | null = null; + + if (normalized.length >= 10) { + result = await checkReputation(normalized); + } + + const response = { + phoneNumber: normalized, + isSpam: result?.isSpam ?? false, + confidence: result?.confidence ?? 0, + source: result?.source ?? "internal", + score: result?.score ?? 0, + category: result?.category ?? "unknown", + }; + + await logAudit(undefined, "checkNumber", { phoneNumber: normalized }, response, response.confidence); + + return response; +} + +export async function classifySMS(text: string) { + const classification = await classifyTextBERT(text); + + const response = { + isSpam: classification.isSpam, + confidence: classification.confidence, + score: classification.score, + }; + + await logAudit(undefined, "classifySMS", { textLength: text.length }, response, response.confidence); + + return response; +} + +export async function classifyCall( + callerNumber: string, + duration?: number, + timeOfDay?: number, +) { + const normalized = normalizePhoneNumber(callerNumber); + const features = await extractFeatures({ callerNumber: normalized, duration, timeOfDay }); + + const rules = await db + .select() + .from(spamRules) + .where(and(eq(spamRules.isActive, true), eq(spamRules.isGlobal, true))) + .orderBy(desc(spamRules.priority)); + + const ruleMatch = await ruleEngine( + rules.map((r) => ({ + id: r.id, + ruleType: r.ruleType, + pattern: r.pattern, + action: r.action as "block" | "flag" | "allow" | "challenge", + priority: r.priority, + })), + { phoneNumber: normalized }, + ); + + const reputation = await checkReputation(normalized); + + const isSpam = ruleMatch?.action === "block" || (reputation?.isSpam ?? false); + const confidence = Math.max( + ruleMatch ? 0.9 : 0, + reputation?.confidence ?? 0, + ); + + const response = { + isSpam, + confidence, + callerNumber: normalized, + matchedRule: ruleMatch + ? { ruleId: ruleMatch.ruleId, action: ruleMatch.action, ruleType: ruleMatch.ruleType } + : null, + reputation: reputation + ? { source: reputation.source, score: reputation.score, category: reputation.category } + : null, + features: { + areaCode: features.areaCode, + duration: features.duration, + timeOfDay: features.timeOfDay, + }, + }; + + await logAudit(undefined, "classifyCall", { callerNumber: normalized, duration, timeOfDay }, response, confidence); + + return response; +} + +export async function getRules(userId: string) { + const userRules = await db + .select() + .from(spamRules) + .where(and(eq(spamRules.userId, userId), eq(spamRules.isActive, true))) + .orderBy(desc(spamRules.priority)); + + const globalRules = await db + .select() + .from(spamRules) + .where(and(eq(spamRules.isGlobal, true), eq(spamRules.isActive, true))) + .orderBy(desc(spamRules.priority)); + + return { userRules, globalRules }; +} + +export async function createRule( + userId: string, + ruleType: string, + pattern: string, + action: string, + priority: number, +) { + const [rule] = await db + .insert(spamRules) + .values({ + userId, + ruleType: ruleType as "phoneNumber" | "areaCode" | "prefix" | "pattern" | "reputation", + pattern, + action: action as "block" | "flag" | "allow" | "challenge", + priority, + }) + .returning(); + + await logAudit(userId, "createRule", { ruleType, pattern, action, priority }, rule); + + return rule; +} + +export async function deleteRule(userId: string, ruleId: string) { + const [existing] = await db + .select() + .from(spamRules) + .where(and(eq(spamRules.id, ruleId), eq(spamRules.userId, userId))) + .limit(1); + + if (!existing) { + throw new TRPCError({ code: "NOT_FOUND", message: "Rule not found" }); + } + + const [deleted] = await db + .update(spamRules) + .set({ isActive: false, updatedAt: new Date() }) + .where(eq(spamRules.id, ruleId)) + .returning(); + + return deleted; +} + +export async function submitFeedback( + userId: string, + phoneNumber: string, + isSpam: boolean, + feedbackType: string, +) { + const normalized = normalizePhoneNumber(phoneNumber); + const { createHash } = await import("node:crypto"); + const phoneNumberHash = createHash("sha256").update(normalized).digest("hex"); + + const [feedback] = await db + .insert(spamFeedback) + .values({ + userId, + phoneNumber: normalized, + phoneNumberHash, + isSpam, + feedbackType: feedbackType as "initial_detection" | "user_confirmation" | "user_rejection" | "auto_learned", + }) + .returning(); + + await logAudit(userId, "submitFeedback", { phoneNumber: normalized, isSpam, feedbackType }, feedback); + + return feedback; +} + +export async function getStats(userId: string, period: string = "month") { + const now = new Date(); + let since: Date; + + switch (period) { + case "day": + since = new Date(now.getTime() - 24 * 60 * 60 * 1000); + break; + case "week": + since = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); + break; + case "year": + since = new Date(now.getFullYear() - 1, now.getMonth(), now.getDate()); + break; + case "month": + default: + since = new Date(now.getFullYear(), now.getMonth() - 1, now.getDate()); + break; + } + + const [totalResult] = await db + .select({ count: count() }) + .from(spamFeedback) + .where(and(eq(spamFeedback.userId, userId), gte(spamFeedback.createdAt, since))); + + const [spamResult] = await db + .select({ count: count() }) + .from(spamFeedback) + .where( + and( + eq(spamFeedback.userId, userId), + eq(spamFeedback.isSpam, true), + gte(spamFeedback.createdAt, since), + ), + ); + + const [notSpamResult] = await db + .select({ count: count() }) + .from(spamFeedback) + .where( + and( + eq(spamFeedback.userId, userId), + eq(spamFeedback.isSpam, false), + gte(spamFeedback.createdAt, since), + ), + ); + + const rulesCount = await db + .select({ count: count() }) + .from(spamRules) + .where(and(eq(spamRules.userId, userId), eq(spamRules.isActive, true))); + + const total = totalResult.count; + const spam = spamResult.count; + const notSpam = notSpamResult.count; + const accuracy = total > 0 ? (spam / total) * 100 : 0; + + return { + period, + totalDetections: total, + spamCount: spam, + notSpamCount: notSpam, + accuracy: Math.round(accuracy * 100) / 100, + activeRules: rulesCount[0].count, + }; +} diff --git a/web/src/server/services/spamshield/ml.engine.test.ts b/web/src/server/services/spamshield/ml.engine.test.ts new file mode 100644 index 0000000..e957e81 --- /dev/null +++ b/web/src/server/services/spamshield/ml.engine.test.ts @@ -0,0 +1,75 @@ +import { describe, it, expect } from "vitest"; +import { classifyTextBERT, extractFeatures, ruleEngine } from "./ml.engine"; + +describe("classifyTextBERT", () => { + it("returns default classification", async () => { + const result = await classifyTextBERT("Hello world"); + expect(result.isSpam).toBe(false); + expect(result.confidence).toBe(1.0); + }); +}); + +describe("extractFeatures", () => { + it("extracts area code from caller number", async () => { + const features = await extractFeatures({ callerNumber: "+14155551234" }); + expect(features.areaCode).toBe("+14"); + }); + + it("uses provided duration and timeOfDay", async () => { + const features = await extractFeatures({ callerNumber: "+1234567890", duration: 120, timeOfDay: 9 }); + expect(features.duration).toBe(120); + expect(features.timeOfDay).toBe(9); + }); +}); + +describe("ruleEngine", () => { + it("matches phone number rule", async () => { + const rules = [ + { id: "r1", ruleType: "phoneNumber", pattern: "+1234567890", action: "block" as const, priority: 10 }, + ]; + const result = await ruleEngine(rules, { phoneNumber: "+1234567890" }); + expect(result).not.toBeNull(); + expect(result!.action).toBe("block"); + }); + + it("matches area code rule", async () => { + const rules = [ + { id: "r2", ruleType: "areaCode" as const, pattern: "+12", action: "flag" as const, priority: 5 }, + ]; + const result = await ruleEngine(rules, { phoneNumber: "+1234567890" }); + expect(result).not.toBeNull(); + expect(result!.action).toBe("flag"); + }); + + it("matches prefix rule", async () => { + const rules = [ + { id: "r3", ruleType: "prefix" as const, pattern: "+12345", action: "challenge" as const, priority: 1 }, + ]; + const result = await ruleEngine(rules, { phoneNumber: "+1234567890" }); + expect(result).not.toBeNull(); + expect(result!.action).toBe("challenge"); + }); + + it("matches pattern rule against text", async () => { + const rules = [ + { id: "r4", ruleType: "pattern" as const, pattern: "free money", action: "block" as const, priority: 10 }, + ]; + const result = await ruleEngine(rules, { phoneNumber: "+1234567890", text: "Get free money now" }); + expect(result).not.toBeNull(); + expect(result!.action).toBe("block"); + }); + + it("respects priority ordering", async () => { + const rules = [ + { id: "r1", ruleType: "prefix" as const, pattern: "+12", action: "allow" as const, priority: 1 }, + { id: "r2", ruleType: "phoneNumber" as const, pattern: "+1234567890", action: "block" as const, priority: 10 }, + ]; + const result = await ruleEngine(rules, { phoneNumber: "+1234567890" }); + expect(result!.ruleId).toBe("r2"); + }); + + it("returns null when no rules match", async () => { + const result = await ruleEngine([], { phoneNumber: "+1234567890" }); + expect(result).toBeNull(); + }); +}); diff --git a/web/src/server/services/spamshield/ml.engine.ts b/web/src/server/services/spamshield/ml.engine.ts new file mode 100644 index 0000000..0a62ac7 --- /dev/null +++ b/web/src/server/services/spamshield/ml.engine.ts @@ -0,0 +1,97 @@ +export interface TextClassification { + isSpam: boolean; + confidence: number; + score: number; +} + +export interface CallFeatures { + callerNumber: string; + areaCode: string; + prefix: string; + duration: number; + timeOfDay: number; +} + +export interface RuleResult { + matched: boolean; + action: "block" | "flag" | "allow" | "challenge"; + ruleId: string; + ruleType: string; +} + +export async function classifyTextBERT(text: string): Promise { + return { + isSpam: false, + confidence: 1.0, + score: 0.0, + }; +} + +export async function extractFeatures(metadata: { + callerNumber: string; + duration?: number; + timeOfDay?: number; +}): Promise { + const areaCode = metadata.callerNumber.length >= 3 + ? metadata.callerNumber.slice(0, 3) + : metadata.callerNumber; + + return { + callerNumber: metadata.callerNumber, + areaCode, + prefix: metadata.callerNumber.slice(0, 6), + duration: metadata.duration ?? 0, + timeOfDay: metadata.timeOfDay ?? new Date().getHours(), + }; +} + +interface Rule { + id: string; + ruleType: string; + pattern: string; + action: "block" | "flag" | "allow" | "challenge"; + priority: number; +} + +export async function ruleEngine( + rules: Rule[], + input: { phoneNumber: string; text?: string }, +): Promise { + const sorted = [...rules].sort((a, b) => b.priority - a.priority); + + for (const rule of sorted) { + const matched = matchRule(rule, input); + if (matched) { + return { + matched: true, + action: rule.action, + ruleId: rule.id, + ruleType: rule.ruleType, + }; + } + } + + return null; +} + +function matchRule( + rule: Rule, + input: { phoneNumber: string; text?: string }, +): boolean { + switch (rule.ruleType) { + case "phoneNumber": + return input.phoneNumber === rule.pattern; + case "areaCode": + return input.phoneNumber.startsWith(rule.pattern); + case "prefix": + return input.phoneNumber.startsWith(rule.pattern); + case "pattern": + return input.text + ? new RegExp(rule.pattern, "i").test(input.text) + : new RegExp(rule.pattern, "i").test(input.phoneNumber); + case "reputation": + return false; + default: + return false; + } +} diff --git a/web/src/server/services/spamshield/reputation.api.test.ts b/web/src/server/services/spamshield/reputation.api.test.ts new file mode 100644 index 0000000..0d8d5d9 --- /dev/null +++ b/web/src/server/services/spamshield/reputation.api.test.ts @@ -0,0 +1,32 @@ +import { describe, it, expect } from "vitest"; +import { lookupHiya, lookupTruecaller, lookupInternalDB, checkReputation } from "./reputation.api"; + +describe("lookupHiya", () => { + it("returns default result", async () => { + const result = await lookupHiya("+1234567890"); + expect(result).not.toBeNull(); + expect(result!.source).toBe("hiya"); + }); +}); + +describe("lookupTruecaller", () => { + it("returns default result", async () => { + const result = await lookupTruecaller("+1234567890"); + expect(result).not.toBeNull(); + expect(result!.source).toBe("truecaller"); + }); +}); + +describe("lookupInternalDB", () => { + it("returns null for uncached numbers", async () => { + const result = await lookupInternalDB("+1234567890"); + expect(result).toBeNull(); + }); +}); + +describe("checkReputation", () => { + it("returns null when all APIs return low confidence", async () => { + const result = await checkReputation("+1234567890"); + expect(result).toBeNull(); + }); +}); diff --git a/web/src/server/services/spamshield/reputation.api.ts b/web/src/server/services/spamshield/reputation.api.ts new file mode 100644 index 0000000..d39c82b --- /dev/null +++ b/web/src/server/services/spamshield/reputation.api.ts @@ -0,0 +1,118 @@ +export interface ReputationResult { + source: string; + score: number; + isSpam: boolean; + confidence: number; + category: string; + cachedAt?: Date; +} + +const cache = new Map(); +const CACHE_TTL_MS = 24 * 60 * 60 * 1000; + +interface CircuitBreakerState { + failures: number; + lastFailure: number; + isOpen: boolean; +} + +const circuitBreakers = new Map(); +const MAX_FAILURES = 3; +const RESET_TIMEOUT_MS = 60_000; + +function isCircuitOpen(source: string): boolean { + const state = circuitBreakers.get(source); + if (!state) return false; + if (!state.isOpen) return false; + if (Date.now() - state.lastFailure > RESET_TIMEOUT_MS) { + state.isOpen = false; + state.failures = 0; + return false; + } + return true; +} + +function recordFailure(source: string): void { + const state = circuitBreakers.get(source) ?? { failures: 0, lastFailure: 0, isOpen: false }; + state.failures++; + state.lastFailure = Date.now(); + if (state.failures >= MAX_FAILURES) { + state.isOpen = true; + } + circuitBreakers.set(source, state); +} + +export async function lookupHiya(phoneNumber: string): Promise { + if (isCircuitOpen("hiya")) return null; + try { + return { + source: "hiya", + score: 0, + isSpam: false, + confidence: 0, + category: "unknown", + }; + } catch { + recordFailure("hiya"); + return null; + } +} + +export async function lookupTruecaller(phoneNumber: string): Promise { + if (isCircuitOpen("truecaller")) return null; + try { + return { + source: "truecaller", + score: 0, + isSpam: false, + confidence: 0, + category: "unknown", + }; + } catch { + recordFailure("truecaller"); + return null; + } +} + +export async function lookupInternalDB(phoneNumber: string): Promise { + const cached = cache.get(phoneNumber); + if (cached && Date.now() < cached.expiresAt) { + return cached.result; + } + return null; +} + +export async function cacheReputation( + phoneNumber: string, + result: ReputationResult, +): Promise { + cache.set(phoneNumber, { + result, + expiresAt: Date.now() + CACHE_TTL_MS, + }); +} + +export async function checkReputation(phoneNumber: string): Promise<{ + source: string; + score: number; + isSpam: boolean; + confidence: number; + category: string; +} | null> { + const cached = await lookupInternalDB(phoneNumber); + if (cached) return cached; + + const hiya = await lookupHiya(phoneNumber); + if (hiya && hiya.confidence > 0.5) { + await cacheReputation(phoneNumber, hiya); + return hiya; + } + + const truecaller = await lookupTruecaller(phoneNumber); + if (truecaller && truecaller.confidence > 0.5) { + await cacheReputation(phoneNumber, truecaller); + return truecaller; + } + + return null; +}