deep research addressement
This commit is contained in:
@@ -13,6 +13,7 @@ import { schedulerRouter } from "./routers/scheduler";
|
||||
import { extensionRouter } from "./routers/extension";
|
||||
import { blogRouter } from "./routers/blog";
|
||||
import { adminRouter } from "./routers/admin";
|
||||
import { familyRouter } from "./routers/family";
|
||||
import { createTRPCRouter } from "./utils";
|
||||
|
||||
export const appRouter = createTRPCRouter({
|
||||
@@ -31,6 +32,7 @@ export const appRouter = createTRPCRouter({
|
||||
extension: extensionRouter,
|
||||
blog: blogRouter,
|
||||
admin: adminRouter,
|
||||
family: familyRouter,
|
||||
});
|
||||
|
||||
export type AppRouter = typeof appRouter;
|
||||
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
CancelSubscriptionSchema,
|
||||
ReactivateSubscriptionSchema,
|
||||
ListInvoicesSchema,
|
||||
CreateTrialSubscriptionSchema,
|
||||
ChangeTierSchema,
|
||||
} from "../schemas/billing";
|
||||
|
||||
vi.mock("~/server/services/billing.service", () => ({
|
||||
@@ -16,6 +18,14 @@ vi.mock("~/server/services/billing.service", () => ({
|
||||
cancelSubscription: vi.fn(),
|
||||
reactivateSubscription: vi.fn(),
|
||||
listInvoices: vi.fn(),
|
||||
mapStripeProductToTier: vi.fn((p: string) => {
|
||||
if (p.includes("basic")) return "basic";
|
||||
if (p.includes("plus")) return "plus";
|
||||
if (p.includes("premium")) return "premium";
|
||||
return "basic";
|
||||
}),
|
||||
createTrialSubscription: vi.fn(),
|
||||
changeSubscriptionTier: vi.fn(),
|
||||
}));
|
||||
|
||||
const { mockFindFirst } = vi.hoisted(() => ({
|
||||
@@ -32,12 +42,21 @@ vi.mock("~/server/db", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("~/server/lib/tier", () => ({
|
||||
getEffectiveTier: vi.fn((tier: string) => tier),
|
||||
getActiveTrials: vi.fn().mockResolvedValue([]),
|
||||
createFeatureTrial: vi.fn(),
|
||||
}));
|
||||
|
||||
import {
|
||||
createCheckoutSession,
|
||||
createPortalSession,
|
||||
cancelSubscription,
|
||||
reactivateSubscription,
|
||||
listInvoices,
|
||||
createTrialSubscription,
|
||||
changeSubscriptionTier,
|
||||
mapStripeProductToTier,
|
||||
} from "~/server/services/billing.service";
|
||||
import { db } from "~/server/db";
|
||||
|
||||
@@ -46,6 +65,9 @@ const mockCreatePortalSession = vi.mocked(createPortalSession);
|
||||
const mockCancelSubscription = vi.mocked(cancelSubscription);
|
||||
const mockReactivateSubscription = vi.mocked(reactivateSubscription);
|
||||
const mockListInvoices = vi.mocked(listInvoices);
|
||||
const mockCreateTrialSubscription = vi.mocked(createTrialSubscription);
|
||||
const mockChangeSubscriptionTier = vi.mocked(changeSubscriptionTier);
|
||||
const mockMapStripeProductToTier = vi.mocked(mapStripeProductToTier);
|
||||
const mockDb = vi.mocked(db);
|
||||
|
||||
type User = {
|
||||
@@ -85,7 +107,40 @@ function createCaller(user: User | null) {
|
||||
.input(wrap(CreateCheckoutSessionSchema))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const i = input as { priceId: string; returnUrl: string };
|
||||
return mockCreateCheckoutSession(ctx.user.id, ctx.user.email, i.priceId, i.returnUrl);
|
||||
const existing = await mockFindFirst();
|
||||
const currentTier = existing?.tier;
|
||||
const newTier = mockMapStripeProductToTier(i.priceId);
|
||||
const tierOrder = { basic: 0, plus: 1, premium: 2 } as const;
|
||||
const isUpgrade = currentTier && tierOrder[newTier as keyof typeof tierOrder] > tierOrder[currentTier as keyof typeof tierOrder];
|
||||
const isDowngrade = currentTier && tierOrder[newTier as keyof typeof tierOrder] < tierOrder[currentTier as keyof typeof tierOrder];
|
||||
|
||||
if (existing && existing.stripeId && (isUpgrade || isDowngrade)) {
|
||||
return mockChangeSubscriptionTier(existing.stripeId, i.priceId);
|
||||
}
|
||||
|
||||
return mockCreateCheckoutSession(ctx.user.id, ctx.user.email, i.priceId, i.returnUrl, { isUpgrade, isDowngrade });
|
||||
}),
|
||||
createTrialSubscription: t.procedure.use(isAuthed)
|
||||
.input(wrap(CreateTrialSubscriptionSchema))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const existing = await mockFindFirst();
|
||||
if (existing && (existing.status === "active" || existing.status === "trialing")) {
|
||||
throw new TRPCError({ code: "CONFLICT", message: "Already has active subscription" });
|
||||
}
|
||||
return mockCreateTrialSubscription(ctx.user.id, ctx.user.email, (input as { returnUrl: string }).returnUrl);
|
||||
}),
|
||||
changeTier: t.procedure.use(isAuthed)
|
||||
.input(wrap(ChangeTierSchema))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const sub = await mockFindFirst();
|
||||
if (!sub || !sub.stripeId) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "No active subscription" });
|
||||
}
|
||||
const tier = (input as { tier: string }).tier;
|
||||
const priceMap: Record<string, string> = {
|
||||
basic: "price_basic", plus: "price_plus", premium: "price_premium",
|
||||
};
|
||||
return mockChangeSubscriptionTier(sub.stripeId, priceMap[tier]);
|
||||
}),
|
||||
createPortalSession: t.procedure.use(isAuthed)
|
||||
.input(wrap(CreatePortalSessionSchema))
|
||||
@@ -160,6 +215,7 @@ describe("billing.getSubscription", () => {
|
||||
|
||||
describe("billing.createCheckoutSession", () => {
|
||||
it("creates checkout session and returns clientSecret", async () => {
|
||||
mockFindFirst.mockResolvedValue(undefined);
|
||||
mockCreateCheckoutSession.mockResolvedValue({
|
||||
clientSecret: "cs_123_secret",
|
||||
sessionId: "session_123",
|
||||
@@ -169,11 +225,75 @@ describe("billing.createCheckoutSession", () => {
|
||||
const result = await api.createCheckoutSession({
|
||||
priceId: "price_basic",
|
||||
returnUrl: "https://example.com/return",
|
||||
});
|
||||
}) as { clientSecret: string; sessionId: string };
|
||||
|
||||
expect(result.clientSecret).toBe("cs_123_secret");
|
||||
expect(result.sessionId).toBe("session_123");
|
||||
});
|
||||
|
||||
it("triggers tier change for upgrade", async () => {
|
||||
mockFindFirst.mockResolvedValue({
|
||||
id: "sub-1", stripeId: "sub_stripe_1", tier: "basic", status: "active",
|
||||
});
|
||||
mockChangeSubscriptionTier.mockResolvedValue({ subscription: { id: "sub_stripe_1" } as any });
|
||||
|
||||
const api = createCaller(makeUser());
|
||||
await api.createCheckoutSession({
|
||||
priceId: "price_plus",
|
||||
returnUrl: "https://example.com/return",
|
||||
});
|
||||
|
||||
expect(mockChangeSubscriptionTier).toHaveBeenCalledWith("sub_stripe_1", "price_plus");
|
||||
});
|
||||
});
|
||||
|
||||
describe("billing.createTrialSubscription", () => {
|
||||
it("creates trial subscription for user without active sub", async () => {
|
||||
mockFindFirst.mockResolvedValue(undefined);
|
||||
mockCreateTrialSubscription.mockResolvedValue({
|
||||
sessionId: "session_trial",
|
||||
url: "https://checkout.stripe.com/trial",
|
||||
});
|
||||
|
||||
const api = createCaller(makeUser());
|
||||
const result = await api.createTrialSubscription({
|
||||
returnUrl: "https://example.com/return",
|
||||
});
|
||||
|
||||
expect(result.sessionId).toBe("session_trial");
|
||||
});
|
||||
|
||||
it("rejects user with active subscription", async () => {
|
||||
mockFindFirst.mockResolvedValue({
|
||||
id: "sub-1", stripeId: "sub_stripe_1", tier: "basic", status: "active",
|
||||
});
|
||||
|
||||
const api = createCaller(makeUser());
|
||||
await expect(api.createTrialSubscription({
|
||||
returnUrl: "https://example.com/return",
|
||||
})).rejects.toThrow(TRPCError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("billing.changeTier", () => {
|
||||
it("changes tier with proration", async () => {
|
||||
mockFindFirst.mockResolvedValue({
|
||||
id: "sub-1", stripeId: "sub_stripe_1", tier: "basic", status: "active",
|
||||
});
|
||||
mockChangeSubscriptionTier.mockResolvedValue({ subscription: { id: "sub_stripe_1" } as any });
|
||||
|
||||
const api = createCaller(makeUser());
|
||||
const result = await api.changeTier({ tier: "plus" });
|
||||
|
||||
expect(mockChangeSubscriptionTier).toHaveBeenCalledWith("sub_stripe_1", "price_plus");
|
||||
});
|
||||
|
||||
it("rejects when no subscription exists", async () => {
|
||||
mockFindFirst.mockResolvedValue(undefined);
|
||||
|
||||
const api = createCaller(makeUser());
|
||||
await expect(api.changeTier({ tier: "plus" })).rejects.toThrow(TRPCError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("billing.createPortalSession", () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { wrap } from "@typeschema/valibot";
|
||||
import { createTRPCRouter, protectedProcedure } from "../utils";
|
||||
import { createTRPCRouter, protectedProcedure, rateLimitedProcedure } from "../utils";
|
||||
import {
|
||||
CreateCheckoutSessionSchema,
|
||||
CreatePortalSessionSchema,
|
||||
@@ -10,6 +10,9 @@ import {
|
||||
ListInvoicesSchema,
|
||||
RequestFeatureTrialSchema,
|
||||
UpgradeFromTrialSchema,
|
||||
CreateTrialSubscriptionSchema,
|
||||
ChangeTierSchema,
|
||||
CreateFamilyCheckoutSessionSchema,
|
||||
} from "../schemas/billing";
|
||||
import {
|
||||
getOrCreateCustomer,
|
||||
@@ -19,15 +22,22 @@ import {
|
||||
reactivateSubscription,
|
||||
listInvoices,
|
||||
mapStripeProductToTier,
|
||||
createTrialSubscription,
|
||||
changeSubscriptionTier,
|
||||
} from "~/server/services/billing.service";
|
||||
import { db } from "~/server/db";
|
||||
import { subscriptions } from "~/server/db/schema/subscription";
|
||||
import { subscriptions, familyGroups } from "~/server/db/schema/subscription";
|
||||
import { stripe } from "~/server/stripe";
|
||||
import {
|
||||
getEffectiveTier,
|
||||
getActiveTrials,
|
||||
createFeatureTrial,
|
||||
TIER_ORDER,
|
||||
} from "~/server/lib/tier";
|
||||
import {
|
||||
createFamilyGroup,
|
||||
getFamilyGroup,
|
||||
} from "~/server/services/family.service";
|
||||
|
||||
export const billingRouter = createTRPCRouter({
|
||||
getSubscription: protectedProcedure.query(async ({ ctx }) => {
|
||||
@@ -38,7 +48,10 @@ export const billingRouter = createTRPCRouter({
|
||||
const trials = await getActiveTrials(ctx.user.id);
|
||||
return {
|
||||
...sub,
|
||||
effectiveTier: getEffectiveTier(sub.tier as "basic" | "plus" | "premium", sub.status as "active" | "trialing"),
|
||||
effectiveTier: getEffectiveTier(
|
||||
sub.tier as "basic" | "plus" | "premium",
|
||||
sub.status as "active" | "trialing",
|
||||
),
|
||||
isTrialing: sub.status === "trialing",
|
||||
trials,
|
||||
};
|
||||
@@ -80,6 +93,8 @@ export const billingRouter = createTRPCRouter({
|
||||
basic: process.env.STRIPE_PRICE_BASIC,
|
||||
plus: process.env.STRIPE_PRICE_PLUS,
|
||||
premium: process.env.STRIPE_PRICE_PREMIUM,
|
||||
family_guard: process.env.STRIPE_PRICE_FAMILY_GUARD,
|
||||
family_fortress: process.env.STRIPE_PRICE_FAMILY_FORTRESS,
|
||||
};
|
||||
|
||||
const priceId = priceMap[input.plan];
|
||||
@@ -98,9 +113,41 @@ export const billingRouter = createTRPCRouter({
|
||||
);
|
||||
}),
|
||||
|
||||
createCheckoutSession: protectedProcedure
|
||||
/**
|
||||
* Create a 14-day trial subscription.
|
||||
* No payment method required — Stripe Checkout collects it on conversion.
|
||||
*/
|
||||
createTrialSubscription: rateLimitedProcedure
|
||||
.input(wrap(CreateTrialSubscriptionSchema))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const user = ctx.user!;
|
||||
// Check if user already has an active or trialing subscription
|
||||
const existing = await db.query.subscriptions.findFirst({
|
||||
where: eq(subscriptions.userId, user.id),
|
||||
});
|
||||
|
||||
if (existing && (existing.status === "active" || existing.status === "trialing")) {
|
||||
throw new TRPCError({
|
||||
code: "CONFLICT",
|
||||
message: "You already have an active subscription",
|
||||
});
|
||||
}
|
||||
|
||||
return createTrialSubscription(
|
||||
user.id,
|
||||
user.email,
|
||||
input.returnUrl,
|
||||
);
|
||||
}),
|
||||
|
||||
/**
|
||||
* Create a checkout session for a paid plan.
|
||||
* Rate limited to prevent abuse.
|
||||
*/
|
||||
createCheckoutSession: rateLimitedProcedure
|
||||
.input(wrap(CreateCheckoutSessionSchema))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const user = ctx.user!;
|
||||
const allowedPrices = [
|
||||
process.env.STRIPE_PRICE_BASIC,
|
||||
process.env.STRIPE_PRICE_PLUS,
|
||||
@@ -114,18 +161,120 @@ export const billingRouter = createTRPCRouter({
|
||||
});
|
||||
}
|
||||
|
||||
// Check if this is an upgrade or downgrade
|
||||
const existing = await db.query.subscriptions.findFirst({
|
||||
where: eq(subscriptions.userId, user.id),
|
||||
});
|
||||
|
||||
const currentTier = existing?.tier;
|
||||
const newTier = mapStripeProductToTier(input.priceId);
|
||||
const tierOrder: Record<string, number> = { basic: 0, plus: 1, premium: 2 };
|
||||
|
||||
const isUpgrade = Boolean(
|
||||
currentTier &&
|
||||
tierOrder[newTier] > tierOrder[currentTier],
|
||||
);
|
||||
const isDowngrade = Boolean(
|
||||
currentTier &&
|
||||
tierOrder[newTier] < tierOrder[currentTier],
|
||||
);
|
||||
|
||||
// If user has an active subscription and this is an upgrade/downgrade,
|
||||
// use the tier change flow with proration instead of new checkout
|
||||
if (existing && existing.stripeId && (isUpgrade || isDowngrade)) {
|
||||
return changeSubscriptionTier(existing.stripeId, input.priceId);
|
||||
}
|
||||
|
||||
return createCheckoutSession(
|
||||
ctx.user.id,
|
||||
ctx.user.email,
|
||||
user.id,
|
||||
user.email,
|
||||
input.priceId,
|
||||
input.returnUrl,
|
||||
{ isUpgrade, isDowngrade },
|
||||
);
|
||||
}),
|
||||
|
||||
/**
|
||||
* Create a checkout session for a family plan.
|
||||
* Creates a family group and starts the subscription checkout.
|
||||
*/
|
||||
createFamilyCheckoutSession: rateLimitedProcedure
|
||||
.input(wrap(CreateFamilyCheckoutSessionSchema))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const user = ctx.user!;
|
||||
|
||||
const priceMap: Record<string, string | undefined> = {
|
||||
family_guard: process.env.STRIPE_PRICE_FAMILY_GUARD,
|
||||
family_fortress: process.env.STRIPE_PRICE_FAMILY_FORTRESS,
|
||||
};
|
||||
|
||||
const priceId = priceMap[input.tier];
|
||||
if (!priceId) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid family plan tier" });
|
||||
}
|
||||
|
||||
// Create family group first
|
||||
const group = await createFamilyGroup(user.id, `${user.name ?? "Your"} Family`, input.tier);
|
||||
|
||||
// Create checkout session for the family plan
|
||||
const session = await createCheckoutSession(
|
||||
user.id,
|
||||
user.email,
|
||||
priceId,
|
||||
input.returnUrl,
|
||||
);
|
||||
|
||||
// Link subscription to family group after successful checkout is handled by webhook
|
||||
return {
|
||||
...session,
|
||||
familyGroupId: group.id,
|
||||
};
|
||||
}),
|
||||
|
||||
/**
|
||||
* Change subscription tier with proration.
|
||||
*/
|
||||
changeTier: protectedProcedure
|
||||
.input(wrap(ChangeTierSchema))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const sub = await db.query.subscriptions.findFirst({
|
||||
where: eq(subscriptions.userId, ctx.user.id),
|
||||
});
|
||||
|
||||
if (!sub || !sub.stripeId) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "No active subscription found",
|
||||
});
|
||||
}
|
||||
|
||||
if (sub.status !== "active" && sub.status !== "trialing") {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Cannot change tier for subscription in " + sub.status + " status",
|
||||
});
|
||||
}
|
||||
|
||||
const priceMap: Record<string, string | undefined> = {
|
||||
basic: process.env.STRIPE_PRICE_BASIC,
|
||||
plus: process.env.STRIPE_PRICE_PLUS,
|
||||
premium: process.env.STRIPE_PRICE_PREMIUM,
|
||||
family_guard: process.env.STRIPE_PRICE_FAMILY_GUARD,
|
||||
family_fortress: process.env.STRIPE_PRICE_FAMILY_FORTRESS,
|
||||
};
|
||||
|
||||
const priceId = priceMap[input.tier];
|
||||
if (!priceId) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid tier" });
|
||||
}
|
||||
|
||||
return changeSubscriptionTier(sub.stripeId, priceId);
|
||||
}),
|
||||
|
||||
createPortalSession: protectedProcedure
|
||||
.input(wrap(CreatePortalSessionSchema))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const user = ctx.user;
|
||||
const user = ctx.user!;
|
||||
const stripeCustomerId = user.stripeCustomerId;
|
||||
|
||||
if (!stripeCustomerId) {
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
GroupFilterSchema,
|
||||
GroupDetailsSchema,
|
||||
ResolveAlertSchema,
|
||||
FamilyThreatScoreSchema,
|
||||
} from "../schemas/correlation";
|
||||
|
||||
vi.mock("~/server/services/correlation.service", () => ({
|
||||
@@ -16,6 +17,11 @@ vi.mock("~/server/services/correlation.service", () => ({
|
||||
getCorrelationGroupDetails: vi.fn(),
|
||||
resolveAlert: vi.fn(),
|
||||
getAlertStats: vi.fn(),
|
||||
getThreatScore: vi.fn(),
|
||||
getThreatScoreTrend: vi.fn(),
|
||||
getRecommendations: vi.fn(),
|
||||
getFamilyThreatScore: vi.fn(),
|
||||
correlateAlerts: vi.fn(),
|
||||
}));
|
||||
|
||||
import * as correlationService from "~/server/services/correlation.service";
|
||||
@@ -26,6 +32,11 @@ const mockGetCorrelationGroups = vi.mocked(correlationService.getCorrelationGrou
|
||||
const mockGetCorrelationGroupDetails = vi.mocked(correlationService.getCorrelationGroupDetails);
|
||||
const mockResolveAlert = vi.mocked(correlationService.resolveAlert);
|
||||
const mockGetAlertStats = vi.mocked(correlationService.getAlertStats);
|
||||
const mockGetThreatScore = vi.mocked(correlationService.getThreatScore);
|
||||
const mockGetThreatScoreTrend = vi.mocked(correlationService.getThreatScoreTrend);
|
||||
const mockGetRecommendations = vi.mocked(correlationService.getRecommendations);
|
||||
const mockGetFamilyThreatScore = vi.mocked(correlationService.getFamilyThreatScore);
|
||||
const mockCorrelateAlerts = vi.mocked(correlationService.correlateAlerts);
|
||||
|
||||
type User = {
|
||||
id: string; email: string; name: string | null; image: string | null;
|
||||
@@ -71,6 +82,23 @@ function createCaller(user: User | null) {
|
||||
getStats: t.procedure.use(isAuthed).query(async ({ ctx }) => {
|
||||
return mockGetAlertStats(ctx.user.id);
|
||||
}),
|
||||
getThreatScore: t.procedure.use(isAuthed).query(async ({ ctx }) => {
|
||||
return mockGetThreatScore(ctx.user.id);
|
||||
}),
|
||||
getThreatScoreTrend: t.procedure.use(isAuthed).query(async ({ ctx }) => {
|
||||
return mockGetThreatScoreTrend(ctx.user.id);
|
||||
}),
|
||||
getRecommendations: t.procedure.use(isAuthed).query(async ({ ctx }) => {
|
||||
return mockGetRecommendations(ctx.user.id);
|
||||
}),
|
||||
getFamilyThreatScore: t.procedure.use(isAuthed)
|
||||
.input(wrap(FamilyThreatScoreSchema))
|
||||
.query(async ({ ctx, input }) => {
|
||||
return mockGetFamilyThreatScore(input.groupId);
|
||||
}),
|
||||
runCorrelation: t.procedure.use(isAuthed).mutation(async ({ ctx }) => {
|
||||
return mockCorrelateAlerts(ctx.user.id);
|
||||
}),
|
||||
});
|
||||
|
||||
const caller = t.createCallerFactory(router);
|
||||
@@ -205,7 +233,7 @@ describe("correlation.resolveAlert", () => {
|
||||
});
|
||||
|
||||
describe("correlation.getStats", () => {
|
||||
it("returns alert statistics", async () => {
|
||||
it("returns alert statistics with correlation data", async () => {
|
||||
const stats = {
|
||||
totalAlerts: 10,
|
||||
bySeverity: { HIGH: 5, LOW: 5 },
|
||||
@@ -215,11 +243,132 @@ describe("correlation.getStats", () => {
|
||||
falsePositiveCount: 0,
|
||||
threatScore: 45,
|
||||
threatBreakdown: [{ source: "DARKWATCH", score: 45 }],
|
||||
correlationBonus: 30,
|
||||
correlationCount: 1,
|
||||
narratives: ["Your email was breached and you received spam — possible coordinated attack"],
|
||||
recommendations: ["Enable two-factor authentication"],
|
||||
};
|
||||
mockGetAlertStats.mockResolvedValue(stats as never);
|
||||
const api = createCaller(makeUser());
|
||||
const result = await api.getStats();
|
||||
expect(result.totalAlerts).toBe(10);
|
||||
expect(result.threatScore).toBe(45);
|
||||
expect(result.correlationBonus).toBe(30);
|
||||
expect(result.narratives.length).toBe(1);
|
||||
expect(result.recommendations.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("correlation.getThreatScore", () => {
|
||||
it("returns full threat score with correlation breakdown", async () => {
|
||||
const score = {
|
||||
score: 55,
|
||||
baseScore: 25,
|
||||
correlationBonus: 30,
|
||||
alertCount: 5,
|
||||
correlationCount: 1,
|
||||
sourceBreakdown: { DARKWATCH: 15, SPAMSHIELD: 10 },
|
||||
severityBreakdown: { HIGH: 20, WARNING: 5 },
|
||||
ruleBreakdown: [{ rule: "RULE_1", bonus: 30, name: "Coordinated Attack: Breach + Spam" }],
|
||||
narratives: ["Your email was breached..."],
|
||||
recommendations: ["Enable 2FA"],
|
||||
};
|
||||
mockGetThreatScore.mockResolvedValue(score as never);
|
||||
const api = createCaller(makeUser());
|
||||
const result = await api.getThreatScore();
|
||||
expect(result.score).toBe(55);
|
||||
expect(result.baseScore).toBe(25);
|
||||
expect(result.correlationBonus).toBe(30);
|
||||
expect(result.ruleBreakdown.length).toBe(1);
|
||||
expect(result.ruleBreakdown[0].rule).toBe("RULE_1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("correlation.getThreatScoreTrend", () => {
|
||||
it("returns trend data with data points", async () => {
|
||||
const trend = {
|
||||
dataPoints: [
|
||||
{ date: "2024-01-01", score: 10 },
|
||||
{ date: "2024-01-15", score: 25 },
|
||||
{ date: "2024-02-01", score: 55 },
|
||||
],
|
||||
currentScore: 55,
|
||||
previousScore: 25,
|
||||
change: 30,
|
||||
threatLevel: { level: "medium", color: "yellow", label: "Medium Risk" },
|
||||
};
|
||||
mockGetThreatScoreTrend.mockResolvedValue(trend as never);
|
||||
const api = createCaller(makeUser());
|
||||
const result = await api.getThreatScoreTrend();
|
||||
expect(result.dataPoints.length).toBe(3);
|
||||
expect(result.currentScore).toBe(55);
|
||||
expect(result.change).toBe(30);
|
||||
expect(result.threatLevel.level).toBe("medium");
|
||||
});
|
||||
});
|
||||
|
||||
describe("correlation.getRecommendations", () => {
|
||||
it("returns prioritized recommendations", async () => {
|
||||
const recs = {
|
||||
recommendations: [
|
||||
{ priority: "critical", text: "Your threat score is critically high" },
|
||||
{ priority: "high", text: "Change passwords on all critical accounts" },
|
||||
{ priority: "medium", text: "Enable two-factor authentication" },
|
||||
],
|
||||
narratives: ["Coordinated attack detected"],
|
||||
score: 75,
|
||||
threatLevel: { level: "high", color: "orange", label: "High Risk" },
|
||||
};
|
||||
mockGetRecommendations.mockResolvedValue(recs as never);
|
||||
const api = createCaller(makeUser());
|
||||
const result = await api.getRecommendations();
|
||||
expect(result.recommendations.length).toBe(3);
|
||||
expect(result.recommendations[0].priority).toBe("critical");
|
||||
expect(result.threatLevel.level).toBe("high");
|
||||
});
|
||||
});
|
||||
|
||||
describe("correlation.getFamilyThreatScore", () => {
|
||||
it("returns family-aggregated score", async () => {
|
||||
const familyScore = {
|
||||
familyScore: 65,
|
||||
memberScores: [
|
||||
{ userId: "u1", score: 80 },
|
||||
{ userId: "u2", score: 30 },
|
||||
{ userId: "u3", score: 50 },
|
||||
],
|
||||
recommendations: [
|
||||
{ priority: "high", text: "Change passwords" },
|
||||
],
|
||||
narratives: ["Coordinated attack on family member"],
|
||||
};
|
||||
mockGetFamilyThreatScore.mockResolvedValue(familyScore as never);
|
||||
const api = createCaller(makeUser());
|
||||
const result = await api.getFamilyThreatScore({ groupId: "family-1" });
|
||||
expect(result.familyScore).toBe(65);
|
||||
expect(result.memberScores.length).toBe(3);
|
||||
expect(result.recommendations.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("correlation.runCorrelation", () => {
|
||||
it("triggers correlation pipeline", async () => {
|
||||
const result = {
|
||||
score: 55,
|
||||
baseScore: 25,
|
||||
correlationBonus: 30,
|
||||
alertCount: 5,
|
||||
correlationCount: 1,
|
||||
sourceBreakdown: {},
|
||||
severityBreakdown: {},
|
||||
ruleBreakdown: [{ rule: "RULE_1", bonus: 30, name: "Coordinated Attack" }],
|
||||
narratives: ["Narrative"],
|
||||
recommendations: ["Recommendation"],
|
||||
};
|
||||
mockCorrelateAlerts.mockResolvedValue(result as never);
|
||||
const api = createCaller(makeUser());
|
||||
const data = await api.runCorrelation();
|
||||
expect(data.score).toBe(55);
|
||||
expect(mockCorrelateAlerts).toHaveBeenCalledWith("user-1");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,41 +6,75 @@ import {
|
||||
GroupFilterSchema,
|
||||
GroupDetailsSchema,
|
||||
ResolveAlertSchema,
|
||||
FamilyThreatScoreSchema,
|
||||
} from "../schemas/correlation";
|
||||
import * as correlationService from "~/server/services/correlation.service";
|
||||
|
||||
export const correlationRouter = createTRPCRouter({
|
||||
// Alert timeline (paginated)
|
||||
getAlerts: protectedProcedure
|
||||
.input(wrap(AlertFilterSchema))
|
||||
.query(async ({ ctx, input }) => {
|
||||
return correlationService.getAlertTimeline(ctx.user.id, input);
|
||||
}),
|
||||
|
||||
// Individual alert details with correlation group info
|
||||
getAlertDetails: protectedProcedure
|
||||
.input(wrap(AlertDetailsSchema))
|
||||
.query(async ({ ctx, input }) => {
|
||||
return correlationService.getAlertDetails(ctx.user.id, input.alertId);
|
||||
}),
|
||||
|
||||
// Correlation groups (paginated)
|
||||
getGroups: protectedProcedure
|
||||
.input(wrap(GroupFilterSchema))
|
||||
.query(async ({ ctx, input }) => {
|
||||
return correlationService.getCorrelationGroups(ctx.user.id, input);
|
||||
}),
|
||||
|
||||
// Correlation group details with all linked alerts
|
||||
getGroupDetails: protectedProcedure
|
||||
.input(wrap(GroupDetailsSchema))
|
||||
.query(async ({ ctx, input }) => {
|
||||
return correlationService.getCorrelationGroupDetails(ctx.user.id, input.groupId);
|
||||
}),
|
||||
|
||||
// Resolve an alert (marks correlation group as resolved/false positive)
|
||||
resolveAlert: protectedProcedure
|
||||
.input(wrap(ResolveAlertSchema))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
return correlationService.resolveAlert(ctx.user.id, input.alertId, input.resolution);
|
||||
}),
|
||||
|
||||
// Alert stats with threat score, breakdown, narratives, and recommendations
|
||||
getStats: protectedProcedure.query(async ({ ctx }) => {
|
||||
return correlationService.getAlertStats(ctx.user.id);
|
||||
}),
|
||||
|
||||
// Full threat score with correlation rules, narratives, and recommendations
|
||||
getThreatScore: protectedProcedure.query(async ({ ctx }) => {
|
||||
return correlationService.getThreatScore(ctx.user.id);
|
||||
}),
|
||||
|
||||
// Threat score trend data for 90-day graph
|
||||
getThreatScoreTrend: protectedProcedure.query(async ({ ctx }) => {
|
||||
return correlationService.getThreatScoreTrend(ctx.user.id);
|
||||
}),
|
||||
|
||||
// Proactive recommendations based on current threat state
|
||||
getRecommendations: protectedProcedure.query(async ({ ctx }) => {
|
||||
return correlationService.getRecommendations(ctx.user.id);
|
||||
}),
|
||||
|
||||
// Family-aggregated threat score
|
||||
getFamilyThreatScore: protectedProcedure
|
||||
.input(wrap(FamilyThreatScoreSchema))
|
||||
.query(async ({ ctx, input }) => {
|
||||
return correlationService.getFamilyThreatScore(input.groupId);
|
||||
}),
|
||||
|
||||
// Trigger correlation pipeline manually
|
||||
runCorrelation: protectedProcedure.mutation(async ({ ctx }) => {
|
||||
return correlationService.correlateAlerts(ctx.user.id);
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -175,7 +175,7 @@ describe("darkwatch.getExposureDetails", () => {
|
||||
|
||||
describe("darkwatch.runScan", () => {
|
||||
it("triggers a scan", async () => {
|
||||
mockRunScan.mockResolvedValue({ scanId: "s1" });
|
||||
mockRunScan.mockResolvedValue({ scanId: "s1", queued: false });
|
||||
const api = createCaller(makeUser());
|
||||
const result = await api.runScan({});
|
||||
expect(result.scanId).toBe("s1");
|
||||
@@ -184,7 +184,7 @@ describe("darkwatch.runScan", () => {
|
||||
|
||||
describe("darkwatch.getScanStatus", () => {
|
||||
it("returns scan status", async () => {
|
||||
mockGetScanStatus.mockResolvedValue({ status: "idle", startedAt: null, completedAt: null, progress: 0, error: null });
|
||||
mockGetScanStatus.mockResolvedValue({ status: "idle", scanId: null, startedAt: null, completedAt: null, progress: 0, currentSource: null, error: null });
|
||||
const api = createCaller(makeUser());
|
||||
const result = await api.getScanStatus();
|
||||
expect(result.status).toBe("idle");
|
||||
|
||||
272
web/src/server/api/routers/family.ts
Normal file
272
web/src/server/api/routers/family.ts
Normal file
@@ -0,0 +1,272 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { wrap } from "@typeschema/valibot";
|
||||
import { createTRPCRouter, protectedProcedure, rateLimitedProcedure } from "../utils";
|
||||
import {
|
||||
CreateFamilyGroupSchema,
|
||||
InviteFamilyMemberSchema,
|
||||
AcceptInvitationSchema,
|
||||
ResendInvitationSchema,
|
||||
CancelInvitationSchema,
|
||||
RemoveFamilyMemberSchema,
|
||||
LeaveFamilyGroupSchema,
|
||||
UpdateMemberRoleSchema,
|
||||
TransferOwnershipSchema,
|
||||
ConfigureServicesSchema,
|
||||
UpdateAlertPreferencesSchema,
|
||||
UpdateFamilyPlanTierSchema,
|
||||
MemberDetailSchema,
|
||||
} from "../schemas/family";
|
||||
import {
|
||||
getFamilyGroup,
|
||||
getFamilyGroupById,
|
||||
createFamilyGroup,
|
||||
updateFamilyPlanTier,
|
||||
inviteMember,
|
||||
acceptInvitation,
|
||||
resendInvitation,
|
||||
cancelInvitation,
|
||||
listPendingInvitations,
|
||||
removeMember,
|
||||
leaveFamilyGroup,
|
||||
updateMemberRole,
|
||||
transferOwnership,
|
||||
getFamilyDashboard,
|
||||
getMemberDetail,
|
||||
configureMemberServices,
|
||||
getMemberServices,
|
||||
updateMemberAlertPreferences,
|
||||
getAlertRouting,
|
||||
} from "~/server/services/family.service";
|
||||
import { db } from "~/server/db";
|
||||
import { familyGroups } from "~/server/db/schema/subscription";
|
||||
|
||||
export const familyRouter = createTRPCRouter({
|
||||
/**
|
||||
* Get the current user's family group with members.
|
||||
*/
|
||||
getGroup: protectedProcedure.query(async ({ ctx }) => {
|
||||
return getFamilyGroup(ctx.user.id);
|
||||
}),
|
||||
|
||||
/**
|
||||
* Create a new family group.
|
||||
*/
|
||||
createGroup: protectedProcedure
|
||||
.input(wrap(CreateFamilyGroupSchema))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
// Check if user already has a family group
|
||||
try {
|
||||
await getFamilyGroup(ctx.user.id);
|
||||
throw new TRPCError({
|
||||
code: "CONFLICT",
|
||||
message: "You already belong to a family group",
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof TRPCError && err.code === "NOT_FOUND") {
|
||||
// No existing group — good to create
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
return createFamilyGroup(ctx.user.id, input.name, input.planTier);
|
||||
}),
|
||||
|
||||
/**
|
||||
* Update the family plan tier.
|
||||
*/
|
||||
updatePlanTier: protectedProcedure
|
||||
.input(wrap(UpdateFamilyPlanTierSchema))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const group = await getFamilyGroup(ctx.user.id);
|
||||
return updateFamilyPlanTier(group.id, input.planTier, ctx.user.id);
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get the family dashboard with all members' threat scores and alert counts.
|
||||
* No sensitive breach details are exposed for other members.
|
||||
*/
|
||||
getDashboard: protectedProcedure.query(async ({ ctx }) => {
|
||||
return getFamilyDashboard(ctx.user.id);
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get detailed view of a specific member.
|
||||
* Sensitive data (SSN, breach details) only visible to the member themselves or the owner.
|
||||
*/
|
||||
getMemberDetail: protectedProcedure
|
||||
.input(wrap(MemberDetailSchema))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const group = await getFamilyGroup(ctx.user.id);
|
||||
return getMemberDetail(group.id, input.userId, ctx.user.id);
|
||||
}),
|
||||
|
||||
/**
|
||||
* Invite a family member by email.
|
||||
* Sends an email with a signed invitation token.
|
||||
* Enforces plan tier member limits.
|
||||
*/
|
||||
inviteMember: protectedProcedure
|
||||
.input(wrap(InviteFamilyMemberSchema))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const group = await getFamilyGroup(ctx.user.id);
|
||||
|
||||
const callerMember = group.members.find(
|
||||
(m) => m.userId === ctx.user.id,
|
||||
);
|
||||
|
||||
if (!callerMember || (callerMember.role !== "owner" && callerMember.role !== "admin")) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "Only owner or admin can invite members",
|
||||
});
|
||||
}
|
||||
|
||||
return inviteMember(
|
||||
group.id,
|
||||
input.email,
|
||||
ctx.user.id,
|
||||
input.role,
|
||||
);
|
||||
}),
|
||||
|
||||
/**
|
||||
* Accept a family invitation using the signed token.
|
||||
* Called when a user clicks the invitation link.
|
||||
*/
|
||||
acceptInvitation: protectedProcedure
|
||||
.input(wrap(AcceptInvitationSchema))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
return acceptInvitation(input.token, ctx.user.id);
|
||||
}),
|
||||
|
||||
/**
|
||||
* Resend a pending invitation (sends reminder email).
|
||||
*/
|
||||
resendInvitation: protectedProcedure
|
||||
.input(wrap(ResendInvitationSchema))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
return resendInvitation(input.invitationId, ctx.user.id);
|
||||
}),
|
||||
|
||||
/**
|
||||
* Cancel a pending invitation.
|
||||
*/
|
||||
cancelInvitation: protectedProcedure
|
||||
.input(wrap(CancelInvitationSchema))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
return cancelInvitation(input.invitationId, ctx.user.id);
|
||||
}),
|
||||
|
||||
/**
|
||||
* List all pending invitations for the family group.
|
||||
*/
|
||||
listInvitations: protectedProcedure.query(async ({ ctx }) => {
|
||||
const group = await getFamilyGroup(ctx.user.id);
|
||||
return listPendingInvitations(group.id, ctx.user.id);
|
||||
}),
|
||||
|
||||
/**
|
||||
* Remove a member from the family group.
|
||||
* Creates a prorated credit via Stripe.
|
||||
*/
|
||||
removeMember: protectedProcedure
|
||||
.input(wrap(RemoveFamilyMemberSchema))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const group = await getFamilyGroup(ctx.user.id);
|
||||
await removeMember(group.id, input.userId, ctx.user.id);
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
/**
|
||||
* Leave the family group voluntarily.
|
||||
* Cannot leave if you are the owner.
|
||||
*/
|
||||
leaveGroup: protectedProcedure
|
||||
.input(wrap(LeaveFamilyGroupSchema))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await leaveFamilyGroup(input.groupId, ctx.user.id);
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
/**
|
||||
* Update a member's role.
|
||||
*/
|
||||
updateMemberRole: protectedProcedure
|
||||
.input(wrap(UpdateMemberRoleSchema))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const group = await getFamilyGroup(ctx.user.id);
|
||||
const updated = await updateMemberRole(
|
||||
group.id,
|
||||
input.userId,
|
||||
input.role,
|
||||
ctx.user.id,
|
||||
);
|
||||
return updated;
|
||||
}),
|
||||
|
||||
/**
|
||||
* Transfer ownership to another member.
|
||||
*/
|
||||
transferOwnership: protectedProcedure
|
||||
.input(wrap(TransferOwnershipSchema))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const group = await getFamilyGroup(ctx.user.id);
|
||||
await transferOwnership(group.id, input.newOwnerId, ctx.user.id);
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
/**
|
||||
* Configure which services a member has access to.
|
||||
* Only owner/admin can configure.
|
||||
*/
|
||||
configureServices: protectedProcedure
|
||||
.input(wrap(ConfigureServicesSchema))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const group = await getFamilyGroup(ctx.user.id);
|
||||
return configureMemberServices(
|
||||
group.id,
|
||||
input.userId,
|
||||
input.services,
|
||||
ctx.user.id,
|
||||
);
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get a member's configured services.
|
||||
*/
|
||||
getMemberServices: protectedProcedure
|
||||
.input(wrap(MemberDetailSchema))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const group = await getFamilyGroup(ctx.user.id);
|
||||
return getMemberServices(group.id, input.userId, ctx.user.id);
|
||||
}),
|
||||
|
||||
/**
|
||||
* Update own alert notification preferences.
|
||||
*/
|
||||
updateAlertPreferences: protectedProcedure
|
||||
.input(wrap(UpdateAlertPreferencesSchema))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
return updateMemberAlertPreferences(
|
||||
input.groupId,
|
||||
ctx.user.id,
|
||||
input.preferences,
|
||||
);
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get alert routing for a given alert type.
|
||||
* Used internally by the notification system.
|
||||
*/
|
||||
getAlertRouting: protectedProcedure.query(async ({ ctx }) => {
|
||||
const group = await getFamilyGroup(ctx.user.id);
|
||||
return {
|
||||
critical: await getAlertRouting(group.id, "critical"),
|
||||
security: await getAlertRouting(group.id, "security"),
|
||||
billing: await getAlertRouting(group.id, "billing"),
|
||||
general: await getAlertRouting(group.id, "general"),
|
||||
};
|
||||
}),
|
||||
});
|
||||
@@ -6,10 +6,12 @@ import {
|
||||
ScanListingsSchema,
|
||||
BrokerListingsFilterSchema,
|
||||
RemovalRequestsFilterSchema,
|
||||
EnableAdapterSchema,
|
||||
} from "../schemas/removebrokers";
|
||||
import * as removebrokersService from "~/server/services/removebrokers.service";
|
||||
|
||||
export const removebrokersRouter = createTRPCRouter({
|
||||
// Core removal flow
|
||||
getBrokerRegistry: protectedProcedure.query(async () => {
|
||||
return removebrokersService.getBrokerRegistry();
|
||||
}),
|
||||
@@ -47,4 +49,60 @@ export const removebrokersRouter = createTRPCRouter({
|
||||
getStats: protectedProcedure.query(async ({ ctx }) => {
|
||||
return removebrokersService.getStats(ctx.user.id);
|
||||
}),
|
||||
|
||||
// Enhanced stats with per-broker success rates
|
||||
getEnhancedStats: protectedProcedure.query(async ({ ctx }) => {
|
||||
return removebrokersService.getEnhancedStats(ctx.user.id);
|
||||
}),
|
||||
|
||||
// CAPTCHA solver
|
||||
getCaptchaSolverStatus: protectedProcedure.query(async () => {
|
||||
return removebrokersService.getCaptchaSolverStatus();
|
||||
}),
|
||||
|
||||
// Email verification
|
||||
processEmailConfirmations: protectedProcedure.mutation(async () => {
|
||||
return removebrokersService.processEmailConfirmations();
|
||||
}),
|
||||
|
||||
// Re-scan pipeline
|
||||
executeReScan: protectedProcedure.mutation(async () => {
|
||||
return removebrokersService.executeReScan();
|
||||
}),
|
||||
|
||||
getReListingStats: protectedProcedure.query(async () => {
|
||||
return removebrokersService.getReListingStats();
|
||||
}),
|
||||
|
||||
// Adapter health
|
||||
getAdapterSystemHealth: protectedProcedure.query(async () => {
|
||||
return removebrokersService.getAdapterSystemHealth();
|
||||
}),
|
||||
|
||||
getBrokenAdapters: protectedProcedure.query(async () => {
|
||||
return removebrokersService.getBrokenAdaptersList();
|
||||
}),
|
||||
|
||||
enableAdapter: protectedProcedure
|
||||
.input(wrap(EnableAdapterSchema))
|
||||
.mutation(async ({ input }) => {
|
||||
return removebrokersService.reEnableAdapter(input.brokerId);
|
||||
}),
|
||||
|
||||
getAllAdapterHealth: protectedProcedure.query(async () => {
|
||||
return removebrokersService.getAllAdapterHealthStatus();
|
||||
}),
|
||||
|
||||
// Cost tracking
|
||||
getMonthlyCosts: protectedProcedure.query(async () => {
|
||||
return removebrokersService.getMonthlyCosts();
|
||||
}),
|
||||
|
||||
getCostPerUser: protectedProcedure.query(async () => {
|
||||
return removebrokersService.getCostPerUser();
|
||||
}),
|
||||
|
||||
getCostHistory: protectedProcedure.query(async () => {
|
||||
return removebrokersService.getCostHistoryData();
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -22,6 +22,13 @@ vi.mock("~/server/services/spamshield.service", () => ({
|
||||
getStats: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("~/server/services/spamshield/onnx.inference", () => ({
|
||||
initSpamModel: vi.fn().mockResolvedValue(true),
|
||||
getModelInfo: vi.fn().mockReturnValue({ version: "1.0.0", task: "sms-spam-classification", num_labels: 2 }),
|
||||
isModelLoaded: vi.fn().mockReturnValue(true),
|
||||
getThresholds: vi.fn().mockReturnValue({ strict: 0.3, moderate: 0.5, lenient: 0.7 }),
|
||||
}));
|
||||
|
||||
import * as spamshieldService from "~/server/services/spamshield.service";
|
||||
|
||||
const mockCheckNumber = vi.mocked(spamshieldService.checkNumberReputation);
|
||||
@@ -137,7 +144,7 @@ describe("spamshield.classifySMS", () => {
|
||||
|
||||
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 } };
|
||||
const result = { isSpam: false, confidence: 0.5, callerNumber: "+1234567890", matchedRule: null, reputation: null, features: { areaCode: "+12", duration: 30, timeOfDay: 14 }, flaggedByReputation: null };
|
||||
mockClassifyCall.mockResolvedValue(result);
|
||||
const api = createCaller(null);
|
||||
const res = await api.classifyCall({ callerNumber: "+1234567890", duration: 30, timeOfDay: 14 });
|
||||
@@ -216,3 +223,12 @@ describe("spamshield.getStats", () => {
|
||||
await expect(api.getStats({ period: "month" })).rejects.toThrow(TRPCError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("spamshield.modelInfo", () => {
|
||||
it("returns model info publicly", async () => {
|
||||
const { spamshieldRouter } = await import("../routers/spamshield");
|
||||
// The router is built with mocks, so modelInfo should work
|
||||
// We test the structure of the response
|
||||
expect(spamshieldRouter.modelInfo).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
StatsFilterSchema,
|
||||
} from "../schemas/spamshield";
|
||||
import * as spamshieldService from "~/server/services/spamshield.service";
|
||||
import { initSpamModel, getModelInfo, isModelLoaded, getThresholds } from "~/server/services/spamshield/onnx.inference";
|
||||
|
||||
export const spamshieldRouter = createTRPCRouter({
|
||||
checkNumber: publicProcedure
|
||||
@@ -21,7 +22,7 @@ export const spamshieldRouter = createTRPCRouter({
|
||||
classifySMS: publicProcedure
|
||||
.input(wrap(ClassifySMSSchema))
|
||||
.query(async ({ input, ctx }) => {
|
||||
return spamshieldService.classifySMS(input.text, ctx.user?.id);
|
||||
return spamshieldService.classifySMS(input.text, ctx.user?.id, input.threshold);
|
||||
}),
|
||||
|
||||
classifyCall: publicProcedure
|
||||
@@ -73,4 +74,13 @@ export const spamshieldRouter = createTRPCRouter({
|
||||
.query(async ({ ctx, input }) => {
|
||||
return spamshieldService.getStats(ctx.user.id, input.period);
|
||||
}),
|
||||
|
||||
modelInfo: publicProcedure.query(async () => {
|
||||
await initSpamModel();
|
||||
return {
|
||||
loaded: isModelLoaded(),
|
||||
...getModelInfo(),
|
||||
thresholds: getThresholds(),
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -3,32 +3,40 @@ import { initTRPC, TRPCError } from "@trpc/server";
|
||||
import { wrap } from "@typeschema/valibot";
|
||||
import {
|
||||
CreateEnrollmentSchema,
|
||||
EnrollAdditionalSampleSchema,
|
||||
DeleteEnrollmentSchema,
|
||||
AnalyzeAudioSchema,
|
||||
AnalysisFilterSchema,
|
||||
AnalysisResultSchema,
|
||||
AnalysisFeedbackSchema,
|
||||
JobStatusSchema,
|
||||
} from "../schemas/voiceprint";
|
||||
|
||||
vi.mock("~/server/services/voiceprint.service", () => ({
|
||||
getEnrollments: vi.fn(),
|
||||
createEnrollment: vi.fn(),
|
||||
enrollAdditionalSample: vi.fn(),
|
||||
deleteEnrollment: vi.fn(),
|
||||
analyzeAudio: vi.fn(),
|
||||
reportAnalysisFeedback: vi.fn(),
|
||||
getAnalyses: vi.fn(),
|
||||
getAnalysisResult: vi.fn(),
|
||||
getJobStatus: vi.fn(),
|
||||
getUsageStats: vi.fn(),
|
||||
}));
|
||||
|
||||
import * as voiceprintService from "~/server/services/voiceprint.service";
|
||||
|
||||
const mockGetEnrollments = vi.mocked(voiceprintService.getEnrollments);
|
||||
const mockCreateEnrollment = vi.mocked(voiceprintService.createEnrollment);
|
||||
const mockEnrollAdditionalSample = vi.mocked(voiceprintService.enrollAdditionalSample);
|
||||
const mockDeleteEnrollment = vi.mocked(voiceprintService.deleteEnrollment);
|
||||
const mockAnalyzeAudio = vi.mocked(voiceprintService.analyzeAudio);
|
||||
const mockReportAnalysisFeedback = vi.mocked(voiceprintService.reportAnalysisFeedback);
|
||||
const mockGetAnalyses = vi.mocked(voiceprintService.getAnalyses);
|
||||
const mockGetAnalysisResult = vi.mocked(voiceprintService.getAnalysisResult);
|
||||
const mockGetJobStatus = vi.mocked(voiceprintService.getJobStatus);
|
||||
const mockGetUsageStats = vi.mocked(voiceprintService.getUsageStats);
|
||||
|
||||
type User = {
|
||||
id: string; email: string; name: string | null; image: string | null;
|
||||
@@ -54,6 +62,11 @@ function createCaller(user: User | null) {
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
return mockCreateEnrollment(ctx.user.id, input.name, input.audioBase64);
|
||||
}),
|
||||
enrollAdditionalSample: t.procedure.use(isAuthed)
|
||||
.input(wrap(EnrollAdditionalSampleSchema))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
return mockEnrollAdditionalSample(ctx.user.id, input.enrollmentId, input.audioBase64);
|
||||
}),
|
||||
deleteEnrollment: t.procedure.use(isAuthed)
|
||||
.input(wrap(DeleteEnrollmentSchema))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
@@ -64,6 +77,14 @@ function createCaller(user: User | null) {
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
return mockAnalyzeAudio(ctx.user.id, input.audioBase64, input.enrollmentId);
|
||||
}),
|
||||
reportAnalysisFeedback: t.procedure.use(isAuthed)
|
||||
.input(wrap(AnalysisFeedbackSchema))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
return mockReportAnalysisFeedback(ctx.user.id, input.analysisId, {
|
||||
isFalsePositive: input.isFalsePositive,
|
||||
notes: input.notes,
|
||||
});
|
||||
}),
|
||||
getAnalyses: t.procedure.use(isAuthed)
|
||||
.input(wrap(AnalysisFilterSchema))
|
||||
.query(async ({ ctx, input }) => {
|
||||
@@ -79,6 +100,9 @@ function createCaller(user: User | null) {
|
||||
.query(async ({ ctx, input }) => {
|
||||
return mockGetJobStatus(ctx.user.id, input.jobId);
|
||||
}),
|
||||
getUsageStats: t.procedure.use(isAuthed).query(async ({ ctx }) => {
|
||||
return mockGetUsageStats(ctx.user.id);
|
||||
}),
|
||||
});
|
||||
|
||||
const caller = t.createCallerFactory(router);
|
||||
@@ -131,6 +155,26 @@ describe("voiceprint.createEnrollment", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("voiceprint.enrollAdditionalSample", () => {
|
||||
it("enrolls an additional audio sample", async () => {
|
||||
const result = { id: "enr-1", enrollmentsCount: 3, enrollmentStatus: "Enrolled" };
|
||||
mockEnrollAdditionalSample.mockResolvedValue(result as never);
|
||||
const api = createCaller(makeUser());
|
||||
const res = await api.enrollAdditionalSample({
|
||||
enrollmentId: "enr-1",
|
||||
audioBase64: "bW9yZS1hdWRpbw==",
|
||||
});
|
||||
expect(res.enrollmentsCount).toBe(3);
|
||||
});
|
||||
|
||||
it("rejects missing enrollmentId", async () => {
|
||||
const api = createCaller(makeUser());
|
||||
await expect(
|
||||
api.enrollAdditionalSample({ enrollmentId: "", audioBase64: "dGVzdA==" }),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("voiceprint.deleteEnrollment", () => {
|
||||
it("deletes enrollment", async () => {
|
||||
mockDeleteEnrollment.mockResolvedValue({ id: "enr-1", isActive: false } as never);
|
||||
@@ -157,6 +201,20 @@ describe("voiceprint.analyzeAudio", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("voiceprint.reportAnalysisFeedback", () => {
|
||||
it("submits feedback on analysis", async () => {
|
||||
const result = { id: "ana-1", userFeedback: { isFalsePositive: true } };
|
||||
mockReportAnalysisFeedback.mockResolvedValue(result as never);
|
||||
const api = createCaller(makeUser());
|
||||
const res = await api.reportAnalysisFeedback({
|
||||
analysisId: "ana-1",
|
||||
isFalsePositive: true,
|
||||
notes: "Not synthetic",
|
||||
});
|
||||
expect((res.userFeedback as { isFalsePositive: boolean }).isFalsePositive).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("voiceprint.getAnalyses", () => {
|
||||
it("returns paginated analyses", async () => {
|
||||
const data = { items: [], total: 0, page: 1, limit: 20, totalPages: 0 };
|
||||
@@ -193,3 +251,14 @@ describe("voiceprint.getJobStatus", () => {
|
||||
expect(result.status).toBe("RUNNING");
|
||||
});
|
||||
});
|
||||
|
||||
describe("voiceprint.getUsageStats", () => {
|
||||
it("returns usage statistics", async () => {
|
||||
const stats = { analysesThisMonth: 5, activeEnrollments: 2 };
|
||||
mockGetUsageStats.mockResolvedValue(stats);
|
||||
const api = createCaller(makeUser());
|
||||
const result = await api.getUsageStats();
|
||||
expect(result.analysesThisMonth).toBe(5);
|
||||
expect(result.activeEnrollments).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
import { wrap } from "@typeschema/valibot";
|
||||
import { z } from "zod";
|
||||
import { createTRPCRouter, protectedProcedure } from "../utils";
|
||||
import {
|
||||
CreateEnrollmentSchema,
|
||||
EnrollAdditionalSampleSchema,
|
||||
DeleteEnrollmentSchema,
|
||||
AnalyzeAudioSchema,
|
||||
AnalysisFilterSchema,
|
||||
AnalysisResultSchema,
|
||||
AnalysisFeedbackSchema,
|
||||
JobStatusSchema,
|
||||
AnalyzeCallRecordingSchema,
|
||||
GetCallAnalysesSchema,
|
||||
GetCallAnalysisSchema,
|
||||
UpdateCallAnalysisSettingsSchema,
|
||||
EmergencyHangupSchema,
|
||||
} from "../schemas/voiceprint";
|
||||
import * as voiceprintService from "~/server/services/voiceprint.service";
|
||||
|
||||
@@ -21,6 +29,16 @@ export const voiceprintRouter = createTRPCRouter({
|
||||
return voiceprintService.createEnrollment(ctx.user.id, input.name, input.audioBase64);
|
||||
}),
|
||||
|
||||
enrollAdditionalSample: protectedProcedure
|
||||
.input(wrap(EnrollAdditionalSampleSchema))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
return voiceprintService.enrollAdditionalSample(
|
||||
ctx.user.id,
|
||||
input.enrollmentId,
|
||||
input.audioBase64,
|
||||
);
|
||||
}),
|
||||
|
||||
deleteEnrollment: protectedProcedure
|
||||
.input(wrap(DeleteEnrollmentSchema))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
@@ -33,6 +51,15 @@ export const voiceprintRouter = createTRPCRouter({
|
||||
return voiceprintService.analyzeAudio(ctx.user.id, input.audioBase64, input.enrollmentId);
|
||||
}),
|
||||
|
||||
reportAnalysisFeedback: protectedProcedure
|
||||
.input(wrap(AnalysisFeedbackSchema))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
return voiceprintService.reportAnalysisFeedback(ctx.user.id, input.analysisId, {
|
||||
isFalsePositive: input.isFalsePositive,
|
||||
notes: input.notes,
|
||||
});
|
||||
}),
|
||||
|
||||
getAnalyses: protectedProcedure
|
||||
.input(wrap(AnalysisFilterSchema))
|
||||
.query(async ({ ctx, input }) => {
|
||||
@@ -50,4 +77,78 @@ export const voiceprintRouter = createTRPCRouter({
|
||||
.query(async ({ ctx, input }) => {
|
||||
return voiceprintService.getJobStatus(ctx.user.id, input.jobId);
|
||||
}),
|
||||
|
||||
getUsageStats: protectedProcedure.query(async ({ ctx }) => {
|
||||
return voiceprintService.getUsageStats(ctx.user.id);
|
||||
}),
|
||||
|
||||
// ---- Call Recording Endpoints ----
|
||||
|
||||
/**
|
||||
* Analyze a call recording audio file.
|
||||
* Accepts base64 audio or multipart upload (via form data).
|
||||
*/
|
||||
analyzeCallRecording: protectedProcedure
|
||||
.input(wrap(AnalyzeCallRecordingSchema))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
return voiceprintService.analyzeCallRecording(ctx.user.id, {
|
||||
audioBase64: input.audioBase64 ?? undefined,
|
||||
phoneNumber: input.phoneNumber,
|
||||
direction: input.direction as "incoming" | "outgoing",
|
||||
duration: input.duration,
|
||||
callStartedAt: new Date(input.callStartedAt),
|
||||
consentState: input.consentState as "one-party" | "two-party" | "unknown" | undefined,
|
||||
});
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get paginated call analysis history.
|
||||
*/
|
||||
getCallAnalyses: protectedProcedure
|
||||
.input(wrap(GetCallAnalysesSchema))
|
||||
.query(async ({ ctx, input }) => {
|
||||
return voiceprintService.getCallAnalyses(ctx.user.id, {
|
||||
page: input.page,
|
||||
limit: input.limit,
|
||||
status: input.status,
|
||||
});
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get a single call analysis by ID.
|
||||
*/
|
||||
getCallAnalysis: protectedProcedure
|
||||
.input(wrap(GetCallAnalysisSchema))
|
||||
.query(async ({ ctx, input }) => {
|
||||
return voiceprintService.getCallAnalysis(ctx.user.id, input.callRecordingId);
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get or create call analysis settings for the user.
|
||||
*/
|
||||
getCallAnalysisSettings: protectedProcedure.query(async ({ ctx }) => {
|
||||
return voiceprintService.getCallAnalysisSettings(ctx.user.id);
|
||||
}),
|
||||
|
||||
/**
|
||||
* Update call analysis settings.
|
||||
*/
|
||||
updateCallAnalysisSettings: protectedProcedure
|
||||
.input(wrap(UpdateCallAnalysisSettingsSchema))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
return voiceprintService.updateCallAnalysisSettings(ctx.user.id, input);
|
||||
}),
|
||||
|
||||
/**
|
||||
* Emergency hangup + block number when synthetic voice detected.
|
||||
* Records the block action and returns instructions for the device to execute.
|
||||
*/
|
||||
emergencyHangup: protectedProcedure
|
||||
.input(wrap(EmergencyHangupSchema))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
return voiceprintService.emergencyHangup(ctx.user.id, {
|
||||
callRecordingId: input.callRecordingId,
|
||||
phoneNumber: input.phoneNumber,
|
||||
});
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { object, string, minLength, optional, picklist } from "valibot";
|
||||
import { object, string, minLength, optional, picklist, boolean } from "valibot";
|
||||
import { returnUrlSchema } from "~/lib/url-validation";
|
||||
|
||||
export const CreateCheckoutSessionSchema = object({
|
||||
@@ -31,3 +31,17 @@ export const UpgradeFromTrialSchema = object({
|
||||
plan: picklist(["basic", "plus", "premium"]),
|
||||
returnUrl: returnUrlSchema,
|
||||
});
|
||||
|
||||
export const CreateTrialSubscriptionSchema = object({
|
||||
returnUrl: returnUrlSchema,
|
||||
});
|
||||
|
||||
export const ChangeTierSchema = object({
|
||||
tier: picklist(["basic", "plus", "premium", "family_guard", "family_fortress"]),
|
||||
});
|
||||
|
||||
export const CreateFamilyCheckoutSessionSchema = object({
|
||||
tier: picklist(["family_guard", "family_fortress"]),
|
||||
returnUrl: returnUrlSchema,
|
||||
familyGroupId: string([minLength(1)]),
|
||||
});
|
||||
|
||||
@@ -34,3 +34,7 @@ export const ResolveAlertSchema = object({
|
||||
alertId: string([minLength(1)]),
|
||||
resolution: picklist(["RESOLVED", "FALSE_POSITIVE"]),
|
||||
});
|
||||
|
||||
export const FamilyThreatScoreSchema = object({
|
||||
groupId: string([minLength(1)]),
|
||||
});
|
||||
|
||||
65
web/src/server/api/schemas/family.ts
Normal file
65
web/src/server/api/schemas/family.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { object, string, email, minLength, optional, picklist, array, boolean, number } from "valibot";
|
||||
|
||||
export const CreateFamilyGroupSchema = object({
|
||||
name: string([minLength(1)]),
|
||||
planTier: optional(picklist(["family_guard", "family_fortress"])),
|
||||
});
|
||||
|
||||
export const InviteFamilyMemberSchema = object({
|
||||
email: string([email()]),
|
||||
role: optional(picklist(["admin", "member"]), "member"),
|
||||
});
|
||||
|
||||
export const AcceptInvitationSchema = object({
|
||||
token: string([minLength(1)]),
|
||||
});
|
||||
|
||||
export const ResendInvitationSchema = object({
|
||||
invitationId: string([minLength(1)]),
|
||||
});
|
||||
|
||||
export const CancelInvitationSchema = object({
|
||||
invitationId: string([minLength(1)]),
|
||||
});
|
||||
|
||||
export const RemoveFamilyMemberSchema = object({
|
||||
userId: string([minLength(1)]),
|
||||
});
|
||||
|
||||
export const LeaveFamilyGroupSchema = object({
|
||||
groupId: string([minLength(1)]),
|
||||
});
|
||||
|
||||
export const UpdateMemberRoleSchema = object({
|
||||
userId: string([minLength(1)]),
|
||||
role: picklist(["owner", "admin", "member"]),
|
||||
});
|
||||
|
||||
export const TransferOwnershipSchema = object({
|
||||
newOwnerId: string([minLength(1)]),
|
||||
});
|
||||
|
||||
export const ConfigureServicesSchema = object({
|
||||
userId: string([minLength(1)]),
|
||||
services: array(object({
|
||||
service: picklist(["darkwatch", "spamshield", "removebrokers", "hometitle", "voiceprint"]),
|
||||
enabled: boolean(),
|
||||
})),
|
||||
});
|
||||
|
||||
export const UpdateAlertPreferencesSchema = object({
|
||||
groupId: string([minLength(1)]),
|
||||
preferences: array(object({
|
||||
alertType: picklist(["critical", "security", "billing", "general"]),
|
||||
channel: picklist(["email", "push", "sms"]),
|
||||
enabled: boolean(),
|
||||
})),
|
||||
});
|
||||
|
||||
export const UpdateFamilyPlanTierSchema = object({
|
||||
planTier: picklist(["family_guard", "family_fortress"]),
|
||||
});
|
||||
|
||||
export const MemberDetailSchema = object({
|
||||
userId: string([minLength(1)]),
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { object, string, minLength, optional, number, picklist } from "valibot";
|
||||
import { object, string, minLength, optional, number, picklist, boolean } from "valibot";
|
||||
|
||||
export const PersonalInfoSchema = object({
|
||||
fullName: string([minLength(1)]),
|
||||
@@ -35,3 +35,17 @@ export const RemovalRequestsFilterSchema = object({
|
||||
page: optional(number(), 1),
|
||||
limit: optional(number(), 20),
|
||||
});
|
||||
|
||||
export const EnableAdapterSchema = object({
|
||||
brokerId: string([minLength(1)]),
|
||||
});
|
||||
|
||||
export const ReScanConfigSchema = object({
|
||||
cooldownDays: optional(number(), 7),
|
||||
batchSize: optional(number(), 50),
|
||||
autoReSubmit: optional(boolean(), true),
|
||||
});
|
||||
|
||||
export const CostHistorySchema = object({
|
||||
months: optional(number(), 6),
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ export const CheckNumberSchema = object({
|
||||
|
||||
export const ClassifySMSSchema = object({
|
||||
text: string([minLength(1)]),
|
||||
threshold: optional(picklist(["strict", "moderate", "lenient"]), "moderate"),
|
||||
});
|
||||
|
||||
export const ClassifyCallSchema = object({
|
||||
|
||||
@@ -19,3 +19,11 @@ export const UpdateRoleSchema = object({
|
||||
userId: string(),
|
||||
role: picklist(["owner", "admin", "member"]),
|
||||
});
|
||||
|
||||
export const InviteByEmailSchema = object({
|
||||
email: string([email()]),
|
||||
});
|
||||
|
||||
export const AcceptInviteSchema = object({
|
||||
token: string(),
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
optional,
|
||||
number,
|
||||
picklist,
|
||||
boolean,
|
||||
} from "valibot";
|
||||
|
||||
/**
|
||||
@@ -29,6 +30,11 @@ export const CreateEnrollmentSchema = object({
|
||||
audioBase64: string([minLength(1), maxLength(MAX_BASE64_LENGTH)]),
|
||||
});
|
||||
|
||||
export const EnrollAdditionalSampleSchema = object({
|
||||
enrollmentId: string([minLength(1)]),
|
||||
audioBase64: string([minLength(1), maxLength(MAX_BASE64_LENGTH)]),
|
||||
});
|
||||
|
||||
export const DeleteEnrollmentSchema = object({
|
||||
enrollmentId: string([minLength(1)]),
|
||||
});
|
||||
@@ -48,6 +54,51 @@ export const AnalysisResultSchema = object({
|
||||
analysisId: string([minLength(1)]),
|
||||
});
|
||||
|
||||
export const AnalysisFeedbackSchema = object({
|
||||
analysisId: string([minLength(1)]),
|
||||
isFalsePositive: boolean(),
|
||||
notes: optional(string()),
|
||||
});
|
||||
|
||||
export const JobStatusSchema = object({
|
||||
jobId: string([minLength(1)]),
|
||||
});
|
||||
|
||||
/** Call recording analysis schemas */
|
||||
export const AnalyzeCallRecordingSchema = object({
|
||||
/** Audio file as base64 (alternative to multipart upload) */
|
||||
audioBase64: optional(string([minLength(1), maxLength(MAX_BASE64_LENGTH)])),
|
||||
/** Phone number of the caller/called party */
|
||||
phoneNumber: string([minLength(1)]),
|
||||
/** Call direction */
|
||||
direction: string(),
|
||||
/** Call duration in seconds */
|
||||
duration: number(),
|
||||
/** Call start timestamp ISO string */
|
||||
callStartedAt: string(),
|
||||
/** Two-party consent state detected on device */
|
||||
consentState: optional(string()),
|
||||
});
|
||||
|
||||
export const GetCallAnalysesSchema = object({
|
||||
page: optional(number(), 1),
|
||||
limit: optional(number(), 20),
|
||||
status: optional(string()),
|
||||
});
|
||||
|
||||
export const GetCallAnalysisSchema = object({
|
||||
callRecordingId: string([minLength(1)]),
|
||||
});
|
||||
|
||||
export const UpdateCallAnalysisSettingsSchema = object({
|
||||
callAnalysisEnabled: optional(boolean()),
|
||||
autoAnalyze: optional(boolean()),
|
||||
audioRetentionDays: optional(number()),
|
||||
notifyOnSynthetic: optional(boolean()),
|
||||
autoBlockSynthetic: optional(boolean()),
|
||||
});
|
||||
|
||||
export const EmergencyHangupSchema = object({
|
||||
callRecordingId: string([minLength(1)]),
|
||||
phoneNumber: string([minLength(1)]),
|
||||
});
|
||||
|
||||
@@ -47,17 +47,20 @@ describe("SubscriptionSchema", () => {
|
||||
status: "active",
|
||||
current_period_start: 1700000000,
|
||||
current_period_end: 1702678400,
|
||||
cancel_at_period_end: "false",
|
||||
trial_end: 1700500000,
|
||||
cancel_at_period_end: false,
|
||||
metadata: { userId: "user_123" },
|
||||
items: {
|
||||
data: { price: { id: "price_basic" } },
|
||||
data: [{ price: { id: "price_basic" } }],
|
||||
},
|
||||
};
|
||||
const result = safeParse(SubscriptionSchema, data);
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.output.current_period_start).toBe(1700000000);
|
||||
expect(result.output.items?.data?.price?.id).toBe("price_basic");
|
||||
expect(result.output.items?.data?.[0]?.price?.id).toBe("price_basic");
|
||||
expect(result.output.trial_end).toBe(1700500000);
|
||||
expect(result.output.cancel_at_period_end).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -75,14 +78,17 @@ describe("SubscriptionSchema", () => {
|
||||
const result = safeParse(SubscriptionSchema, data);
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.output.cancel_at_period_end).toBe("false");
|
||||
expect(result.output.cancel_at_period_end).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts string cancel_at_period_end", () => {
|
||||
const data = { id: "sub_123", cancel_at_period_end: "true" };
|
||||
it("accepts boolean cancel_at_period_end", () => {
|
||||
const data = { id: "sub_123", cancel_at_period_end: true };
|
||||
const result = safeParse(SubscriptionSchema, data);
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.output.cancel_at_period_end).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects missing required id", () => {
|
||||
@@ -100,6 +106,23 @@ describe("SubscriptionSchema", () => {
|
||||
const result = safeParse(SubscriptionSchema, data);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("handles items as array of price objects", () => {
|
||||
const data = {
|
||||
id: "sub_123",
|
||||
items: {
|
||||
data: [
|
||||
{ price: { id: "price_1" } },
|
||||
{ price: { id: "price_2" } },
|
||||
],
|
||||
},
|
||||
};
|
||||
const result = safeParse(SubscriptionSchema, data);
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.output.items?.data).toHaveLength(2);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("InvoiceSchema", () => {
|
||||
@@ -112,6 +135,23 @@ describe("InvoiceSchema", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts full invoice data with amount and currency", () => {
|
||||
const data = {
|
||||
id: "in_123",
|
||||
subscription: "sub_456",
|
||||
amount_due: 1999,
|
||||
currency: "usd",
|
||||
status: "paid",
|
||||
};
|
||||
const result = safeParse(InvoiceSchema, data);
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.output.id).toBe("in_123");
|
||||
expect(result.output.amount_due).toBe(1999);
|
||||
expect(result.output.currency).toBe("usd");
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts invoice without subscription (for partial invoices)", () => {
|
||||
const data = { id: "in_123" };
|
||||
const result = safeParse(InvoiceSchema, data);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { object, string, optional, number, type Output } from "valibot";
|
||||
import { object, string, optional, number, array, boolean, type Output } from "valibot";
|
||||
|
||||
/**
|
||||
* Validates a Stripe Checkout Session object from webhook data.
|
||||
@@ -16,10 +16,12 @@ export const CheckoutSessionSchema = object({
|
||||
/**
|
||||
* Price item inside a Stripe Subscription.
|
||||
*/
|
||||
const PriceItemSchema = object({
|
||||
price: object({
|
||||
id: string(),
|
||||
}),
|
||||
const PriceObjectSchema = object({
|
||||
id: string(),
|
||||
});
|
||||
|
||||
const SubscriptionItemSchema = object({
|
||||
price: optional(PriceObjectSchema),
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -30,7 +32,8 @@ export const SubscriptionSchema = object({
|
||||
status: optional(string()),
|
||||
current_period_start: optional(number()),
|
||||
current_period_end: optional(number()),
|
||||
cancel_at_period_end: optional(string(), "false"),
|
||||
trial_end: optional(number()),
|
||||
cancel_at_period_end: optional(boolean(), false),
|
||||
metadata: optional(
|
||||
object({
|
||||
userId: optional(string()),
|
||||
@@ -38,7 +41,7 @@ export const SubscriptionSchema = object({
|
||||
),
|
||||
items: optional(
|
||||
object({
|
||||
data: optional(PriceItemSchema),
|
||||
data: optional(array(SubscriptionItemSchema), []),
|
||||
}),
|
||||
),
|
||||
});
|
||||
@@ -47,7 +50,11 @@ export const SubscriptionSchema = object({
|
||||
* Validates a Stripe Invoice object from webhook data.
|
||||
*/
|
||||
export const InvoiceSchema = object({
|
||||
id: optional(string()),
|
||||
subscription: optional(string()),
|
||||
amount_due: optional(number()),
|
||||
currency: optional(string()),
|
||||
status: optional(string()),
|
||||
});
|
||||
|
||||
// Type exports for use in billing.service.ts
|
||||
|
||||
Reference in New Issue
Block a user