deep research addressement

This commit is contained in:
2026-06-01 08:40:10 -04:00
parent c159f07322
commit ba73daa66c
205 changed files with 157390 additions and 951 deletions

View File

@@ -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;

View File

@@ -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", () => {

View File

@@ -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) {

View File

@@ -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");
});
});

View File

@@ -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);
}),
});

View File

@@ -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");

View 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"),
};
}),
});

View File

@@ -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();
}),
});

View File

@@ -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();
});
});

View File

@@ -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(),
};
}),
});

View File

@@ -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);
});
});

View File

@@ -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,
});
}),
});

View File

@@ -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)]),
});

View File

@@ -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)]),
});

View 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)]),
});

View File

@@ -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),
});

View File

@@ -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({

View File

@@ -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(),
});

View File

@@ -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)]),
});

View File

@@ -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);

View File

@@ -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