diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index 530296e..9b4587b 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -1,3 +1,6 @@ + export { appRouter, type AppRouter } from "./root"; export { createContext, type Context } from "./context"; -export { createTRPCRouter, publicProcedure, protectedProcedure } from "./trpc"; \ No newline at end of file +export { createTRPCRouter, publicProcedure, protectedProcedure } from "./trpc"; +export { rateLimit, RATE_LIMITS } from "./middleware/security"; +export { cache, CacheKeys } from "./middleware/cache"; \ No newline at end of file diff --git a/packages/api/src/routers/stripe.ts b/packages/api/src/routers/stripe.ts index f17d5f2..7ca51a9 100644 --- a/packages/api/src/routers/stripe.ts +++ b/packages/api/src/routers/stripe.ts @@ -37,32 +37,47 @@ export const stripeRouter = createTRPCRouter({ }); } - const session = await stripe.checkout.sessions.create({ - payment_method_types: ["card"], - line_items: [ - { - price_data: { - currency: "usd", - product_data: { - name: "DSGT Membership", - description: "One year membership to Data Science at Georgia Tech", - // images: ["https://example.com/logo.png"], // Optional: Add a logo if available + try { + const session = await stripe.checkout.sessions.create({ + payment_method_types: ["card"], + line_items: [ + { + price_data: { + currency: "usd", + product_data: { + name: "DSGT Membership", + description: "One year membership to Data Science at Georgia Tech", + // images: ["https://example.com/logo.png"], // Optional: Add a logo if available + }, + unit_amount: 2500, // $15.00 }, - unit_amount: 2500, // $15.00 + quantity: 1, }, - quantity: 1, + ], + mode: "payment", + success_url: `${input.returnUrl}?payment=success&session_id={CHECKOUT_SESSION_ID}`, + cancel_url: `${input.returnUrl}?payment=cancelled`, + customer_email: user.email, + metadata: { + userId: ctx.userId!, }, - ], - mode: "payment", - success_url: `${input.returnUrl}?payment=success&session_id={CHECKOUT_SESSION_ID}`, - cancel_url: `${input.returnUrl}?payment=cancelled`, - customer_email: user.email, - metadata: { - userId: ctx.userId!, - }, - }); + }); - return { url: session.url }; + return { url: session.url }; + } catch (error: any) { + console.error("Stripe Checkout Error:", error); + // Check for invalid API key errors specifically if possible, but obscure all + if (error.message?.includes("Invalid API Key")) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Payment configuration error. Please contact support.", + }); + } + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Failed to create checkout session. Please try again later.", + }); + } }), /** diff --git a/packages/auth/src/config.ts b/packages/auth/src/config.ts index 0a69bc8..adea66b 100644 --- a/packages/auth/src/config.ts +++ b/packages/auth/src/config.ts @@ -121,17 +121,23 @@ export const authConfig: NextAuthConfig = { "https://datasciencegt.org"; const host = new URL(baseUrl).host; - const result = await transport.sendMail({ - to: identifier, - from: provider.from, - subject: `${code} — Your sign-in code for ${host}`, - text: `Your sign-in code is: ${code}\n\nEnter this code on ${host} to sign in. It expires in 10 minutes.\n\nIf you didn't request this, you can safely ignore this email.\n`, - html: html({ code, host }), - }); - - const failed = result.rejected.concat(result.pending).filter(Boolean); - if (failed.length) { - throw new Error(`Email(s) (${failed.join(", ")}) could not be sent`); + try { + const result = await transport.sendMail({ + to: identifier, + from: provider.from, + subject: `${code} — Your sign-in code for ${host}`, + text: `Your sign-in code is: ${code}\n\nEnter this code on ${host} to sign in. It expires in 10 minutes.\n\nIf you didn't request this, you can safely ignore this email.\n`, + html: html({ code, host }), + }); + + const failed = result.rejected.concat(result.pending).filter(Boolean); + if (failed.length) { + console.error(`[sendVerificationRequest] Email(s) could not be sent: ${failed.join(", ")}`); + throw new Error(`Email(s) could not be sent`); + } + } catch (error) { + console.error("[sendVerificationRequest] Failed to send email:", error); + throw new Error("Failed to send verification email. Please try again later."); } }, }), diff --git a/packages/db/src/schemas/stripe.ts b/packages/db/src/schemas/stripe.ts index 263c03b..c14a755 100644 --- a/packages/db/src/schemas/stripe.ts +++ b/packages/db/src/schemas/stripe.ts @@ -1,4 +1,4 @@ -import { pgTable, text, timestamp, uuid, integer, boolean } from "drizzle-orm/pg-core"; +import { pgTable, text, timestamp, uuid, integer, boolean, index } from "drizzle-orm/pg-core"; import { relations } from "drizzle-orm"; import { users } from "./auth"; import { members } from "./members"; @@ -33,7 +33,10 @@ export const stripePayments = pgTable("stripe_payment", { metadata: text("metadata"), // JSON string for any extra Stripe metadata createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at").defaultNow().notNull(), -}); +}, (table) => ({ + customerEmailIdx: index("stripe_payment_customer_email_idx").on(table.customerEmail), + linkedUserIdIdx: index("stripe_payment_linked_user_id_idx").on(table.linkedUserId), +})); /** * Links users who signed in with a different email (e.g., Google) diff --git a/sites/mainweb/app/(portal)/api/auth/verify-email/route.ts b/sites/mainweb/app/(portal)/api/auth/verify-email/route.ts index fbea95a..2e08d78 100644 --- a/sites/mainweb/app/(portal)/api/auth/verify-email/route.ts +++ b/sites/mainweb/app/(portal)/api/auth/verify-email/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; -import { db, users, sessions } from "@query/db"; -import { eq, sql } from "drizzle-orm"; +import { db, users, sessions, accounts, stripePayments, userAccountLinks, members, verificationTokens } from "@query/db"; +import { eq, and, isNull, sql } from "drizzle-orm"; +import { rateLimit, cache } from "@query/api"; /** * Code-based email verification endpoint. @@ -9,11 +10,22 @@ import { eq, sql } from "drizzle-orm"; * verificationToken table, consumes it, creates a session, and * returns the session cookie + redirect URL. */ + export async function POST(request: NextRequest) { try { const body = await request.json(); const { code, email } = body as { code?: string; email?: string }; + + const ip = request.headers.get("x-forwarded-for") || "unknown"; + const limit = rateLimit(ip, 10, 1); // 10 attempts, refills 1/sec + if (!limit.allowed) { + return NextResponse.json( + { success: false, error: "Too many attempts. Please try again later." }, + { status: 429 } + ); + } + if (!code || !email) { return NextResponse.json( { success: false, error: "Missing code or email." }, @@ -32,65 +44,186 @@ export async function POST(request: NextRequest) { const customTokenValue = `custom:${code}`; console.log(`[verify-email] Verifying code for ${email}`); - // Consume the token (DELETE + RETURNING) - const result = await db.execute(sql` - DELETE FROM "verificationToken" - WHERE "identifier" = ${email} AND "token" = ${customTokenValue} - RETURNING * - `); + const result = await db.transaction(async (tx) => { + // Consume the token (DELETE + RETURNING) + const [invite] = await tx + .delete(verificationTokens) + .where(and( + eq(verificationTokens.identifier, email), + eq(verificationTokens.token, customTokenValue) + )) + .returning(); + + if (!invite) { + console.warn(`[verify-email] No matching code for ${email}`); + return null; + } + + // Check expiry + if (new Date(invite.expires) < new Date()) { + console.warn(`[verify-email] Code expired for ${email}`); + return { error: "Code has expired. Please request a new one." }; + } + + console.log(`[verify-email] Code verified for ${email}`); + + // Find or create user + let user = await tx + .select() + .from(users) + .where(eq(users.email, email)) + .then((r) => r[0] ?? null); + + if (!user) { + const newId = crypto.randomUUID(); + const inserted = await tx + .insert(users) + .values({ id: newId, email, emailVerified: new Date() }) + .returning(); + user = inserted[0]!; + console.log(`[verify-email] Created new user ${user.id}`); + } else if (!user.emailVerified) { + await tx + .update(users) + .set({ emailVerified: new Date() }) + .where(eq(users.id, user.id)); + } + + // Create a database session + const sessionToken = crypto.randomUUID(); + const sessionExpires = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); // 30 days + + await tx.insert(sessions).values({ + sessionToken, + userId: user.id, + expires: sessionExpires, + }); + + // --- Auto-link: Create account record for email provider if missing --- + const existingAccount = await tx + .select() + .from(accounts) + .where(and(eq(accounts.userId, user.id), eq(accounts.provider, "nodemailer"))) + .then((res) => res[0]); + + if (!existingAccount) { + await tx.insert(accounts).values({ + userId: user.id, + type: "email", + provider: "nodemailer", + providerAccountId: email, + }); + console.log(`[verify-email] Created account record for ${email}`); + } - if (result.rowCount === 0) { - console.warn(`[verify-email] No matching code for ${email}`); + // --- Auto-link: Link Stripe payment if matching email exists --- + try { + const payment = await tx.query.stripePayments.findFirst({ + where: and( + eq(stripePayments.customerEmail, email), + isNull(stripePayments.linkedUserId), + eq(stripePayments.paymentStatus, "paid") + ), + }); + + if (payment) { + // Check no existing link for this user + const existingLink = await tx.query.userAccountLinks.findFirst({ + where: eq(userAccountLinks.userId, user.id), + }); + + if (!existingLink) { + const names = (user.name || "Member").split(" "); + const firstName = names[0] || "Member"; + const lastName = names.slice(1).join(" ") || "Member"; + + await tx.insert(userAccountLinks).values({ + userId: user.id, + stripePaymentId: payment.id, + providedFirstName: firstName, + providedLastName: lastName, + providedEmail: email, + }); + + await tx + .update(stripePayments) + .set({ + linkedUserId: user.id, + linkedAt: new Date(), + updatedAt: new Date(), + }) + .where(eq(stripePayments.id, payment.id)); + + // Create/Update membership + const now = new Date(); + const oneYearFromNow = new Date(now); + oneYearFromNow.setFullYear(oneYearFromNow.getFullYear() + 1); + + const existingMember = await tx.query.members.findFirst({ + where: eq(members.userId, user.id), + }); + + if (existingMember) { + await tx + .update(members) + .set({ + isActive: true, + membershipStartDate: now, + membershipEndDate: oneYearFromNow, + renewalCount: existingMember.renewalCount + 1, + memberType: "continuous", + updatedAt: now, + }) + .where(eq(members.id, existingMember.id)); + } else { + await tx.insert(members).values({ + userId: user.id, + firstName, + lastName, + memberType: "new", + isActive: true, + membershipStartDate: now, + membershipEndDate: oneYearFromNow, + renewalCount: 0, + }); + } + + console.log(`[verify-email] Auto-linked Stripe payment ${payment.id} for ${email}`); + } + } + } catch (linkError) { + console.error("[verify-email] Auto-link error:", linkError); + // Don't fail the login if auto-link fails, just log it + } + + return { + sessionToken, + sessionExpires, + userId: user.id, + }; + }); + + if (!result) { return NextResponse.json( { success: false, error: "Invalid or expired code. Please try again." }, { status: 401 } ); } - const invite = result.rows[0] as any; - console.log(`[verify-email] Code verified for ${email}`); - - // Check expiry - if (new Date(invite.expires) < new Date()) { - console.warn(`[verify-email] Code expired for ${email}`); + if ('error' in result) { return NextResponse.json( - { success: false, error: "Code has expired. Please request a new one." }, + { success: false, error: result.error }, { status: 401 } ); } - // Find or create user - let user = await db - .select() - .from(users) - .where(eq(users.email, email)) - .then((r) => r[0] ?? null); - - if (!user) { - const newId = crypto.randomUUID(); - const inserted = await db - .insert(users) - .values({ id: newId, email, emailVerified: new Date() }) - .returning(); - user = inserted[0]!; - console.log(`[verify-email] Created new user ${user.id}`); - } else if (!user.emailVerified) { - await db - .update(users) - .set({ emailVerified: new Date() }) - .where(eq(users.id, user.id)); + try { + cache.delete(`member:${result.userId}`); + cache.delete(`member:status:${result.userId}`); + } catch (e) { + console.warn("[verify-email] Failed to invalidate cache", e); } - // Create a database session - const sessionToken = crypto.randomUUID(); - const sessionExpires = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); // 30 days - - await db.insert(sessions).values({ - sessionToken, - userId: user.id, - expires: sessionExpires, - }); - // Build JSON response with Set-Cookie header const baseUrl = process.env.NEXTAUTH_URL || @@ -106,12 +239,12 @@ export async function POST(request: NextRequest) { redirectUrl: "/dashboard", }); - response.cookies.set(cookieName, sessionToken, { + response.cookies.set(cookieName, result.sessionToken, { httpOnly: true, sameSite: "lax", secure: isSecure, path: "/", - expires: sessionExpires, + expires: result.sessionExpires, }); console.log(`[verify-email] Session created for ${email}`); diff --git a/sites/mainweb/app/(portal)/api/webhooks/stripe/route.ts b/sites/mainweb/app/(portal)/api/webhooks/stripe/route.ts index 631e082..7060336 100644 --- a/sites/mainweb/app/(portal)/api/webhooks/stripe/route.ts +++ b/sites/mainweb/app/(portal)/api/webhooks/stripe/route.ts @@ -2,6 +2,10 @@ import { NextRequest, NextResponse } from "next/server"; import Stripe from "stripe"; import { db, stripePayments, users, members } from "@query/db"; import { eq } from "drizzle-orm"; +import { cache } from "@query/api"; + +// Type for the transaction object +type Tx = Parameters["transaction"]>[0]>[0]; const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!; @@ -24,6 +28,11 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: "Server configuration error" }, { status: 500 }); } + if (!db) { + console.error("Database not initialized."); + return NextResponse.json({ error: "Server configuration error" }, { status: 500 }); + } + let event: Stripe.Event; try { @@ -55,56 +64,79 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: "No customer email" }, { status: 400 }); } - const existingPayment = await db?.query.stripePayments.findFirst({ + // Check if payment already exists (idempotency) + const existingPayment = await db.query.stripePayments.findFirst({ where: eq(stripePayments.stripeSessionId, session.id), }); if (existingPayment) { console.log(`Payment already processed: ${session.id}`); + // If payment exists, verify membership was created too + if (existingPayment.linkedUserId) { + try { + // Invalidate cache just in case + cache.delete(`member:${existingPayment.linkedUserId}`); + cache.delete(`member:status:${existingPayment.linkedUserId}`); + } catch (e) { + console.warn("Failed to invalidate cache", e); + } + } return NextResponse.json({ received: true }); } // Check if user already exists - // Priority 1: Check metadata userId (most reliable) + // Priority 1: Check metadata userId let targetUser = null; if (metadataUserId) { - targetUser = await db?.query.users.findFirst({ + targetUser = await db.query.users.findFirst({ where: eq(users.id, metadataUserId), }); } // Priority 2: Fallback to email match if (!targetUser) { - targetUser = await db?.query.users.findFirst({ + targetUser = await db.query.users.findFirst({ where: eq(users.email, customerEmail), }); } - // Save payment record - const [payment] = await db! - .insert(stripePayments) - .values({ - stripeSessionId: session.id, - stripeCustomerId: session.customer as string | null, - stripePaymentIntentId: session.payment_intent as string | null, - customerEmail, // Normalized - customerName, - amountTotal: session.amount_total, - currency: session.currency || "usd", - paymentStatus: session.payment_status as "paid" | "unpaid" | "no_payment_required", - linkedUserId: targetUser?.id || null, - linkedAt: targetUser ? new Date() : null, - metadata: session.metadata ? JSON.stringify(session.metadata) : null, - }) - .returning(); - - // If user exists and paid, create/update membership - if (targetUser && session.payment_status === "paid") { - await createOrUpdateMembership(targetUser.id, customerName, customerEmail, phoneNumber); - } + // Execute in transaction + await db.transaction(async (tx) => { + // Save payment record + const [payment] = await tx + .insert(stripePayments) + .values({ + stripeSessionId: session.id, + stripeCustomerId: session.customer as string | null, + stripePaymentIntentId: session.payment_intent as string | null, + customerEmail, // Normalized + customerName, + amountTotal: session.amount_total, + currency: session.currency || "usd", + paymentStatus: session.payment_status as "paid" | "unpaid" | "no_payment_required", + linkedUserId: targetUser?.id || null, + linkedAt: targetUser ? new Date() : null, + metadata: session.metadata ? JSON.stringify(session.metadata) : null, + }) + .returning(); + + // If user exists and paid, create/update membership + if (targetUser && session.payment_status === "paid") { + await createOrUpdateMembership(tx, targetUser.id, customerName, customerEmail, phoneNumber); + + // Invalidate cache + try { + cache.delete(`member:${targetUser.id}`); + cache.delete(`member:status:${targetUser.id}`); + } catch (e) { + console.warn("Failed to invalidate cache inside webhook", e); + } + } + + console.log(`Stripe payment recorded: ${payment?.id} for ${customerEmail} (Linked to: ${targetUser?.id || 'Unlinked'})`); + }); - console.log(`Stripe payment recorded: ${payment?.id} for ${customerEmail} (Linked to: ${targetUser?.id || 'Unlinked'})`); } catch (error) { console.error("Error processing checkout session:", error); return NextResponse.json({ error: "Processing failed" }, { status: 500 }); @@ -114,14 +146,16 @@ export async function POST(req: NextRequest) { return NextResponse.json({ received: true }); } + async function createOrUpdateMembership( + tx: Tx, userId: string, customerName: string | null | undefined, customerEmail: string, phoneNumber: string | null | undefined ) { // Check if member already exists - const existingMember = await db?.query.members.findFirst({ + const existingMember = await tx.query.members.findFirst({ where: eq(members.userId, userId), }); @@ -136,7 +170,7 @@ async function createOrUpdateMembership( if (existingMember) { // Renew membership - await db! + await tx .update(members) .set({ isActive: true, @@ -152,7 +186,7 @@ async function createOrUpdateMembership( console.log(`Membership renewed for user ${userId}`); } else { // Create new membership - await db!.insert(members).values({ + await tx.insert(members).values({ userId, firstName, lastName, diff --git a/sites/mainweb/app/(portal)/verify/page.tsx b/sites/mainweb/app/(portal)/verify/page.tsx index 64fafe1..e790321 100644 --- a/sites/mainweb/app/(portal)/verify/page.tsx +++ b/sites/mainweb/app/(portal)/verify/page.tsx @@ -81,6 +81,7 @@ function VerifyContent() { const res = await fetch('/api/auth/verify-email', { method: 'POST', headers: { 'Content-Type': 'application/json' }, + credentials: 'include', body: JSON.stringify({ code: fullCode, email }), }); @@ -114,10 +115,10 @@ function VerifyContent() {

- Enter_Code + Enter Code

- Secure_Authentication // Code_Verification + Code Verfication

@@ -186,9 +187,7 @@ function VerifyContent() { )}

-

- Query_Security_Protocols_Active -

+

); @@ -198,7 +197,7 @@ export default function VerifyPage() { return ( - Loading_Verification... + Loading
}>