feat: add SpamShield router for spam detection and call analysis
- Create tRPC router with checkNumber, classifySMS, classifyCall endpoints - Add protected procedures for rule CRUD, feedback submission, and stats - Implement service layer with phone number normalization and audit logging - Add ML engine with BERT stub, feature extraction, and rule engine - Implement reputation API module with circuit breaker and caching - Write comprehensive tests (34 tests) for all layers - Wire spamshield router into app root router
This commit is contained in:
@@ -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;
|
||||
|
||||
218
web/src/server/api/routers/spamshield.test.ts
Normal file
218
web/src/server/api/routers/spamshield.test.ts
Normal file
@@ -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<Ctx>().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> = {}): 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);
|
||||
});
|
||||
});
|
||||
75
web/src/server/api/routers/spamshield.ts
Normal file
75
web/src/server/api/routers/spamshield.ts
Normal file
@@ -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);
|
||||
}),
|
||||
});
|
||||
36
web/src/server/api/schemas/spamshield.ts
Normal file
36
web/src/server/api/schemas/spamshield.ts
Normal file
@@ -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"),
|
||||
});
|
||||
Reference in New Issue
Block a user