From 6847602e6e0cdfd03b3707cdad37cfed210b0e2a Mon Sep 17 00:00:00 2001 From: Aman Raj <113578582+huamanraj@users.noreply.github.com> Date: Mon, 15 Dec 2025 22:14:48 +0530 Subject: [PATCH] refactor: update user context and authentication flow to include subscription details --- apps/api/src/context.ts | 4 +- apps/api/src/index.ts | 14 +--- apps/api/src/routers/auth.ts | 46 +++++++++++- apps/api/src/services/auth.service.ts | 61 ++++++++++++--- apps/api/src/trpc.ts | 32 +++++++- apps/api/src/utils/auth.ts | 74 +++++++++++++++++-- .../components/checkout/CheckoutWrapper.tsx | 18 ++++- .../src/components/payment/PaymentFlow.tsx | 28 ++----- apps/web/src/hooks/useSubscription.ts | 6 +- apps/web/src/lib/auth/config.ts | 56 +++++++++++++- apps/web/src/lib/auth/protected-routes.ts | 15 ++-- apps/web/src/middleware.ts | 30 ++++++-- apps/web/src/types/next-auth.d.ts | 23 ++++++ 13 files changed, 324 insertions(+), 83 deletions(-) diff --git a/apps/api/src/context.ts b/apps/api/src/context.ts index 22434598..a12f9d29 100644 --- a/apps/api/src/context.ts +++ b/apps/api/src/context.ts @@ -1,6 +1,6 @@ import type { CreateExpressContextOptions } from "@trpc/server/adapters/express"; import prisma from "./prisma.js"; -import type { User } from "@prisma/client"; +import type { UserWithSubscription } from "./utils/auth.js"; export async function createContext({ req, @@ -10,7 +10,7 @@ export async function createContext({ res: CreateExpressContextOptions["res"]; db: typeof prisma; ip?: string; - user?: User | null; + user?: UserWithSubscription | null; }> { const ip = req.ip || req.socket.remoteAddress || "unknown"; diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index f1925322..43e71c3e 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -111,7 +111,6 @@ app.get("/join-community", apiLimiter, async (req: Request, res: Response) => { const token = authHeader.substring(7); - // Verify token and get user let user; try { user = await verifyToken(token); @@ -119,18 +118,7 @@ app.get("/join-community", apiLimiter, async (req: Request, res: Response) => { return res.status(401).json({ error: "Unauthorized - Invalid token" }); } - // Check if user has an active subscription - const subscription = await prismaModule.prisma.subscription.findFirst({ - where: { - userId: user.id, - status: SUBSCRIPTION_STATUS.ACTIVE, - endDate: { - gte: new Date(), - }, - }, - }); - - if (!subscription) { + if (!user.isPaidUser || !user.subscription) { return res.status(403).json({ error: "Forbidden - Active subscription required to join community", }); diff --git a/apps/api/src/routers/auth.ts b/apps/api/src/routers/auth.ts index 2a208f60..e873f279 100644 --- a/apps/api/src/routers/auth.ts +++ b/apps/api/src/routers/auth.ts @@ -68,7 +68,51 @@ export const authRouter = router({ }), getSession: protectedProcedure.query( async ({ ctx }: { ctx: { user: any } }) => { - return authService.getSession(ctx.user); + const userId = ctx.user.id; + const user = await ctx.db.prisma.user.findUnique({ + where: { id: userId }, + include: { + subscriptions: { + where: { + status: "active", + endDate: { + gte: new Date(), + }, + }, + orderBy: { + startDate: "desc", + }, + take: 1, + include: { + plan: true, + }, + }, + }, + }); + + if (!user) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "User not found", + }); + } + + const activeSubscription = user.subscriptions[0] || null; + + return authService.getSession({ + ...user, + isPaidUser: !!activeSubscription, + subscription: activeSubscription + ? { + id: activeSubscription.id, + status: activeSubscription.status, + startDate: activeSubscription.startDate, + endDate: activeSubscription.endDate, + planId: activeSubscription.planId, + planName: activeSubscription.plan?.name, + } + : null, + }); } ), generateJWT: publicProcedure diff --git a/apps/api/src/services/auth.service.ts b/apps/api/src/services/auth.service.ts index 93ac0e41..8015ea87 100644 --- a/apps/api/src/services/auth.service.ts +++ b/apps/api/src/services/auth.service.ts @@ -1,5 +1,6 @@ import { generateToken } from "../utils/auth.js"; import type { PrismaClient } from "@prisma/client"; +import { SUBSCRIPTION_STATUS } from "../constants/subscription.js"; interface GoogleAuthInput { email: string; @@ -39,20 +40,49 @@ export const authService = { authMethod: authMethod || "google", lastLogin: new Date(), }, - select: { - id: true, - email: true, - firstName: true, - authMethod: true, - createdAt: true, - lastLogin: true, + include: { + subscriptions: { + where: { + status: SUBSCRIPTION_STATUS.ACTIVE, + endDate: { + gte: new Date(), + }, + }, + orderBy: { + startDate: "desc", + }, + take: 1, + include: { + plan: true, + }, + }, }, }); + const activeSubscription = user.subscriptions[0] || null; const token = generateToken(email); return { - user, + user: { + id: user.id, + email: user.email, + firstName: user.firstName, + authMethod: user.authMethod, + createdAt: user.createdAt, + lastLogin: user.lastLogin, + completedSteps: user.completedSteps, + isPaidUser: !!activeSubscription, + subscription: activeSubscription + ? { + id: activeSubscription.id, + status: activeSubscription.status, + startDate: activeSubscription.startDate, + endDate: activeSubscription.endDate, + planId: activeSubscription.planId, + planName: activeSubscription.plan?.name, + } + : null, + }, token, }; }, @@ -197,12 +227,19 @@ export const authService = { }); }, - /** - * Get user session information - */ getSession(user: any) { return { - user, + user: { + id: user.id, + email: user.email, + firstName: user.firstName, + authMethod: user.authMethod, + createdAt: user.createdAt, + lastLogin: user.lastLogin, + completedSteps: user.completedSteps, + isPaidUser: user.isPaidUser || false, + subscription: user.subscription || null, + }, }; }, }; diff --git a/apps/api/src/trpc.ts b/apps/api/src/trpc.ts index d9049676..ff10d738 100644 --- a/apps/api/src/trpc.ts +++ b/apps/api/src/trpc.ts @@ -1,7 +1,7 @@ import { initTRPC, TRPCError } from "@trpc/server"; import superjson from "superjson"; import type { Context } from "./context.js"; -import { verifyToken } from "./utils/auth.js"; +import { verifyToken, type UserWithSubscription } from "./utils/auth.js"; const t = initTRPC.context().create({ transformer: superjson, @@ -24,7 +24,7 @@ const isAuthed = t.middleware(async ({ ctx, next }) => { return next({ ctx: { ...ctx, - user, + user: user as UserWithSubscription, }, }); } catch (error) { @@ -35,6 +35,32 @@ const isAuthed = t.middleware(async ({ ctx, next }) => { } }); +const requiresSubscription = t.middleware(async ({ ctx, next }) => { + if (!ctx.user) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "Authentication required", + }); + } + + const user = ctx.user as UserWithSubscription; + + if (!user.isPaidUser || !user.subscription) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Active subscription required", + }); + } + + return next({ + ctx: { + ...ctx, + user, + }, + }); +}); + export const router = t.router; export const publicProcedure = t.procedure; -export const protectedProcedure:any = t.procedure.use(isAuthed); +export const protectedProcedure = t.procedure.use(isAuthed) as any; +export const proProcedure = protectedProcedure.use(requiresSubscription) as any; diff --git a/apps/api/src/utils/auth.ts b/apps/api/src/utils/auth.ts index a0a1b282..f61eeceb 100644 --- a/apps/api/src/utils/auth.ts +++ b/apps/api/src/utils/auth.ts @@ -1,33 +1,95 @@ import jwt from "jsonwebtoken"; -import prisma from "../prisma.js"; +import prismaModule from "../prisma.js"; +import { SUBSCRIPTION_STATUS } from "../constants/subscription.js"; +const { prisma } = prismaModule; const JWT_SECRET = process.env.JWT_SECRET! as string; if (!process.env.JWT_SECRET) { throw new Error("JWT_SECRET is not defined in the environment variables"); } +export interface UserWithSubscription { + id: string; + email: string; + firstName: string; + authMethod: string; + createdAt: Date; + lastLogin: Date; + completedSteps: any; + isPaidUser: boolean; + subscription: { + id: string; + status: string; + startDate: Date; + endDate: Date | null; + planId: string; + } | null; +} + export const generateToken = (email: string): string => { return jwt.sign({ email }, JWT_SECRET, { expiresIn: "7d" }); }; -export const verifyToken = async (token: string) => { +export const verifyToken = async (token: string): Promise => { try { const decoded = jwt.verify(token, JWT_SECRET); - if (typeof decoded === "string") { + if (typeof decoded === "string" || !decoded || typeof decoded !== "object") { throw new Error("Invalid token payload"); } - const user = await prisma.prisma.user.findUnique({ - where: { email: decoded.email }, + const email = (decoded as { email?: string }).email; + if (!email) { + throw new Error("Email not found in token"); + } + + const user = await prisma.user.findUnique({ + where: { email }, + include: { + subscriptions: { + where: { + status: SUBSCRIPTION_STATUS.ACTIVE, + endDate: { + gte: new Date(), + }, + }, + orderBy: { + startDate: "desc", + }, + take: 1, + include: { + plan: true, + }, + }, + }, }); if (!user) { throw new Error("User not found"); } - return user; + const activeSubscription = user.subscriptions[0] || null; + + return { + id: user.id, + email: user.email, + firstName: user.firstName, + authMethod: user.authMethod, + createdAt: user.createdAt, + lastLogin: user.lastLogin, + completedSteps: user.completedSteps, + isPaidUser: !!activeSubscription, + subscription: activeSubscription + ? { + id: activeSubscription.id, + status: activeSubscription.status, + startDate: activeSubscription.startDate, + endDate: activeSubscription.endDate, + planId: activeSubscription.planId, + } + : null, + }; } catch (error) { throw new Error("Token verification failed"); } diff --git a/apps/web/src/components/checkout/CheckoutWrapper.tsx b/apps/web/src/components/checkout/CheckoutWrapper.tsx index 2b983016..4d426aec 100644 --- a/apps/web/src/components/checkout/CheckoutWrapper.tsx +++ b/apps/web/src/components/checkout/CheckoutWrapper.tsx @@ -1,13 +1,27 @@ "use client"; -import React from "react"; +import React, { useEffect, useState } from "react"; import { useSubscription } from "@/hooks/useSubscription"; -import { useRouter } from "next/navigation"; +import { useRouter, useSearchParams } from "next/navigation"; +import { useSession } from "next-auth/react"; import CheckoutConfirmation from "./checkout-confirmation"; export default function CheckoutWrapper() { const { isPaidUser, isLoading } = useSubscription(); const router = useRouter(); + const searchParams = useSearchParams(); + const { data: session, update } = useSession(); + const paymentSuccess = searchParams.get("payment") === "success"; + const [hasRefreshed, setHasRefreshed] = useState(false); + + useEffect(() => { + if (paymentSuccess && session && !hasRefreshed) { + update().then(() => { + setHasRefreshed(true); + router.refresh(); + }); + } + }, [paymentSuccess, session, update, hasRefreshed, router]); // Show loading state while checking subscription if (isLoading) { diff --git a/apps/web/src/components/payment/PaymentFlow.tsx b/apps/web/src/components/payment/PaymentFlow.tsx index 4a38aeae..cb807542 100644 --- a/apps/web/src/components/payment/PaymentFlow.tsx +++ b/apps/web/src/components/payment/PaymentFlow.tsx @@ -44,7 +44,7 @@ const PaymentFlow: React.FC = ({ buttonClassName, callbackUrl, }) => { - const { data: session, status: sessionStatus } = useSession(); + const { data: session, status: sessionStatus, update } = useSession(); const router = useRouter(); const [isProcessing, setIsProcessing] = useState(false); const orderDataRef = useRef<{ @@ -52,7 +52,6 @@ const PaymentFlow: React.FC = ({ amount: number; // Stored for display purposes only } | null>(null); - const utils = trpc.useUtils(); const createOrderMutation = (trpc.payment as any).createOrder.useMutation(); const verifyPaymentMutation = ( trpc.payment as any @@ -74,27 +73,10 @@ const PaymentFlow: React.FC = ({ planId: planId, }); - // payment verification succeeded - proceed with redirect - // subscription cache refresh is decoupled as best-effort background action - // errors in refresh won't affect the successful payment verification - (async () => { - try { - await (utils.user as any).subscriptionStatus.invalidate(); - await Promise.race([ - (utils.user as any).subscriptionStatus.fetch(undefined), - new Promise((resolve) => setTimeout(resolve, 3000)), // 3s timeout - ]); - } catch (refreshError) { - console.warn( - "subscription cache refresh failed (non-fatal):", - refreshError - ); - } - })(); - - // redirect immediately after successful verification - // checkout page will refetch subscription status if cache refresh failed - router.push("/checkout"); + await update(); + + router.push("/checkout?payment=success"); + router.refresh(); } catch (error) { console.error("Verification failed:", error); alert("Payment verification failed. Please contact support."); diff --git a/apps/web/src/hooks/useSubscription.ts b/apps/web/src/hooks/useSubscription.ts index 943c3794..beaadce2 100644 --- a/apps/web/src/hooks/useSubscription.ts +++ b/apps/web/src/hooks/useSubscription.ts @@ -27,10 +27,10 @@ export function useSubscription() { isFetched, } = (trpc.user as any).subscriptionStatus.useQuery(undefined, { enabled: !!session?.user && status === "authenticated", - refetchOnWindowFocus: false, + refetchOnWindowFocus: true, refetchOnMount: true, - staleTime: 5 * 60 * 1000, // Consider data fresh for 5 minutes - gcTime: 10 * 60 * 1000, // Keep in cache for 10 minutes + staleTime: 30 * 1000, + gcTime: 5 * 60 * 1000, }); useEffect(() => { diff --git a/apps/web/src/lib/auth/config.ts b/apps/web/src/lib/auth/config.ts index 1cb4a34b..f0705a39 100644 --- a/apps/web/src/lib/auth/config.ts +++ b/apps/web/src/lib/auth/config.ts @@ -2,6 +2,7 @@ import type { NextAuthOptions } from "next-auth"; import GoogleProvider from "next-auth/providers/google"; import GithubProvider from "next-auth/providers/github"; import { serverTrpc } from "../trpc-server"; +import { createAuthenticatedClient } from "../trpc-server"; export const authConfig: NextAuthOptions = { providers: [ @@ -17,6 +18,21 @@ export const authConfig: NextAuthOptions = { }, }), ], + session: { + strategy: "jwt", + maxAge: 7 * 24 * 60 * 60, + }, + cookies: { + sessionToken: { + name: `${process.env.NODE_ENV === "production" ? "__Secure-" : ""}next-auth.session-token`, + options: { + httpOnly: true, + sameSite: "lax", + path: "/", + secure: process.env.NODE_ENV === "production", + }, + }, + }, callbacks: { async signIn({ user, profile, account }) { try { @@ -41,14 +57,22 @@ export const authConfig: NextAuthOptions = { }, async session({ session, token }) { + const isPaidUser = (token.isPaidUser as boolean) || false; + const subscription = (token.subscription as any) || null; + return { ...session, - accessToken: token.jwtToken, + accessToken: token.jwtToken as string, expires: session.expires, + user: { + ...session.user, + isPaidUser, + subscription, + }, }; }, - async jwt({ token, account, user }) { + async jwt({ token, account, user, trigger }) { if (account && user) { try { const data = await serverTrpc.auth.generateJWT.mutate({ @@ -60,6 +84,34 @@ export const authConfig: NextAuthOptions = { console.error("JWT token error:", error); } } + + if (token.jwtToken) { + const shouldRefresh = + trigger === "update" || + trigger === "signIn" || + !token.isPaidUser || + token.isPaidUser === undefined; + + if (shouldRefresh) { + try { + const tempSession = { + accessToken: token.jwtToken as string, + user: { email: user?.email || token.email }, + } as any; + + const trpc = createAuthenticatedClient(tempSession); + const sessionData = await (trpc.auth as any).getSession.query(); + + if (sessionData?.user) { + token.isPaidUser = sessionData.user.isPaidUser || false; + token.subscription = sessionData.user.subscription || null; + } + } catch (error) { + console.error("Session refresh error:", error); + } + } + } + return token; }, }, diff --git a/apps/web/src/lib/auth/protected-routes.ts b/apps/web/src/lib/auth/protected-routes.ts index 45935e37..cb58f646 100644 --- a/apps/web/src/lib/auth/protected-routes.ts +++ b/apps/web/src/lib/auth/protected-routes.ts @@ -1,14 +1,9 @@ -/** - * Configuration for dashboard routes that require authentication. - * - * To add a new protected route, simply add the path to this array. - * To remove protection from a route, remove it from this array. - * - * Routes are matched using prefix matching, so nested routes under - * a protected path will also be protected (e.g., /dashboard/projects/123 - * is protected if /dashboard/projects is in this array). - */ export const PROTECTED_DASHBOARD_ROUTES = [ "/dashboard/projects", "/dashboard/sheet", ] as const; + +export const PROTECTED_PRO_ROUTES = [ + "/dashboard/pro", + "/dashboard/newsletters", +] as const; diff --git a/apps/web/src/middleware.ts b/apps/web/src/middleware.ts index ea126e31..0215c625 100644 --- a/apps/web/src/middleware.ts +++ b/apps/web/src/middleware.ts @@ -1,14 +1,13 @@ import { getToken } from "next-auth/jwt"; import { NextRequest, NextResponse } from "next/server"; -import { PROTECTED_DASHBOARD_ROUTES } from "@/lib/auth/protected-routes"; +import { + PROTECTED_DASHBOARD_ROUTES, + PROTECTED_PRO_ROUTES, +} from "@/lib/auth/protected-routes"; export async function middleware(req: NextRequest) { - const adaptedReq = { - headers: req.headers, - cookies: req.cookies, - }; const token = await getToken({ - req: adaptedReq as any, + req, secret: process.env.NEXTAUTH_SECRET, }); @@ -18,11 +17,30 @@ export async function middleware(req: NextRequest) { pathname.startsWith(path) ); + const isProRoute = PROTECTED_PRO_ROUTES.some((path) => + pathname.startsWith(path) + ); + if (isProtectedRoute && !token) { const signInUrl = new URL("/login", req.url); signInUrl.searchParams.set("callbackUrl", pathname); return NextResponse.redirect(signInUrl); } + if (isProRoute) { + if (!token) { + const signInUrl = new URL("/login", req.url); + signInUrl.searchParams.set("callbackUrl", pathname); + return NextResponse.redirect(signInUrl); + } + + const isPaidUser = (token as any).isPaidUser || false; + + if (!isPaidUser) { + const pricingUrl = new URL("/pricing", req.url); + return NextResponse.redirect(pricingUrl); + } + } + return NextResponse.next(); } diff --git a/apps/web/src/types/next-auth.d.ts b/apps/web/src/types/next-auth.d.ts index 22a45358..26d687d9 100644 --- a/apps/web/src/types/next-auth.d.ts +++ b/apps/web/src/types/next-auth.d.ts @@ -3,11 +3,34 @@ import "next-auth"; declare module "next-auth" { interface Session { accessToken?: string; + user: { + name?: string | null; + email?: string | null; + image?: string | null; + isPaidUser?: boolean; + subscription?: { + id: string; + status: string; + startDate: Date; + endDate: Date | null; + planId: string; + planName?: string; + } | null; + }; } } declare module "next-auth/jwt" { interface JWT { jwtToken?: string; + isPaidUser?: boolean; + subscription?: { + id: string; + status: string; + startDate: Date; + endDate: Date | null; + planId: string; + planName?: string; + } | null; } } \ No newline at end of file