diff --git a/README.md b/README.md index e215bc4..5917030 100644 --- a/README.md +++ b/README.md @@ -1,36 +1,74 @@ -This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). +# CodeHorse -## Getting Started +CodeHorse is an AI-assisted code review and repository intelligence platform. It connects to your GitHub repositories, indexes the codebase with Pinecone-powered RAG, and uses Gemini-backed reviewers to leave detailed comments on pull requests while surfacing personal activity insights inside a Next.js dashboard. -First, run the development server: +## Why it exists +- Eliminate the wait for code reviews by automatically posting actionable AI feedback on every PR. +- Keep a personal overview of commits, pull requests, and generated reviews via an activity dashboard. +- Centralize repository management (connect/disconnect, usage tracking) without leaving the browser. +- Blend contextual retrieval, structured review prompts, and GitHub webhooks so feedback stays relevant to the codebase. -```bash -npm run dev -# or -yarn dev -# or -pnpm dev -# or -bun dev -``` +## Feature highlights +- **Dashboard analytics**: Charts, contribution graph, and counters for repos, commits, PRs, and AI reviews ([app/dashboard](app/dashboard/page.tsx)). +- **Repository manager**: Infinite-scroll view of GitHub repos with one-click connect that provisions webhooks and triggers indexing jobs ([app/dashboard/repository/page.tsx]). +- **AI review history**: Access recently generated reviews, status, and deep links back to GitHub ([app/dashboard/reviews/page.tsx]). +- **Background jobs with Inngest**: `repository.connected` events stream files into Pinecone; `pr.review.requested` events fetch diffs, RAG context, and post Gemini reviews back to GitHub ([inngest/functions](inngest/functions/index.ts)). +- **Stack**: Next.js App Router (16), React 19, TypeScript, Prisma + PostgreSQL, BetterAuth (GitHub OAuth), Pinecone, Inngest, TanStack Query, Tailwind CSS 4. + +## Prerequisites +- Node.js 20+ +- PostgreSQL database URL (Neon, Supabase, etc.) +- Pinecone index (dimensions must match your embeddings config) +- GitHub OAuth app (for BetterAuth) and GitHub App/webhook secret for PR events +- Optional: `ngrok` or similar tunnel so GitHub can reach your local `/api/webhooks/github` + +## Environment variables + +| Variable | Required | Purpose | +| --- | --- | --- | +| `NEXT_PUBLIC_APP_BASE_URL` | ✅ | Public origin used for auth redirects and webhook URLs. | +| `BETTER_AUTH_URL` | ✅ | Same as base URL unless proxied; consumed by the BetterAuth client. | +| `DATABASE_URL` | ✅ | PostgreSQL connection string used by Prisma. | +| `GITHUB_CLIENT_ID` / `GITHUB_CLIENT_SECRET` | ✅ (prod) | GitHub OAuth credentials for production. | +| `GITHUB_CLIENT_ID_DEV` / `GITHUB_CLIENT_SECRET_DEV` | ✅ (dev) | Separate OAuth creds for local development. | +| `PINECONE_DB_API_KEY` | ✅ | API key used to talk to your Pinecone index. | -Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. +> Tip: keep `NEXT_PUBLIC_APP_BASE_URL` and `BETTER_AUTH_URL` in sync (`http://localhost:3000` during development). Update GitHub OAuth callback + homepage URLs whenever you change tunnels or deploy to Vercel. -You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. +## Local development -This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. +```bash +# Install deps & generate the Prisma client +npm install + +# Apply database schema (creates tables + seed state if configured) +npx prisma migrate dev -## Learn More +# Run the Next.js dev server +npm run dev -To learn more about Next.js, take a look at the following resources: +# In another terminal, start the Inngest dev server for background jobs +npx inngest dev -- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. -- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. +# (Optional) expose the app publicly so GitHub can deliver webhooks +ngrok http 3000 +``` -You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! +- Connecting a repo from the dashboard will call GitHub, store metadata in Prisma, enqueue `repository.connected`, and immediately start Pinecone indexing. +- Opening or updating a PR will hit the `/api/webhooks/github` route, fire `pr.review.requested`, gather diff/context, generate the Gemini review, post it back to GitHub, and persist the review in PostgreSQL for the dashboard. -## Deploy on Vercel +## Project structure -The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. +``` +app/ # Next.js App Router routes (auth, dashboard, API handlers) +components/ # Reusable UI primitives (Radix-based) +lib/ # Auth, DB, Pinecone clients, shared utilities +module/ # Domain modules (github integration, AI/RAG utils, dashboard) +inngest/ # Background functions for indexing + review generation +prisma/ # Prisma schema and migrations +``` -Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. +## Deployment notes +- Set `NEXT_PUBLIC_APP_BASE_URL` and `BETTER_AUTH_URL` to your production domain before deploying to Vercel. +- Recreate the GitHub OAuth + webhook URLs inside your GitHub app to point at the live domain. +- Provision the same Pinecone index + PostgreSQL database that you used locally; run `npx prisma migrate deploy` as part of your CI/CD workflow. diff --git a/app/api/subscription/create/route.ts b/app/api/subscription/create/route.ts new file mode 100644 index 0000000..63a2a9e --- /dev/null +++ b/app/api/subscription/create/route.ts @@ -0,0 +1,87 @@ +import { NextRequest, NextResponse } from "next/server"; +import { auth } from "@/lib/auth"; +import prisma from "@/lib/db"; +import razorpay from "@/module/payment/config/razorpay"; + +/** + * POST /api/subscription/create + * + * Creates a Razorpay subscription for the authenticated user. + * - Validates user session + * - Checks if user already has an active subscription + * - Creates a Razorpay subscription using the plan ID from env + * - Saves the razorpaySubscriptionId on the User record + * - Returns subscriptionId + keyId for the frontend checkout modal + */ +export async function POST(req: NextRequest) { + try { + const session = await auth.api.getSession({ + headers: req.headers, + }); + + if (!session?.user) { + return NextResponse.json( + { error: "Unauthorized" }, + { status: 401 } + ); + } + + // Check if user already has an active subscription + const user = await prisma.user.findUnique({ + where: { id: session.user.id }, + }); + + if (!user) { + return NextResponse.json( + { error: "User not found" }, + { status: 404 } + ); + } + + if (user.subscriptionTier === "PRO" && user.subscriptionStatus === "ACTIVE") { + return NextResponse.json( + { error: "You already have an active subscription" }, + { status: 400 } + ); + } + + const planId = process.env.RAZORPAY_PLAN_ID; + if (!planId) { + console.error("RAZORPAY_PLAN_ID is not configured"); + return NextResponse.json( + { error: "Payment configuration error" }, + { status: 500 } + ); + } + + // Create a Razorpay subscription + const subscription = await razorpay().subscriptions.create({ + plan_id: planId, + customer_notify: 1, + total_count: 12, // 12 monthly billing cycles + notes: { + userId: session.user.id, + userEmail: session.user.email, + }, + }); + + // Save the subscription ID on the user record + await prisma.user.update({ + where: { id: session.user.id }, + data: { + razorpaySubscriptionId: subscription.id, + }, + }); + + return NextResponse.json({ + subscriptionId: subscription.id, + keyId: process.env.NEXT_PUBLIC_RAZORPAY_KEY_ID, + }); + } catch (error) { + console.error("Error creating subscription:", error); + return NextResponse.json( + { error: "Failed to create subscription" }, + { status: 500 } + ); + } +} diff --git a/app/api/subscription/verify/route.ts b/app/api/subscription/verify/route.ts new file mode 100644 index 0000000..7d29892 --- /dev/null +++ b/app/api/subscription/verify/route.ts @@ -0,0 +1,78 @@ +import { NextRequest, NextResponse } from "next/server"; +import { auth } from "@/lib/auth"; +import prisma from "@/lib/db"; +import crypto from "crypto"; + +/** + * POST /api/subscription/verify + * + * Verifies the Razorpay payment signature after the checkout modal completes. + * This prevents users from spoofing a successful payment on the frontend. + * + * Flow: + * 1. Frontend sends razorpay_payment_id, razorpay_subscription_id, razorpay_signature + * 2. We compute HMAC-SHA256(payment_id + "|" + subscription_id) using our key_secret + * 3. Compare with the signature from Razorpay + * 4. On match: activate the subscription in our DB + */ +export async function POST(req: NextRequest) { + try { + const session = await auth.api.getSession({ + headers: req.headers, + }); + + if (!session?.user) { + return NextResponse.json( + { error: "Unauthorized" }, + { status: 401 } + ); + } + + const body = await req.json(); + const { + razorpay_payment_id, + razorpay_subscription_id, + razorpay_signature, + } = body; + + if (!razorpay_payment_id || !razorpay_subscription_id || !razorpay_signature) { + return NextResponse.json( + { error: "Missing required payment fields" }, + { status: 400 } + ); + } + + // Verify signature + const keySecret = process.env.RAZORPAY_KEY_SECRET!; + const generatedSignature = crypto + .createHmac("sha256", keySecret) + .update(`${razorpay_payment_id}|${razorpay_subscription_id}`) + .digest("hex"); + + if (generatedSignature !== razorpay_signature) { + console.error("Razorpay signature verification failed"); + return NextResponse.json( + { error: "Payment verification failed" }, + { status: 400 } + ); + } + + // Signature is valid — activate the subscription + await prisma.user.update({ + where: { id: session.user.id }, + data: { + subscriptionTier: "PRO", + subscriptionStatus: "ACTIVE", + razorpaySubscriptionId: razorpay_subscription_id, + }, + }); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error("Error verifying payment:", error); + return NextResponse.json( + { error: "Payment verification failed" }, + { status: 500 } + ); + } +} diff --git a/app/api/webhooks/polar/route.ts b/app/api/webhooks/polar/route.ts deleted file mode 100644 index af8081e..0000000 --- a/app/api/webhooks/polar/route.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { NextResponse } from "next/server"; - - -export async function POST(request: Request) { - return NextResponse.json({received: true}) -} \ No newline at end of file diff --git a/app/api/webhooks/razorpay/route.ts b/app/api/webhooks/razorpay/route.ts new file mode 100644 index 0000000..c327ac1 --- /dev/null +++ b/app/api/webhooks/razorpay/route.ts @@ -0,0 +1,172 @@ +import { NextRequest, NextResponse } from "next/server"; +import crypto from "crypto"; +import prisma from "@/lib/db"; + +/** + * POST /api/webhooks/razorpay + * + * Handles asynchronous Razorpay webhook events for subscription lifecycle management. + * These webhooks are the source of truth for subscription status — more reliable than + * frontend callbacks since they're server-to-server. + * + * Webhook events handled: + * - subscription.activated → tier=PRO, status=ACTIVE + * - subscription.charged → tier=PRO, status=ACTIVE (renewal) + * - subscription.pending → status=PENDING + * - subscription.halted → tier=FREE, status=EXPIRED + * - subscription.cancelled → tier=FREE, status=CANCELED + * - subscription.completed → tier=FREE, status=EXPIRED + * - subscription.paused → status=PAUSED + * - subscription.resumed → status=ACTIVE + * + * Setup: Configure this URL in Razorpay Dashboard → Webhooks + * URL: https://your-domain.com/api/webhooks/razorpay + */ +export async function POST(req: NextRequest) { + try { + const rawBody = await req.text(); + const signature = req.headers.get("x-razorpay-signature"); + + if (!signature) { + return NextResponse.json( + { error: "Missing signature" }, + { status: 400 } + ); + } + + // Verify webhook signature + const webhookSecret = process.env.RAZORPAY_WEBHOOK_SECRET!; + const expectedSignature = crypto + .createHmac("sha256", webhookSecret) + .update(rawBody) + .digest("hex"); + + if (expectedSignature !== signature) { + console.error("Razorpay webhook signature verification failed"); + return NextResponse.json( + { error: "Invalid signature" }, + { status: 400 } + ); + } + + const payload = JSON.parse(rawBody); + const event = payload.event; + const subscriptionEntity = payload.payload?.subscription?.entity; + + if (!subscriptionEntity) { + // Not a subscription event, acknowledge it + return NextResponse.json({ received: true }); + } + + const razorpaySubscriptionId = subscriptionEntity.id; + + // Find the user by their Razorpay subscription ID + const user = await prisma.user.findFirst({ + where: { razorpaySubscriptionId }, + }); + + if (!user) { + console.warn( + `Webhook received for unknown subscription: ${razorpaySubscriptionId}` + ); + // Return 200 to prevent Razorpay from retrying + return NextResponse.json({ received: true }); + } + + // Map Razorpay events to our subscription state + switch (event) { + case "subscription.activated": + await prisma.user.update({ + where: { id: user.id }, + data: { + subscriptionTier: "PRO", + subscriptionStatus: "ACTIVE", + }, + }); + break; + + case "subscription.charged": + // Successful renewal payment + await prisma.user.update({ + where: { id: user.id }, + data: { + subscriptionTier: "PRO", + subscriptionStatus: "ACTIVE", + }, + }); + break; + + case "subscription.pending": + // Payment failed, retries may follow + await prisma.user.update({ + where: { id: user.id }, + data: { + subscriptionStatus: "PENDING", + }, + }); + break; + + case "subscription.halted": + // All retries exhausted + await prisma.user.update({ + where: { id: user.id }, + data: { + subscriptionTier: "FREE", + subscriptionStatus: "EXPIRED", + }, + }); + break; + + case "subscription.cancelled": + await prisma.user.update({ + where: { id: user.id }, + data: { + subscriptionTier: "FREE", + subscriptionStatus: "CANCELED", + }, + }); + break; + + case "subscription.completed": + // All billing cycles completed + await prisma.user.update({ + where: { id: user.id }, + data: { + subscriptionTier: "FREE", + subscriptionStatus: "EXPIRED", + }, + }); + break; + + case "subscription.paused": + await prisma.user.update({ + where: { id: user.id }, + data: { + subscriptionStatus: "PAUSED", + }, + }); + break; + + case "subscription.resumed": + await prisma.user.update({ + where: { id: user.id }, + data: { + subscriptionTier: "PRO", + subscriptionStatus: "ACTIVE", + }, + }); + break; + + default: + console.log(`Unhandled Razorpay webhook event: ${event}`); + } + + return NextResponse.json({ received: true }); + } catch (error) { + console.error("Error processing Razorpay webhook:", error); + return NextResponse.json( + { error: "Webhook processing failed" }, + { status: 500 } + ); + } +} diff --git a/app/dashboard/reviews/page.tsx b/app/dashboard/reviews/page.tsx index 358d9ce..6351869 100644 --- a/app/dashboard/reviews/page.tsx +++ b/app/dashboard/reviews/page.tsx @@ -9,7 +9,7 @@ import { getReviews } from "@/module/review/actions"; import { formatDistanceToNow } from "date-fns"; const ReviewsPage = () => { - const { data: reviews, isLoading } = useQuery({ + const { data: reviews = [], isLoading } = useQuery({ queryKey: ["reviews"], queryFn: async () => { return await getReviews() diff --git a/app/dashboard/subscription/page.tsx b/app/dashboard/subscription/page.tsx index 61a775d..4e1b577 100644 --- a/app/dashboard/subscription/page.tsx +++ b/app/dashboard/subscription/page.tsx @@ -4,14 +4,21 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; -import { Check, X, Loader2, ExternalLink, RefreshCw } from "lucide-react"; -import { checkout, customer } from "@/lib/auth-client"; +import { Check, X, Loader2, RefreshCw, CreditCard, Sparkles, Shield, Zap } from "lucide-react"; import { useSearchParams } from "next/navigation"; import { useQuery } from "@tanstack/react-query"; import { useState, useEffect } from "react"; import { toast } from "sonner" import { getSubscriptionData, syncSubscriptionStatus } from "@/module/payment/actions"; +import { cancelSubscription } from "@/module/payment/actions/cancel"; import { Spinner } from "@/components/ui/spinner"; +import Script from "next/script"; + +declare global { + interface Window { + Razorpay: any; + } +} const PLAN_FEATURES = { free: [ @@ -34,7 +41,7 @@ const PLAN_FEATURES = { export default function SubscriptionPage() { const [checkoutLoading, setCheckoutLoading] = useState(false); - const [portalLoading, setPortalLoading] = useState(false); + const [cancelLoading, setCancelLoading] = useState(false); const [syncLoading, setSyncLoading] = useState(false); const searchParams = useSearchParams(); const success = searchParams.get("success"); @@ -86,7 +93,7 @@ export default function SubscriptionPage() { ) } - if (!data.user) { + if (!data?.user) { return (
Choose the perfect plan for your needs
+ <> + +Choose the perfect plan for your needs
++ {isPro ? "No limits on reviews" : "Free tier allows 5 reviews per repository"} +
- {isPro ? "No limits on reviews" : "Free tier allows 5 reviews per repository"} -
+ {!isPro &&