diff --git a/apps/api/.env.example b/apps/api/.env.example index 12e24a40..8cfed34b 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -18,6 +18,7 @@ GITHUB_PERSONAL_ACCESS_TOKEN=token-from-your-github-account RAZORPAY_KEY_ID=razorpay-key-id RAZORPAY_KEY_SECRET=razorpay-key-secret RAZORPAY_WEBHOOK_SECRET=razorpay-webhook-secret +RAZORPAY_SPONSOR_PLAN_ID=plan_xxxxxxxxxxxxx # to send slack invite. # it'll be similar to : https://join.slack.com/t/name-of-ur-org/shared_invite/invite-id diff --git a/apps/api/package.json b/apps/api/package.json index 628886a5..52cd4202 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -18,6 +18,7 @@ "@types/cors": "^2.8.19", "@types/express": "^4.17.21", "@types/jsonwebtoken": "^9.0.7", + "@types/multer": "^2.0.0", "@types/node": "^24.5.1", "prisma": "^5.22.0", "tsx": "^4.20.3", @@ -28,15 +29,17 @@ "@opensox/shared": "workspace:*", "@prisma/client": "^5.22.0", "@trpc/server": "^11.7.2", + "cloudinary": "^2.0.0", "cors": "^2.8.5", "dotenv": "^16.5.0", "express": "^4.21.2", "express-rate-limit": "^7.5.0", "helmet": "^7.2.0", "jsonwebtoken": "^9.0.2", + "multer": "^2.0.2", "razorpay": "^2.9.6", "superjson": "^2.2.5", "zeptomail": "^6.2.1", "zod": "^4.1.9" } -} +} \ No newline at end of file diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 2bf9695f..0eadaccd 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -42,6 +42,7 @@ model User { accounts Account[] payments Payment[] subscriptions Subscription[] + sponsors Sponsor[] } model Account { @@ -64,7 +65,7 @@ model Account { model Payment { id String @id @default(cuid()) - userId String + userId String? subscriptionId String? razorpayPaymentId String @unique razorpayOrderId String @@ -74,7 +75,7 @@ model Payment { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt subscription Subscription? @relation(fields: [subscriptionId], references: [id]) - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + user User? @relation(fields: [userId], references: [id], onDelete: Cascade) } model Subscription { @@ -102,3 +103,20 @@ model Plan { updatedAt DateTime @updatedAt subscriptions Subscription[] } + +model Sponsor { + id String @id @default(cuid()) + userId String? + company_name String + description String + website String + image_url String + razorpay_payment_id String? + razorpay_sub_id String? + plan_status String // active, cancelled, pending_payment, pending_submission, failed + contact_name String? // customer name from razorpay + contact_email String? // customer email from razorpay + contact_phone String? // customer phone from razorpay + created_at DateTime @default(now()) + user User? @relation(fields: [userId], references: [id]) +} diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index f1925322..ccc97bce 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -14,9 +14,34 @@ import crypto from "crypto"; import { paymentService } from "./services/payment.service.js"; import { verifyToken } from "./utils/auth.js"; import { SUBSCRIPTION_STATUS } from "./constants/subscription.js"; +import multer from "multer"; +import { v2 as cloudinary } from "cloudinary"; +import { handleRazorpayWebhook } from "./webhooks.js"; dotenv.config(); +// validate required environment variables early +const requiredEnv = [ + "CLOUDINARY_CLOUD_NAME", + "CLOUDINARY_API_KEY", + "CLOUDINARY_API_SECRET", + "RAZORPAY_WEBHOOK_SECRET", +] as const; + +for (const key of requiredEnv) { + if (!process.env[key] || process.env[key]!.trim().length === 0) { + console.error(`missing required env var: ${key}`); + process.exit(1); + } +} + +// configure cloudinary (kept local to this route) +cloudinary.config({ + cloud_name: process.env.CLOUDINARY_CLOUD_NAME!, + api_key: process.env.CLOUDINARY_API_KEY!, + api_secret: process.env.CLOUDINARY_API_SECRET!, +}); + const app = express(); const PORT = process.env.PORT || 4000; const CORS_ORIGINS = process.env.CORS_ORIGINS @@ -62,8 +87,9 @@ const apiLimiter = rateLimit({ // Request size limits (except for webhook - needs raw body) app.use("/webhook/razorpay", express.raw({ type: "application/json" })); -app.use(express.json({ limit: "10kb" })); -app.use(express.urlencoded({ limit: "10kb", extended: true })); +// Reduce global JSON/urlencoded limits to prevent DoS +app.use(express.json({ limit: "5mb" })); +app.use(express.urlencoded({ limit: "5mb", extended: true })); // CORS configuration const corsOptions: CorsOptionsType = { @@ -98,6 +124,63 @@ app.get("/test", apiLimiter, (req: Request, res: Response) => { res.status(200).json({ status: "ok", message: "Test endpoint is working" }); }); +// Secure multipart upload setup with strict validation +const upload = multer({ + storage: multer.memoryStorage(), // avoid temp files; stream to Cloudinary + limits: { + fileSize: 10 * 1024 * 1024, // 10MB per-file limit for this endpoint + files: 1, + }, + fileFilter: (_req, file, cb) => { + const allowed = ["image/png", "image/jpeg", "image/jpg", "image/webp"]; + if (!allowed.includes(file.mimetype)) { + return cb(new Error("Invalid file type")); + } + cb(null, true); + }, +}); + +// Dedicated upload endpoint that only accepts multipart/form-data +app.post( + "/upload/sponsor-image", + apiLimiter, + (req, res, next) => { + if (!req.is("multipart/form-data")) { + return res.status(415).json({ error: "Unsupported Media Type. Use multipart/form-data." }); + } + next(); + }, + upload.single("file"), + async (req: Request, res: Response) => { + try { + if (!req.file) { + return res.status(400).json({ error: "No file provided" }); + } + + const file = req.file; // narrow for TypeScript across closures + + // Stream upload to Cloudinary + const folder = "opensox/sponsors"; + const result = await new Promise((resolve, reject) => { + const stream = cloudinary.uploader.upload_stream({ folder }, (error, uploadResult) => { + if (error) return reject(error); + resolve(uploadResult); + }); + stream.end(file.buffer); + }); + + return res.status(200).json({ + url: result.secure_url, + bytes: file.size, + mimetype: file.mimetype, + }); + } catch (err: any) { + const isLimit = err?.message?.toLowerCase()?.includes("file too large"); + return res.status(isLimit ? 413 : 400).json({ error: err.message || "Upload failed" }); + } + } +); + // Slack Community Invite Endpoint (Protected) app.get("/join-community", apiLimiter, async (req: Request, res: Response) => { try { @@ -153,104 +236,8 @@ app.get("/join-community", apiLimiter, async (req: Request, res: Response) => { } }); -// Razorpay Webhook Handler (Backup Flow) -app.post("/webhook/razorpay", async (req: Request, res: Response) => { - try { - const webhookSecret = process.env.RAZORPAY_WEBHOOK_SECRET; - if (!webhookSecret) { - console.error("RAZORPAY_WEBHOOK_SECRET not configured"); - return res.status(500).json({ error: "Webhook not configured" }); - } - - // Get signature from headers - const signature = req.headers["x-razorpay-signature"] as string; - if (!signature) { - return res.status(400).json({ error: "Missing signature" }); - } - - // Verify webhook signature - const body = req.body.toString(); - const expectedSignature = crypto - .createHmac("sha256", webhookSecret) - .update(body) - .digest("hex"); - - const isValidSignature = crypto.timingSafeEqual( - Buffer.from(signature), - Buffer.from(expectedSignature) - ); - - if (!isValidSignature) { - console.error("Invalid webhook signature"); - return res.status(400).json({ error: "Invalid signature" }); - } - - // Parse the event - const event = JSON.parse(body); - const eventType = event.event; - - // Handle payment.captured event - if (eventType === "payment.captured") { - const payment = event.payload.payment.entity; - - // Extract payment details - const razorpayPaymentId = payment.id; - const razorpayOrderId = payment.order_id; - const amount = payment.amount; - const currency = payment.currency; - - // Get user ID from order notes (should be stored when creating order) - const notes = payment.notes || {}; - const userId = notes.user_id; - - if (!userId) { - console.error("User ID not found in payment notes"); - return res.status(400).json({ error: "User ID not found" }); - } - - // Get plan ID from notes - const planId = notes.plan_id; - if (!planId) { - console.error("Plan ID not found in payment notes"); - return res.status(400).json({ error: "Plan ID not found" }); - } - - try { - // Create payment record (with idempotency check) - const paymentRecord = await paymentService.createPaymentRecord(userId, { - razorpayPaymentId, - razorpayOrderId, - amount, - currency, - }); - - // Create subscription (with idempotency check) - await paymentService.createSubscription( - userId, - planId, - paymentRecord.id - ); - - console.log( - `✅ Webhook: Payment ${razorpayPaymentId} processed successfully` - ); - return res.status(200).json({ status: "ok" }); - } catch (error: any) { - console.error("Webhook payment processing error:", error); - // Return 200 to prevent Razorpay retries for application errors - return res - .status(200) - .json({ status: "ok", note: "Already processed" }); - } - } - - // Acknowledge other events - return res.status(200).json({ status: "ok" }); - } catch (error: any) { - console.error("Webhook error:", error); - return res.status(500).json({ error: "Internal server error" }); - } -}); +// Razorpay Webhook Handler +app.post("/webhook/razorpay", handleRazorpayWebhook); // Connect to database prismaModule.connectDB(); diff --git a/apps/api/src/routers/_app.ts b/apps/api/src/routers/_app.ts index 782b4361..b1a8f01f 100644 --- a/apps/api/src/routers/_app.ts +++ b/apps/api/src/routers/_app.ts @@ -14,6 +14,8 @@ const testRouter = router({ }), }); +import { sponsorRouter } from "./sponsor.js"; + export const appRouter = router({ hello: testRouter, query: queryRouter, @@ -21,6 +23,7 @@ export const appRouter = router({ project: projectRouter, auth: authRouter, payment: paymentRouter, + sponsor: sponsorRouter, }); export type AppRouter = typeof appRouter; diff --git a/apps/api/src/routers/sponsor.ts b/apps/api/src/routers/sponsor.ts new file mode 100644 index 00000000..f00401b3 --- /dev/null +++ b/apps/api/src/routers/sponsor.ts @@ -0,0 +1,453 @@ +import { router, publicProcedure } from "../trpc.js"; +import { z } from "zod"; +import prismaModule from "../prisma.js"; +import { paymentService } from "../services/payment.service.js"; +import { TRPCError } from "@trpc/server"; +import { rz_instance } from "../clients/razorpay.js"; +import crypto from "crypto"; + +const { prisma } = prismaModule; + +import { v2 as cloudinary } from "cloudinary"; + +cloudinary.config({ + cloud_name: process.env.CLOUDINARY_CLOUD_NAME || "", + api_key: process.env.CLOUDINARY_API_KEY || "", + api_secret: process.env.CLOUDINARY_API_SECRET || "", +}); + +// fixed monthly sponsorship amount +const SPONSOR_MONTHLY_AMOUNT = 50000; // $500 USD +const SPONSOR_CURRENCY = "USD"; +const SPONSOR_PLAN_ID = process.env.RAZORPAY_SPONSOR_PLAN_ID || ""; + +const verifySubscriptionSignature = ( + subscriptionId: string, + paymentId: string, + signature: string +): boolean => { + try { + const keySecret = process.env.RAZORPAY_KEY_SECRET; + if (!keySecret) { + throw new Error("RAZORPAY_KEY_SECRET not configured"); + } + + const generatedSignatureHex = crypto + .createHmac("sha256", keySecret) + .update(`${paymentId}|${subscriptionId}`) + .digest("hex"); + + const a = Buffer.from(signature, "hex"); + const b = Buffer.from(generatedSignatureHex, "hex"); + + if (a.length !== b.length) return false; + + return crypto.timingSafeEqual(a, b); + } catch (error) { + console.error("subscription signature verification error:", error); + return false; + } +}; + +export const sponsorRouter = router({ + // upload image to cloudinary (public, no auth required) + uploadImage: publicProcedure + .input( + z.object({ + file: z + .string() + .refine( + (val) => { + // Accept data URLs and raw base64 strings; size under 5MB + const isDataUrl = /^data:.*;base64,/.test(val); + const base64Payload = isDataUrl ? val.split(",")[1] ?? "" : val; + // Base64 size approximation: bytes = (length * 3) / 4 + const base64SizeBytes = Math.floor((base64Payload.length * 3) / 4); + const under5MB = base64SizeBytes > 0 && base64SizeBytes < 5 * 1024 * 1024; + // If data URL, ensure it's an image MIME type + const mimeOk = !isDataUrl || /^data:image\/(png|jpe?g|webp);base64,/.test(val); + return under5MB && mimeOk; + }, + { message: "file must be an image data URL or base64 under 5MB (png/jpg/jpeg/webp)" } + ), + }) + ) + .mutation(async ({ input }: { input: { file: string } }) => { + try { + const result = await cloudinary.uploader.upload(input.file, { + folder: "opensox/sponsors", + resource_type: "image", + allowed_formats: ["jpg", "jpeg", "png", "webp"], + }); + return { url: result.secure_url }; + } catch (error) { + console.error("cloudinary upload error:", error); + throw new Error("image upload failed"); + } + }), + + // create razorpay subscription for sponsorship (public, no auth required) + createSubscription: publicProcedure + .input( + z.object({ + planId: z.string(), + }) + ) + .mutation(async ({ input }) => { + try { + // Validate that the sponsor plan is configured + if (!SPONSOR_PLAN_ID) { + console.error("❌ SPONSOR_PLAN_ID not configured in environment"); + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "sponsor plan not configured on server", + }); + } + + // Validate that the planId matches the configured sponsor plan + if (input.planId !== SPONSOR_PLAN_ID) { + console.error( + "🚨 SECURITY: Invalid planId attempt detected", + { + received: input.planId, + expected: SPONSOR_PLAN_ID, + timestamp: new Date().toISOString(), + } + ); + throw new TRPCError({ + code: "FORBIDDEN", + message: "invalid plan selected; only the configured sponsor plan is allowed", + }); + } + + const subscription = await rz_instance.subscriptions.create({ + plan_id: input.planId, + total_count: 12, + customer_notify: 1, + notes: { + type: "sponsor", + }, + }); + + const razorpayKeyId = process.env.RAZORPAY_KEY_ID; + if (!razorpayKeyId) { + console.error("❌ RAZORPAY_KEY_ID not configured in environment"); + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "payment gateway not properly configured", + }); + } + + return { + subscriptionId: subscription.id, + planId: subscription.plan_id, + status: subscription.status, + key: razorpayKeyId, + }; + } catch (error) { + console.error("failed to create sponsor subscription:", error); + if (error instanceof TRPCError) throw error; + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "failed to create sponsor subscription", + }); + } + }), + + // verify payment and create sponsor record (public, no auth required) + verifyPayment: publicProcedure + .input( + z.object({ + razorpay_payment_id: z.string(), + razorpay_order_id: z.string().optional(), + razorpay_subscription_id: z.string().optional(), + razorpay_signature: z.string(), + }) + ) + .mutation(async ({ input }) => { + try { + let subscriptionId: string | null = input.razorpay_subscription_id ?? null; + + if (input.razorpay_order_id) { + const isValidSignature = paymentService.verifyPaymentSignature( + input.razorpay_order_id, + input.razorpay_payment_id, + input.razorpay_signature + ); + + if (!isValidSignature) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "invalid payment signature", + }); + } + + const order = await rz_instance.orders.fetch(input.razorpay_order_id); + // Note: For subscriptions, the first payment may have an order with subscription's amount + // We'll be more lenient here and just verify signature + } else if (subscriptionId) { + const isValidSignature = verifySubscriptionSignature( + subscriptionId, + input.razorpay_payment_id, + input.razorpay_signature + ); + + if (!isValidSignature) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "invalid subscription payment signature", + }); + } + + if (!SPONSOR_PLAN_ID) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "sponsor subscription plan not configured", + }); + } + + const subscription = await rz_instance.subscriptions.fetch(subscriptionId); + if (!subscription || subscription.plan_id !== SPONSOR_PLAN_ID) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "invalid subscription details", + }); + } + } else { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "missing order or subscription information", + }); + } + + // upsert payment to avoid race conditions / duplicates + await prisma.payment.upsert({ + where: { razorpayPaymentId: input.razorpay_payment_id }, + update: { + razorpayOrderId: input.razorpay_order_id ?? "", + amount: SPONSOR_MONTHLY_AMOUNT, + currency: SPONSOR_CURRENCY, + status: "captured", + }, + create: { + razorpayPaymentId: input.razorpay_payment_id, + razorpayOrderId: input.razorpay_order_id ?? "", + amount: SPONSOR_MONTHLY_AMOUNT, + currency: SPONSOR_CURRENCY, + status: "captured", + }, + }); + + // Fetch payment details with proper typing + type RazorpayPaymentDetails = { + contact?: string | number; + email?: string; + notes?: { name?: string } | null; + }; + + const paymentDetails = (await rz_instance.payments.fetch( + input.razorpay_payment_id + )) as RazorpayPaymentDetails; + + let contactName: string | null = null; + let contactEmail: string | null = null; + let contactPhone: string | null = null; + + if (paymentDetails.contact != null) { + contactPhone = String(paymentDetails.contact); + } + if (paymentDetails.email) { + contactEmail = String(paymentDetails.email); + } + if (paymentDetails.notes?.name) { + contactName = String(paymentDetails.notes.name); + } + + // create or update sponsor record with pending_submission status + const existingSponsor = await prisma.sponsor.findFirst({ + where: { razorpay_payment_id: input.razorpay_payment_id }, + }); + + if (!existingSponsor) { + await prisma.sponsor.create({ + data: { + razorpay_payment_id: input.razorpay_payment_id, + razorpay_sub_id: subscriptionId, + plan_status: "pending_submission", + company_name: "", + description: "", + website: "", + image_url: "", + contact_name: contactName, + contact_email: contactEmail, + contact_phone: contactPhone, + }, + }); + } else { + // update existing sponsor with contact details if not already set + await prisma.sponsor.update({ + where: { id: existingSponsor.id }, + data: { + contact_name: contactName || existingSponsor.contact_name, + contact_email: contactEmail || existingSponsor.contact_email, + contact_phone: contactPhone || existingSponsor.contact_phone, + razorpay_sub_id: subscriptionId || existingSponsor.razorpay_sub_id, + }, + }); + } + + return { + success: true, + paymentId: input.razorpay_payment_id, + }; + } catch (error) { + console.error("error in verifyPayment:", error); + if (error instanceof TRPCError) throw error; + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "failed to verify payment", + cause: error, + }); + } + }), + + // submit sponsor assets (public, no auth required) + submitAssets: publicProcedure + .input( + z.object({ + companyName: z.string(), + description: z.string(), + website: z.string().url(), + imageUrl: z.string().url(), + razorpayPaymentId: z.string(), + }) + ) + .mutation(async ({ input }) => { + try { + console.log("đŸ“Ļ Submitting sponsor assets for payment:", input.razorpayPaymentId); + + // verify payment exists and is successful + const payment = await prisma.payment.findUnique({ + where: { razorpayPaymentId: input.razorpayPaymentId }, + }); + + if (!payment || payment.status !== "captured") { + throw new TRPCError({ + code: "NOT_FOUND", + message: "valid payment not found or not captured", + }); + } + + console.log("✅ Payment verified:", { id: payment.id, amount: payment.amount, currency: payment.currency }); + + // Enforce flow: Sponsor must exist with pending_submission from verifyPayment + const existingSponsor = await prisma.sponsor.findFirst({ + where: { + razorpay_payment_id: input.razorpayPaymentId, + plan_status: "pending_submission", + }, + }); + + if (!existingSponsor) { + console.log("❌ No pending sponsor found for payment:", input.razorpayPaymentId); + throw new TRPCError({ + code: "BAD_REQUEST", + message: + "sponsorship not in pending submission state; complete verification first", + }); + } + + console.log("✅ Found pending sponsor:", existingSponsor.id); + + // Update sponsor to active with submitted assets + const updatedSponsor = await prisma.sponsor.update({ + where: { id: existingSponsor.id }, + data: { + company_name: input.companyName, + description: input.description, + website: input.website, + image_url: input.imageUrl, + plan_status: "active", + }, + }); + + console.log("🎉 Sponsorship activated successfully:", updatedSponsor.id); + return updatedSponsor; + } catch (error) { + console.error("error in submitAssets:", error); + if (error instanceof TRPCError) throw error; + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "something went wrong during sponsorship submission", + cause: error, + }); + } + }), + + // check payment status (public) + checkPaymentStatus: publicProcedure + .input(z.object({ paymentId: z.string() })) + .query(async ({ input }) => { + console.log("🔍 Checking payment status for:", input.paymentId); + + const payment = await prisma.payment.findUnique({ + where: { razorpayPaymentId: input.paymentId } + }); + + console.log("đŸ’ŗ Payment record:", payment ? { + id: payment.id, + status: payment.status, + amount: payment.amount, + createdAt: payment.createdAt + } : "NOT FOUND"); + + if (!payment) { + console.log("❌ Payment not found in database"); + return { valid: false, reason: "payment_not_found" }; + } + + if (payment.status !== 'captured') { + console.log("❌ Payment status is not captured:", payment.status); + return { valid: false, reason: "payment_not_captured", status: payment.status }; + } + + const sponsor = await prisma.sponsor.findFirst({ + where: { razorpay_payment_id: input.paymentId } + }); + + console.log("đŸŽ¯ Sponsor record:", sponsor ? { + id: sponsor.id, + plan_status: sponsor.plan_status, + company_name: sponsor.company_name + } : "NOT FOUND"); + + if (!sponsor) { + console.log("❌ Sponsor record not found"); + return { valid: false, reason: "sponsor_not_found" }; + } + + console.log("✅ Payment validation successful"); + return { valid: true, status: sponsor.plan_status }; + }), + + // get active sponsors (public) + getActiveSponsors: publicProcedure.query(async () => { + return await prisma.sponsor.findMany({ + where: { + plan_status: "active", + }, + orderBy: { + created_at: "desc", + }, + select: { + id: true, + company_name: true, + description: true, + image_url: true, + website: true, + plan_status: true, + created_at: true, + }, + }); + }), +}); diff --git a/apps/api/src/webhooks.ts b/apps/api/src/webhooks.ts new file mode 100644 index 00000000..4d953938 --- /dev/null +++ b/apps/api/src/webhooks.ts @@ -0,0 +1,261 @@ +import type { Request, Response } from "express"; +import crypto from "crypto"; +import prismaModule from "./prisma.js"; +import { rz_instance } from "./clients/razorpay.js"; + +const { prisma } = prismaModule; + + +const SPONSOR_MONTHLY_AMOUNT = 50000; // $500 USD +const SPONSOR_CURRENCY = "USD"; + +export const handleRazorpayWebhook = async (req: Request, res: Response) => { + try { + const webhookSecret = process.env.RAZORPAY_WEBHOOK_SECRET; + if (!webhookSecret) { + console.error("RAZORPAY_WEBHOOK_SECRET not configured"); + return res.status(500).json({ error: "Webhook not configured" }); + } + + // Get signature from headers + const signature = req.headers["x-razorpay-signature"] as string; + if (!signature) { + return res.status(400).json({ error: "Missing signature" }); + } + + // Verify webhook signature + // req.body is already a buffer because of express.raw() middleware in index.ts + const body = req.body.toString(); + const expectedSignature = crypto + .createHmac("sha256", webhookSecret) + .update(body) + .digest("hex"); + + const isValidSignature = crypto.timingSafeEqual( + Buffer.from(signature), + Buffer.from(expectedSignature) + ); + + if (!isValidSignature) { + console.error("Invalid webhook signature"); + return res.status(400).json({ error: "Invalid signature" }); + } + + // Parse the event + const event = JSON.parse(body); + const eventType = event.event; + const payload = event.payload; + + console.log(`Received Razorpay webhook: ${eventType}`); + + switch (eventType) { + case "payment.captured": + await handlePaymentCaptured(payload); + break; + case "subscription.charged": + await handleSubscriptionCharged(payload); + break; + case "subscription.pending": + case "subscription.halted": + case "subscription.cancelled": + case "payment.failed": + await handleSubscriptionStatusChange(eventType, payload); + break; + // Add other events as needed + } + + return res.status(200).json({ status: "ok" }); + } catch (error: any) { + console.error("Webhook error:", error); + return res.status(500).json({ error: "Internal server error" }); + } +}; + +// handle payment.captured event for sponsors +async function handlePaymentCaptured(payload: any) { + const payment = payload.payment.entity; + const paymentId = payment.id; + const orderId = payment.order_id; + + console.log("đŸ“Ĩ Webhook: payment.captured", { paymentId, orderId }); + + // extract customer contact details from payment entity (if available in webhook) + let contactName: string | null = null; + let contactEmail: string | null = null; + let contactPhone: string | null = null; + + if (payment.contact) { + contactPhone = String(payment.contact); + } + if (payment.email) { + contactEmail = payment.email; + } + if (payment.notes && payment.notes.name) { + contactName = payment.notes.name; + } + + // if contact details not in webhook payload, fetch from razorpay api + if (!contactPhone || !contactEmail) { + try { + const paymentDetails = await rz_instance.payments.fetch(paymentId); + if (paymentDetails.contact && !contactPhone) { + contactPhone = String(paymentDetails.contact); + } + if (paymentDetails.email && !contactEmail) { + contactEmail = paymentDetails.email; + } + if (paymentDetails.notes && paymentDetails.notes.name && !contactName) { + contactName = paymentDetails.notes.name; + } + } catch (error) { + console.error("failed to fetch payment details from razorpay:", error); + // continue without contact details if fetch fails + } + } + + // Use consistent constants for sponsor payments + await prisma.payment.upsert({ + where: { razorpayPaymentId: paymentId }, + update: { + razorpayOrderId: orderId, + amount: SPONSOR_MONTHLY_AMOUNT, + currency: SPONSOR_CURRENCY, + status: "captured", + }, + create: { + razorpayPaymentId: paymentId, + razorpayOrderId: orderId, + amount: SPONSOR_MONTHLY_AMOUNT, + currency: SPONSOR_CURRENCY, + status: "captured", + }, + }); + + // create or update sponsor record with pending_submission status + const existingSponsor = await prisma.sponsor.findFirst({ + where: { razorpay_payment_id: paymentId }, + }); + + if (!existingSponsor) { + await prisma.sponsor.create({ + data: { + razorpay_payment_id: paymentId, + plan_status: "pending_submission", + company_name: "", + description: "", + website: "", + image_url: "", + contact_name: contactName, + contact_email: contactEmail, + contact_phone: contactPhone, + }, + }); + console.log("✅ Created sponsor record with pending_submission status"); + } else { + // Don't overwrite if already submitted + if (existingSponsor.plan_status !== "active") { + await prisma.sponsor.update({ + where: { id: existingSponsor.id }, + data: { + contact_name: contactName || existingSponsor.contact_name, + contact_email: contactEmail || existingSponsor.contact_email, + contact_phone: contactPhone || existingSponsor.contact_phone, + }, + }); + console.log("✅ Updated sponsor contact details"); + } else { + console.log("â„šī¸ Sponsor already active, skipping update"); + } + } +} + +async function handleSubscriptionCharged(payload: any) { + const payment = payload.payment.entity; + const subscription = payload.subscription.entity; + + // Update sponsor status to active if it matches a known subscription + + + const subId = subscription.id; + + await prisma.sponsor.updateMany({ + where: { + razorpay_sub_id: subId, + }, + data: { + plan_status: "active", + }, + }); +} + +async function handleSubscriptionStatusChange(eventType: string, payload: any) { + const subscription = payload.subscription ? payload.subscription.entity : null; + const payment = payload.payment ? payload.payment.entity : null; + + let subId = subscription ? subscription.id : null; + + // If we don't have subscription entity directly (e.g. payment.failed), try to get from payment + if (!subId && payment) { + // Attempt extraction from description: look for Razorpay-style IDs like "sub_XXXXXXXX" + const tryExtractSubId = (source: unknown): string | null => { + if (!source || typeof source !== "string") return null; + // Prefer explicit "sub_" token + const explicitMatch = source.match(/\bsub_[a-zA-Z0-9]+\b/); + if (explicitMatch && explicitMatch[0].trim().length > 0) return explicitMatch[0]; + + return null; + }; + + // 1) Description + const fromDescription = tryExtractSubId(payment.description); + + // 2) Notes.subscription_id (common place to store linkage) + let fromNotes: string | null = null; + if (payment.notes && typeof payment.notes === "object") { + const maybeNoteSubId = (payment.notes as any).subscription_id; + if (typeof maybeNoteSubId === "string" && maybeNoteSubId.trim().length > 0) { + // Ensure it looks like a subscription id + const validated = tryExtractSubId(maybeNoteSubId) ?? maybeNoteSubId; + if (typeof validated === "string" && validated.trim().length > 0) { + fromNotes = validated; + } + } else { + // If notes might contain a free-form string, attempt extraction + // Some integrations stuff sub_ token in a generic "notes" field + for (const k of Object.keys(payment.notes)) { + const val = (payment.notes as any)[k]; + const extracted = tryExtractSubId(typeof val === "string" ? val : ""); + if (extracted) { + fromNotes = extracted; + break; + } + } + } + } else if (typeof payment.notes === "string") { + fromNotes = tryExtractSubId(payment.notes); + } + + const candidate = fromDescription || fromNotes; + if (typeof candidate === "string" && candidate.trim().length > 0) { + subId = candidate.trim(); + } + } + + if (!subId) return; + + let newStatus = "active"; + if (eventType === "subscription.pending") newStatus = "pending_payment"; + if (eventType === "subscription.halted") newStatus = "unpaid"; // or halted + if (eventType === "subscription.cancelled") newStatus = "cancelled"; + if (eventType === "payment.failed") newStatus = "failed"; + + await prisma.sponsor.updateMany({ + where: { + razorpay_sub_id: subId, + }, + data: { + plan_status: newStatus, + }, + }); +} + diff --git a/apps/web/next.config.js b/apps/web/next.config.js index dc6d8137..a27df2ab 100644 --- a/apps/web/next.config.js +++ b/apps/web/next.config.js @@ -14,6 +14,10 @@ const nextConfig = { protocol: "https", hostname: "img.youtube.com", }, + { + protocol: "https", + hostname: "res.cloudinary.com", + }, ], }, experimental: { @@ -21,4 +25,4 @@ const nextConfig = { }, }; -module.exports = nextConfig; \ No newline at end of file +module.exports = nextConfig; \ No newline at end of file diff --git a/apps/web/package.json b/apps/web/package.json index aaa18cbc..1c4662e9 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -4,13 +4,16 @@ "private": true, "scripts": { "dev": "next dev", - "build": "bash ./scripts/init-submodules.sh && next build", + "build": "next build", + "build:with-premium": "bash ./scripts/init-submodules.sh && next build", "start": "next start", "lint": "next lint" }, "dependencies": { "@heroicons/react": "^2.1.5", + "@hookform/resolvers": "^5.2.2", "@opensox/shared": "workspace:*", + "api": "workspace:*", "@radix-ui/react-accordion": "^1.2.1", "@radix-ui/react-checkbox": "^1.1.2", "@radix-ui/react-label": "^2.1.0", @@ -19,7 +22,7 @@ "@tanstack/react-query": "^5.90.2", "@trpc/client": "^11.6.0", "@trpc/react-query": "^11.6.0", - "@trpc/server": "^11.5.1", + "@trpc/server": "^11.7.2", "@vercel/analytics": "^1.4.1", "@vercel/speed-insights": "^1.1.0", "class-variance-authority": "^0.7.0", @@ -36,12 +39,14 @@ "posthog-js": "^1.203.1", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-hook-form": "^7.68.0", "react-qr-code": "^2.0.18", "react-tweet": "^3.2.1", "sanitize-html": "^2.11.0", "superjson": "^2.2.5", "tailwind-merge": "^2.5.4", "tailwindcss-animate": "^1.0.7", + "zod": "^4.1.9", "zustand": "^5.0.1" }, "devDependencies": { @@ -59,4 +64,4 @@ "tailwindcss": "^3.4.1", "typescript": "^5" } -} +} \ No newline at end of file diff --git a/apps/web/src/app/(main)/(landing)/page.tsx b/apps/web/src/app/(main)/(landing)/page.tsx index 94caacb4..6dc3e4e9 100644 --- a/apps/web/src/app/(main)/(landing)/page.tsx +++ b/apps/web/src/app/(main)/(landing)/page.tsx @@ -1,38 +1,37 @@ -'use client' -import Bento from '@/components/landing-sections/Bento' -import Brands from '@/components/landing-sections/Brands' -import CTA from '@/components/landing-sections/CTA' -import Footer from '@/components/landing-sections/footer' -import Hero from '@/components/landing-sections/Hero' -import HowItWorks from '@/components/landing-sections/how-it-works' -import Navbar from '@/components/landing-sections/navbar' -import Testimonials from '@/components/landing-sections/testimonials' -import Video from '@/components/landing-sections/video' -import React from 'react' -import { FaqSection } from '@/components/faq/FaqSection' - +"use client"; +import Bento from "@/components/landing-sections/Bento"; +import Brands from "@/components/landing-sections/Brands"; +import CTA from "@/components/landing-sections/CTA"; +import Footer from "@/components/landing-sections/footer"; +import Hero from "@/components/landing-sections/Hero"; +import SponsorSection from "@/components/landing-sections/SponsorSection"; +import HowItWorks from "@/components/landing-sections/how-it-works"; +import Navbar from "@/components/landing-sections/navbar"; +import Testimonials from "@/components/landing-sections/testimonials"; +import Video from "@/components/landing-sections/video"; +import React from "react"; +import { FaqSection } from "@/components/faq/FaqSection"; const Landing = () => { - return ( -
- -
- - -
-
- -
-
-
- ) -} - -export default Landing - + return ( +
+ +
+ + + +
+
+ +
+
+
+ ); +}; +export default Landing; diff --git a/apps/web/src/app/(main)/sponsor/layout.tsx b/apps/web/src/app/(main)/sponsor/layout.tsx new file mode 100644 index 00000000..70bbfff6 --- /dev/null +++ b/apps/web/src/app/(main)/sponsor/layout.tsx @@ -0,0 +1,13 @@ +import Navbar from "@/components/landing-sections/navbar"; +import React from "react"; + +const Layout = ({ children }: { children: React.ReactNode }) => { + return ( +
+ + {children} +
+ ); +}; + +export default Layout; diff --git a/apps/web/src/app/(main)/sponsor/page.tsx b/apps/web/src/app/(main)/sponsor/page.tsx new file mode 100644 index 00000000..42a580a0 --- /dev/null +++ b/apps/web/src/app/(main)/sponsor/page.tsx @@ -0,0 +1,386 @@ +"use client"; + +import React, { useState } from "react"; +import { ArrowRight, Loader2 } from "lucide-react"; +import Footer from "@/components/landing-sections/footer"; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@/components/ui/accordion"; +import PrimaryButton from "@/components/ui/custom-button"; +import Image from "next/image"; +import { trpc } from "@/lib/trpc"; +import { useRouter } from "next/navigation"; +import { useRazorpay } from "@/hooks/useRazorpay"; +import type { inferRouterOutputs } from "@trpc/server"; +import type { AppRouter } from "@api/routers/_app"; + +type RouterOutputs = inferRouterOutputs; + +// Extract planId from environment variable with runtime validation +const SPONSOR_PLAN_ID = process.env.NEXT_PUBLIC_SPONSOR_PLAN_ID || ""; + +const SponsorPage = () => { + const [loading, setLoading] = useState(false); + const router = useRouter(); + + const verifyPaymentMutation = trpc.sponsor.verifyPayment.useMutation({ + onSuccess: (data) => { + // redirect to submission page with payment id after verification + router.push(`/sponsor/submit?paymentId=${data.paymentId}`); + }, + onError: (error) => { + console.error("payment verification failed:", error); + alert("payment verification failed: " + error.message); + setLoading(false); + }, + }); + + const { initiatePayment, isLoading: isPaymentLoading } = useRazorpay({ + onSuccess: (response) => { + // verify payment on backend + verifyPaymentMutation.mutate({ + razorpay_payment_id: response.razorpay_payment_id, + razorpay_subscription_id: response.razorpay_subscription_id, + razorpay_signature: response.razorpay_signature, + }); + }, + onFailure: (error) => { + console.error("payment failed:", error); + alert("payment failed: " + error.message); + setLoading(false); + }, + onDismiss: () => { + setLoading(false); + }, + }); + + const createSubscriptionMutation = + trpc.sponsor.createSubscription.useMutation({ + onSuccess: async ( + data: RouterOutputs["sponsor"]["createSubscription"] + ) => { + const options = { + key: data.key, + name: "OpenSox", + description: "Monthly Sponsorship - $500", + subscription_id: data.subscriptionId, + theme: { + color: "#4dd0a4", + }, + options: { + checkout: { + method: { + netbanking: 1, // enable netbanking + card: 1, // enable card payments + upi: 1, // enable upi payments + wallet: 1, // enable wallet payments + }, + }, + }, + }; + + await initiatePayment(options); + }, + onError: (error) => { + console.error("subscription creation failed:", error); + alert("failed to initiate payment. please try again."); + setLoading(false); + }, + }); + + const handleBecomeSponsor = () => { + // Validate planId is configured before proceeding + if (!SPONSOR_PLAN_ID) { + console.error( + "❌ CONFIGURATION ERROR: NEXT_PUBLIC_SPONSOR_PLAN_ID is not set in environment variables" + ); + alert("Sponsor plan is not configured. Please contact support."); + return; + } + + setLoading(true); + // create payment order for $500 monthly sponsorship using env-backed planId + createSubscriptionMutation.mutate({ planId: SPONSOR_PLAN_ID }); + }; + + return ( + <> +
+
+ background +
+
+
+ {/* Hero Section */} +
+

+ Sponsor OpenSox +

+

+ Reach thousands of developers building the next generation of web + applications. Get your brand in front of our growing community of + creators and innovators. +

+
+ + {/* Stats Section */} +
+
+
+ 10K+ +
+
+ Monthly unique visitors +
+
+
+
+ 50K+ +
+
+ Monthly page views +
+
+
+ + {/* Sponsor Slots Section */} +
+

+ Sponsors will be listed below the hero section on homepage. +

+
+ {[1, 2, 3].map((i) => ( +
+
+ + Your logo here + +
+ ))} +
+
+ + {/* Why Sponsor Section */} +
+

+ Why sponsor OpenSox? +

+
+ {[ + { + title: "Reach Top Talent", + description: + "Connect with skilled developers, open source contributors, and tech enthusiasts who are actively building and learning.", + }, + { + title: "Brand Visibility", + description: + "Increase your brand's visibility within the tech community. Your logo will be prominently displayed to thousands of visitors.", + }, + { + title: "Support Open Source", + description: + "Show your commitment to the open source ecosystem by supporting the tools and resources that developers rely on.", + }, + ].map((card, index) => ( +
+ {/* Grid Pattern */} +
+ + {/* Random highlighted squares decoration */} +
+
+
+
+ +
+

+ {card.title} +

+

+ {card.description} +

+
+
+ ))} +
+
+ + {/* FAQ Section */} +
+

+ Frequently Asked Questions +

+ + + + How long does the sponsorship last? + + + Sponsorships are billed monthly. You can cancel your + subscription at any time, and your logo will remain on the + site until the end of your billing period. + + + + + What logo formats do you accept? + + + We accept SVG, PNG, and JPG formats. SVG is preferred for the + best quality on all screen sizes. If providing a PNG or JPG, + please ensure it is high resolution (at least 500px wide). + + + + + When will my sponsorship go live? + + + Your sponsorship will be live immediately after payment + automatically. + + + + + Can I update my link or logo? + + + Yes, absolutely. If you rebrand or want to change the + destination URL, just reach out to our support team, and we + will update it for you. + + + +
+ {/* CTA Section */} +
+
+ {/* Dotted Lines Effect */} +
+ {/* Horizontal Lines */} +
+
+ {/* Vertical Lines */} +
+
+
+ +
+ {/* Background Gradient */} +
+ +
+
+

+ Ready to reach{" "} + + thousands of developers? + +

+

+ Get your brand in + front of our growing{" "} + community. +

+
+ +
+ + {loading || + createSubscriptionMutation.isPending || + isPaymentLoading ? ( + + ) : null} + Become a Sponsor — $500/mo + {!loading && !createSubscriptionMutation.isPending && ( + + )} + +
+ +

+ Your sponsorship will be live immediately after payment + automatically. +

+
+
+
+
+
+
+