feat: user auth, API keys, Stripe billing, and dashboard scoping
- NextAuth v5 credentials auth with registration/login pages - API key CRUD (create, list, revoke) with secure hashing - Stripe checkout, webhooks, and customer portal integration - Rate limiting per subscription tier - All dashboard API endpoints scoped to authenticated user - Prisma schema: User, Account, Session, ApiKey, plus Stripe fields - Auth middleware protecting dashboard and API routes Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-Claude) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
3
apps/web/src/app/api/auth/[...nextauth]/route.ts
Normal file
3
apps/web/src/app/api/auth/[...nextauth]/route.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { handlers } from "@/auth";
|
||||
|
||||
export const { GET, POST } = handlers;
|
||||
67
apps/web/src/app/api/auth/register/route.ts
Normal file
67
apps/web/src/app/api/auth/register/route.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { hash } from "bcryptjs";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
const registerSchema = z.object({
|
||||
email: z.email("Invalid email address"),
|
||||
password: z.string().min(8, "Password must be at least 8 characters"),
|
||||
name: z.string().min(1).optional(),
|
||||
});
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body: unknown = await request.json();
|
||||
const parsed = registerSchema.safeParse(body);
|
||||
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: parsed.error.issues[0]?.message ?? "Invalid input" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { email, password, name } = parsed.data;
|
||||
const normalizedEmail = email.toLowerCase();
|
||||
|
||||
const existing = await prisma.user.findUnique({
|
||||
where: { email: normalizedEmail },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
return NextResponse.json(
|
||||
{ error: "An account with this email already exists" },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
const passwordHash = await hash(password, 12);
|
||||
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
email: normalizedEmail,
|
||||
passwordHash,
|
||||
name: name ?? null,
|
||||
subscription: {
|
||||
create: {
|
||||
tier: "FREE",
|
||||
sessionsLimit: 20,
|
||||
},
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(user, { status: 201 });
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,15 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { Prisma } from "@agentlens/database";
|
||||
import { auth } from "@/auth";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const page = parseInt(searchParams.get("page") ?? "1", 10);
|
||||
const limit = parseInt(searchParams.get("limit") ?? "20", 10);
|
||||
@@ -51,8 +57,9 @@ export async function GET(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
// Build where clause
|
||||
const where: Prisma.DecisionPointWhereInput = {};
|
||||
const where: Prisma.DecisionPointWhereInput = {
|
||||
trace: { userId: session.user.id },
|
||||
};
|
||||
if (type) {
|
||||
where.type = type as Prisma.EnumDecisionTypeFilter["equals"];
|
||||
}
|
||||
|
||||
38
apps/web/src/app/api/keys/[id]/route.ts
Normal file
38
apps/web/src/app/api/keys/[id]/route.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export async function DELETE(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id)
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
const apiKey = await prisma.apiKey.findFirst({
|
||||
where: { id, userId: session.user.id, revoked: false },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (!apiKey) {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
await prisma.apiKey.update({
|
||||
where: { id: apiKey.id },
|
||||
data: { revoked: true },
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true }, { status: 200 });
|
||||
} catch (error) {
|
||||
console.error("Error revoking API key:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
77
apps/web/src/app/api/keys/route.ts
Normal file
77
apps/web/src/app/api/keys/route.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { randomBytes, createHash } from "crypto";
|
||||
import { auth } from "@/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id)
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const keys = await prisma.apiKey.findMany({
|
||||
where: { userId: session.user.id, revoked: false },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
keyPrefix: true,
|
||||
createdAt: true,
|
||||
lastUsedAt: true,
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
|
||||
return NextResponse.json(keys, { status: 200 });
|
||||
} catch (error) {
|
||||
console.error("Error listing API keys:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id)
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const name =
|
||||
typeof body.name === "string" && body.name.trim()
|
||||
? body.name.trim()
|
||||
: "Default";
|
||||
|
||||
const rawHex = randomBytes(24).toString("hex");
|
||||
const fullKey = `al_${rawHex}`;
|
||||
const keyPrefix = fullKey.slice(0, 10);
|
||||
const keyHash = createHash("sha256").update(fullKey).digest("hex");
|
||||
|
||||
const apiKey = await prisma.apiKey.create({
|
||||
data: {
|
||||
userId: session.user.id,
|
||||
name,
|
||||
keyHash,
|
||||
keyPrefix,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
keyPrefix: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{ ...apiKey, key: fullKey },
|
||||
{ status: 201 }
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Error creating API key:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
59
apps/web/src/app/api/settings/account/route.ts
Normal file
59
apps/web/src/app/api/settings/account/route.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: session.user.id },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
createdAt: true,
|
||||
subscription: {
|
||||
select: {
|
||||
tier: true,
|
||||
status: true,
|
||||
sessionsUsed: true,
|
||||
sessionsLimit: true,
|
||||
currentPeriodStart: true,
|
||||
currentPeriodEnd: true,
|
||||
stripeCustomerId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "User not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
// Don't expose raw Stripe customer ID to the client
|
||||
const { subscription, ...rest } = user;
|
||||
const safeSubscription = subscription
|
||||
? {
|
||||
tier: subscription.tier,
|
||||
status: subscription.status,
|
||||
sessionsUsed: subscription.sessionsUsed,
|
||||
sessionsLimit: subscription.sessionsLimit,
|
||||
currentPeriodStart: subscription.currentPeriodStart,
|
||||
currentPeriodEnd: subscription.currentPeriodEnd,
|
||||
hasStripeSubscription: !!subscription.stripeCustomerId,
|
||||
}
|
||||
: null;
|
||||
|
||||
return NextResponse.json({ ...rest, subscription: safeSubscription }, { status: 200 });
|
||||
} catch (error) {
|
||||
console.error("Error fetching account:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,22 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { auth } from "@/auth";
|
||||
|
||||
export async function POST() {
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const userId = session.user.id;
|
||||
const traceFilter = { trace: { userId } };
|
||||
|
||||
await prisma.$transaction([
|
||||
prisma.event.deleteMany(),
|
||||
prisma.decisionPoint.deleteMany(),
|
||||
prisma.span.deleteMany(),
|
||||
prisma.trace.deleteMany(),
|
||||
prisma.event.deleteMany({ where: traceFilter }),
|
||||
prisma.decisionPoint.deleteMany({ where: traceFilter }),
|
||||
prisma.span.deleteMany({ where: traceFilter }),
|
||||
prisma.trace.deleteMany({ where: { userId } }),
|
||||
]);
|
||||
|
||||
return NextResponse.json({ success: true }, { status: 200 });
|
||||
|
||||
@@ -1,14 +1,24 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { auth } from "@/auth";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const userId = session.user.id;
|
||||
const traceFilter = { userId };
|
||||
const childFilter = { trace: { userId } };
|
||||
|
||||
const [totalTraces, totalSpans, totalDecisions, totalEvents] =
|
||||
await Promise.all([
|
||||
prisma.trace.count(),
|
||||
prisma.span.count(),
|
||||
prisma.decisionPoint.count(),
|
||||
prisma.event.count(),
|
||||
prisma.trace.count({ where: traceFilter }),
|
||||
prisma.span.count({ where: childFilter }),
|
||||
prisma.decisionPoint.count({ where: childFilter }),
|
||||
prisma.event.count({ where: childFilter }),
|
||||
]);
|
||||
|
||||
return NextResponse.json(
|
||||
|
||||
95
apps/web/src/app/api/stripe/checkout/route.ts
Normal file
95
apps/web/src/app/api/stripe/checkout/route.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { getStripe, TIER_CONFIG } from "@/lib/stripe";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { priceId, tierKey } = body as {
|
||||
priceId?: string;
|
||||
tierKey?: string;
|
||||
};
|
||||
|
||||
let resolvedPriceId = priceId;
|
||||
|
||||
if (!resolvedPriceId && tierKey) {
|
||||
const tierConfig =
|
||||
TIER_CONFIG[tierKey as keyof typeof TIER_CONFIG];
|
||||
if (tierConfig && "priceId" in tierConfig) {
|
||||
resolvedPriceId = tierConfig.priceId;
|
||||
}
|
||||
}
|
||||
|
||||
if (!resolvedPriceId) {
|
||||
return NextResponse.json(
|
||||
{ error: "priceId or tierKey is required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const validPriceIds = [TIER_CONFIG.STARTER.priceId, TIER_CONFIG.PRO.priceId];
|
||||
if (!validPriceIds.includes(resolvedPriceId)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid priceId" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const userId = session.user.id;
|
||||
|
||||
let subscription = await prisma.subscription.findUnique({
|
||||
where: { userId },
|
||||
});
|
||||
|
||||
let stripeCustomerId = subscription?.stripeCustomerId;
|
||||
|
||||
if (!stripeCustomerId) {
|
||||
const customer = await getStripe().customers.create({
|
||||
email: session.user.email,
|
||||
name: session.user.name ?? undefined,
|
||||
metadata: { userId },
|
||||
});
|
||||
stripeCustomerId = customer.id;
|
||||
|
||||
if (subscription) {
|
||||
await prisma.subscription.update({
|
||||
where: { userId },
|
||||
data: { stripeCustomerId },
|
||||
});
|
||||
} else {
|
||||
subscription = await prisma.subscription.create({
|
||||
data: {
|
||||
userId,
|
||||
stripeCustomerId,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const origin =
|
||||
request.headers.get("origin") ?? "https://agentlens.vectry.tech";
|
||||
|
||||
const checkoutSession = await getStripe().checkout.sessions.create({
|
||||
customer: stripeCustomerId,
|
||||
mode: "subscription",
|
||||
line_items: [{ price: resolvedPriceId, quantity: 1 }],
|
||||
success_url: `${origin}/dashboard/settings?session_id={CHECKOUT_SESSION_ID}`,
|
||||
cancel_url: `${origin}/dashboard/settings`,
|
||||
metadata: { userId },
|
||||
});
|
||||
|
||||
return NextResponse.json({ url: checkoutSession.url }, { status: 200 });
|
||||
} catch (error) {
|
||||
console.error("Error creating checkout session:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
41
apps/web/src/app/api/stripe/portal/route.ts
Normal file
41
apps/web/src/app/api/stripe/portal/route.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { getStripe } from "@/lib/stripe";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const subscription = await prisma.subscription.findUnique({
|
||||
where: { userId: session.user.id },
|
||||
select: { stripeCustomerId: true },
|
||||
});
|
||||
|
||||
if (!subscription?.stripeCustomerId) {
|
||||
return NextResponse.json(
|
||||
{ error: "No active subscription to manage" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const origin =
|
||||
request.headers.get("origin") ?? "https://agentlens.vectry.tech";
|
||||
|
||||
const portalSession = await getStripe().billingPortal.sessions.create({
|
||||
customer: subscription.stripeCustomerId,
|
||||
return_url: `${origin}/dashboard/settings`,
|
||||
});
|
||||
|
||||
return NextResponse.json({ url: portalSession.url }, { status: 200 });
|
||||
} catch (error) {
|
||||
console.error("Error creating portal session:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
179
apps/web/src/app/api/stripe/webhook/route.ts
Normal file
179
apps/web/src/app/api/stripe/webhook/route.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import type Stripe from "stripe";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { getStripe, TIER_CONFIG } from "@/lib/stripe";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
function tierFromPriceId(priceId: string | null): "FREE" | "STARTER" | "PRO" {
|
||||
if (priceId === TIER_CONFIG.STARTER.priceId) return "STARTER";
|
||||
if (priceId === TIER_CONFIG.PRO.priceId) return "PRO";
|
||||
return "FREE";
|
||||
}
|
||||
|
||||
function sessionsLimitForTier(tier: "FREE" | "STARTER" | "PRO"): number {
|
||||
return TIER_CONFIG[tier].sessionsLimit;
|
||||
}
|
||||
|
||||
async function handleCheckoutCompleted(
|
||||
checkoutSession: Stripe.Checkout.Session
|
||||
) {
|
||||
const userId = checkoutSession.metadata?.userId;
|
||||
if (!userId) return;
|
||||
|
||||
const subscriptionId = checkoutSession.subscription as string;
|
||||
const customerId = checkoutSession.customer as string;
|
||||
|
||||
const sub = await getStripe().subscriptions.retrieve(subscriptionId);
|
||||
const firstItem = sub.items.data[0];
|
||||
const priceId = firstItem?.price?.id ?? null;
|
||||
const tier = tierFromPriceId(priceId);
|
||||
const periodStart = firstItem?.current_period_start
|
||||
? new Date(firstItem.current_period_start * 1000)
|
||||
: new Date();
|
||||
const periodEnd = firstItem?.current_period_end
|
||||
? new Date(firstItem.current_period_end * 1000)
|
||||
: new Date();
|
||||
|
||||
await prisma.subscription.upsert({
|
||||
where: { userId },
|
||||
update: {
|
||||
stripeCustomerId: customerId,
|
||||
stripeSubscriptionId: subscriptionId,
|
||||
stripePriceId: priceId,
|
||||
tier,
|
||||
sessionsLimit: sessionsLimitForTier(tier),
|
||||
sessionsUsed: 0,
|
||||
status: "ACTIVE",
|
||||
currentPeriodStart: periodStart,
|
||||
currentPeriodEnd: periodEnd,
|
||||
},
|
||||
create: {
|
||||
userId,
|
||||
stripeCustomerId: customerId,
|
||||
stripeSubscriptionId: subscriptionId,
|
||||
stripePriceId: priceId,
|
||||
tier,
|
||||
sessionsLimit: sessionsLimitForTier(tier),
|
||||
sessionsUsed: 0,
|
||||
status: "ACTIVE",
|
||||
currentPeriodStart: periodStart,
|
||||
currentPeriodEnd: periodEnd,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function handleSubscriptionUpdated(sub: Stripe.Subscription) {
|
||||
const firstItem = sub.items.data[0];
|
||||
const priceId = firstItem?.price?.id ?? null;
|
||||
const tier = tierFromPriceId(priceId);
|
||||
|
||||
const statusMap: Record<string, "ACTIVE" | "PAST_DUE" | "CANCELED" | "UNPAID"> = {
|
||||
active: "ACTIVE",
|
||||
past_due: "PAST_DUE",
|
||||
canceled: "CANCELED",
|
||||
unpaid: "UNPAID",
|
||||
};
|
||||
|
||||
const dbStatus = statusMap[sub.status] ?? "ACTIVE";
|
||||
const periodStart = firstItem?.current_period_start
|
||||
? new Date(firstItem.current_period_start * 1000)
|
||||
: undefined;
|
||||
const periodEnd = firstItem?.current_period_end
|
||||
? new Date(firstItem.current_period_end * 1000)
|
||||
: undefined;
|
||||
|
||||
await prisma.subscription.updateMany({
|
||||
where: { stripeSubscriptionId: sub.id },
|
||||
data: {
|
||||
tier,
|
||||
stripePriceId: priceId,
|
||||
sessionsLimit: sessionsLimitForTier(tier),
|
||||
status: dbStatus,
|
||||
...(periodStart && { currentPeriodStart: periodStart }),
|
||||
...(periodEnd && { currentPeriodEnd: periodEnd }),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function handleSubscriptionDeleted(sub: Stripe.Subscription) {
|
||||
await prisma.subscription.updateMany({
|
||||
where: { stripeSubscriptionId: sub.id },
|
||||
data: {
|
||||
status: "CANCELED",
|
||||
tier: "FREE",
|
||||
sessionsLimit: TIER_CONFIG.FREE.sessionsLimit,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function handleInvoicePaid(invoice: Stripe.Invoice) {
|
||||
const subDetail = invoice.parent?.subscription_details?.subscription;
|
||||
const subscriptionId =
|
||||
typeof subDetail === "string" ? subDetail : subDetail?.id;
|
||||
|
||||
if (!subscriptionId) return;
|
||||
|
||||
await prisma.subscription.updateMany({
|
||||
where: { stripeSubscriptionId: subscriptionId },
|
||||
data: { sessionsUsed: 0 },
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const body = await request.text();
|
||||
const sig = request.headers.get("stripe-signature");
|
||||
|
||||
if (!sig) {
|
||||
return NextResponse.json(
|
||||
{ error: "Missing stripe-signature header" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
let event: Stripe.Event;
|
||||
try {
|
||||
event = getStripe().webhooks.constructEvent(
|
||||
body,
|
||||
sig,
|
||||
process.env.STRIPE_WEBHOOK_SECRET!
|
||||
);
|
||||
} catch (err) {
|
||||
console.error("Webhook signature verification failed");
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid signature" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
switch (event.type) {
|
||||
case "checkout.session.completed":
|
||||
await handleCheckoutCompleted(
|
||||
event.data.object as Stripe.Checkout.Session
|
||||
);
|
||||
break;
|
||||
case "customer.subscription.updated":
|
||||
await handleSubscriptionUpdated(
|
||||
event.data.object as Stripe.Subscription
|
||||
);
|
||||
break;
|
||||
case "customer.subscription.deleted":
|
||||
await handleSubscriptionDeleted(
|
||||
event.data.object as Stripe.Subscription
|
||||
);
|
||||
break;
|
||||
case "invoice.paid":
|
||||
await handleInvoicePaid(event.data.object as Stripe.Invoice);
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error handling ${event.type}:`, error);
|
||||
return NextResponse.json(
|
||||
{ error: "Webhook handler failed" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ received: true }, { status: 200 });
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { auth } from "@/auth";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
@@ -23,14 +24,19 @@ export async function GET(
|
||||
{ params }: RouteParams
|
||||
) {
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
if (!id || typeof id !== "string") {
|
||||
return NextResponse.json({ error: "Invalid trace ID" }, { status: 400 });
|
||||
}
|
||||
|
||||
const trace = await prisma.trace.findUnique({
|
||||
where: { id },
|
||||
const trace = await prisma.trace.findFirst({
|
||||
where: { id, userId: session.user.id },
|
||||
include: {
|
||||
decisionPoints: {
|
||||
orderBy: {
|
||||
@@ -106,14 +112,19 @@ export async function DELETE(
|
||||
{ params }: RouteParams
|
||||
) {
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
if (!id || typeof id !== "string") {
|
||||
return NextResponse.json({ error: "Invalid trace ID" }, { status: 400 });
|
||||
}
|
||||
|
||||
const trace = await prisma.trace.findUnique({
|
||||
where: { id },
|
||||
const trace = await prisma.trace.findFirst({
|
||||
where: { id, userId: session.user.id },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { Prisma } from "@agentlens/database";
|
||||
import { validateApiKey } from "@/lib/api-key";
|
||||
import { auth } from "@/auth";
|
||||
|
||||
// Types
|
||||
interface DecisionPointPayload {
|
||||
@@ -90,11 +92,55 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: "Missing or invalid Authorization header" }, { status: 401 });
|
||||
}
|
||||
|
||||
const apiKey = authHeader.slice(7);
|
||||
if (!apiKey) {
|
||||
const rawApiKey = authHeader.slice(7);
|
||||
if (!rawApiKey) {
|
||||
return NextResponse.json({ error: "Missing API key in Authorization header" }, { status: 401 });
|
||||
}
|
||||
|
||||
const keyValidation = await validateApiKey(rawApiKey);
|
||||
if (!keyValidation) {
|
||||
return NextResponse.json({ error: "Invalid API key" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { userId, subscription } = keyValidation;
|
||||
|
||||
if (!subscription) {
|
||||
return NextResponse.json({ error: "No subscription found for this user" }, { status: 403 });
|
||||
}
|
||||
|
||||
const tier = subscription.tier;
|
||||
const sessionsLimit = subscription.sessionsLimit;
|
||||
|
||||
if (tier === "FREE") {
|
||||
const startOfToday = new Date();
|
||||
startOfToday.setUTCHours(0, 0, 0, 0);
|
||||
|
||||
const dailyCount = await prisma.trace.count({
|
||||
where: {
|
||||
userId,
|
||||
createdAt: { gte: startOfToday },
|
||||
},
|
||||
});
|
||||
|
||||
if (dailyCount >= sessionsLimit) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `Rate limit exceeded. Current plan: ${tier}. Limit: ${sessionsLimit}/day. Upgrade at /settings/billing`,
|
||||
},
|
||||
{ status: 429 }
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (subscription.sessionsUsed >= sessionsLimit) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `Rate limit exceeded. Current plan: ${tier}. Limit: ${sessionsLimit}/month. Upgrade at /settings/billing`,
|
||||
},
|
||||
{ status: 429 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Parse and validate request body
|
||||
const body: BatchTracesRequest = await request.json();
|
||||
if (!body.traces || !Array.isArray(body.traces)) {
|
||||
@@ -190,8 +236,14 @@ export async function POST(request: NextRequest) {
|
||||
// final flushes both work seamlessly.
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
const upserted: string[] = [];
|
||||
let newTraceCount = 0;
|
||||
|
||||
for (const trace of body.traces) {
|
||||
const existing = await tx.trace.findUnique({
|
||||
where: { id: trace.id },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
const traceData = {
|
||||
name: trace.name,
|
||||
sessionId: trace.sessionId,
|
||||
@@ -205,13 +257,16 @@ export async function POST(request: NextRequest) {
|
||||
endedAt: trace.endedAt ? new Date(trace.endedAt) : null,
|
||||
};
|
||||
|
||||
// 1. Upsert the trace record
|
||||
await tx.trace.upsert({
|
||||
where: { id: trace.id },
|
||||
create: { id: trace.id, ...traceData },
|
||||
create: { id: trace.id, userId, ...traceData },
|
||||
update: traceData,
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
newTraceCount++;
|
||||
}
|
||||
|
||||
// 2. Delete existing child records (order matters for FK constraints:
|
||||
// decision points reference spans, so delete decisions first)
|
||||
await tx.decisionPoint.deleteMany({ where: { traceId: trace.id } });
|
||||
@@ -283,6 +338,13 @@ export async function POST(request: NextRequest) {
|
||||
upserted.push(trace.id);
|
||||
}
|
||||
|
||||
if (newTraceCount > 0 && tier !== "FREE") {
|
||||
await tx.subscription.update({
|
||||
where: { id: subscription.id },
|
||||
data: { sessionsUsed: { increment: newTraceCount } },
|
||||
});
|
||||
}
|
||||
|
||||
return upserted;
|
||||
});
|
||||
|
||||
@@ -300,6 +362,11 @@ export async function POST(request: NextRequest) {
|
||||
// GET /api/traces — List traces with pagination
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const page = parseInt(searchParams.get("page") ?? "1", 10);
|
||||
const limit = parseInt(searchParams.get("limit") ?? "20", 10);
|
||||
@@ -339,8 +406,7 @@ export async function GET(request: NextRequest) {
|
||||
return NextResponse.json({ error: "Invalid dateTo parameter. Must be a valid ISO date string." }, { status: 400 });
|
||||
}
|
||||
|
||||
// Build where clause
|
||||
const where: Record<string, unknown> = {};
|
||||
const where: Record<string, unknown> = { userId: session.user.id };
|
||||
if (status) {
|
||||
where.status = status;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { auth } from "@/auth";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -22,6 +23,13 @@ interface TraceUpdateData {
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const currentUserId = session.user.id;
|
||||
|
||||
const headers = new Headers();
|
||||
headers.set("Content-Type", "text/event-stream");
|
||||
headers.set("Cache-Control", "no-cache");
|
||||
@@ -43,6 +51,7 @@ export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const newTraces = await prisma.trace.findMany({
|
||||
where: {
|
||||
userId: currentUserId,
|
||||
OR: [
|
||||
{ createdAt: { gt: lastCheck } },
|
||||
{ updatedAt: { gt: lastCheck } },
|
||||
|
||||
Reference in New Issue
Block a user