From 7e59ee4370cb48b5a9f69a87f79af8c38b0679d4 Mon Sep 17 00:00:00 2001 From: arya-dev2005 Date: Thu, 25 Jun 2026 12:53:35 +0530 Subject: [PATCH] feat: initialize Express API with Prisma schema, authentication, product catalog, and order management endpoints --- api/index.ts | 421 +++++++++++++++++++++++++++++-------------- package.json | 3 + prisma/schema.prisma | 60 ++++++ prisma/seed.ts | 46 +++++ 4 files changed, 398 insertions(+), 132 deletions(-) create mode 100644 prisma/seed.ts diff --git a/api/index.ts b/api/index.ts index 07f010f..4e9ec55 100644 --- a/api/index.ts +++ b/api/index.ts @@ -1,192 +1,349 @@ -import express, { type NextFunction, type Request, type Response } from "express"; -import cors from "cors"; -import bcrypt from "bcryptjs"; -import jwt from "jsonwebtoken"; -import { z } from "zod"; -import { prisma } from "./db.js"; +import express, { Request, Response, NextFunction } from 'express'; +import cors from 'cors'; +import bcrypt from 'bcryptjs'; +import jwt from 'jsonwebtoken'; +import { z } from 'zod'; +import { prisma } from './db.js'; const app = express(); -app.use( - cors({ - origin: true, - credentials: true, - }), -); +app.use(cors()); app.use(express.json()); +// JWT Auth Middleware helper +export interface AuthRequest extends Request { + userId?: string; +} + +export function authenticate(req: AuthRequest, res: Response, next: NextFunction) { + const authHeader = req.headers.authorization; + const token = authHeader && authHeader.split(' ')[1]; + + if (!token) { + return res.status(401).json({ error: 'Access token required' }); + } + + try { + const decoded = jwt.verify(token, process.env.JWT_SECRET || 'fallback_secret') as { userId: string }; + req.userId = decoded.userId; + next(); + } catch (err) { + return res.status(403).json({ error: 'Invalid or expired token' }); + } +} + +// 1. Health check route +app.get('/api/health', async (req: Request, res: Response) => { + try { + // Check DB connection + await prisma.$queryRaw`SELECT 1`; + res.json({ status: 'healthy', database: 'connected', timestamp: new Date() }); + } catch (err) { + res.status(500).json({ status: 'unhealthy', database: 'disconnected', error: (err as Error).message }); + } +}); + +// Zod schemas for Auth const registerSchema = z.object({ email: z.string().email(), - password: z.string().min(8), - name: z.string().min(1).max(120).optional(), + password: z.string().min(6), + name: z.string().optional(), }); const loginSchema = z.object({ email: z.string().email(), - password: z.string().min(1), + password: z.string(), }); -type JwtPayload = { - userId: string; -}; +// 2. Auth: Register +app.post('/api/auth/register', async (req: Request, res: Response) => { + try { + const body = registerSchema.parse(req.body); + const existing = await prisma.user.findUnique({ where: { email: body.email } }); + if (existing) { + return res.status(400).json({ error: 'Email already in use' }); + } -type AuthRequest = Request & { - userId?: string; -}; + const hashedPassword = await bcrypt.hash(body.password, 10); + const user = await prisma.user.create({ + data: { + email: body.email, + password: hashedPassword, + name: body.name, + }, + }); -function getJwtSecret() { - const secret = process.env.JWT_SECRET; - if (!secret) { - throw new Error("JWT_SECRET is not configured"); + const token = jwt.sign({ userId: user.id }, process.env.JWT_SECRET || 'fallback_secret', { expiresIn: '24h' }); + res.status(201).json({ token, user: { id: user.id, email: user.email, name: user.name } }); + } catch (err) { + if (err instanceof z.ZodError) { + return res.status(400).json({ errors: err.errors }); + } + res.status(500).json({ error: (err as Error).message }); } - return secret; -} - -function readCookie(req: Request, name: string) { - const cookies = req.headers.cookie?.split(";") ?? []; - const match = cookies - .map((cookie) => cookie.trim().split("=")) - .find(([key]) => key === name); +}); - return match ? decodeURIComponent(match.slice(1).join("=")) : undefined; -} +// 3. Auth: Login +app.post('/api/auth/login', async (req: Request, res: Response) => { + try { + const body = loginSchema.parse(req.body); + const user = await prisma.user.findUnique({ where: { email: body.email } }); + if (!user) { + return res.status(401).json({ error: 'Invalid email or password' }); + } -function setAuthCookie(res: Response, token: string) { - res.cookie("aureon_token", token, { - httpOnly: true, - secure: process.env.NODE_ENV === "production", - sameSite: "lax", - maxAge: 24 * 60 * 60 * 1000, - path: "/", - }); -} + const valid = await bcrypt.compare(body.password, user.password); + if (!valid) { + return res.status(401).json({ error: 'Invalid email or password' }); + } -function authenticate(req: AuthRequest, res: Response, next: NextFunction) { - const headerToken = req.headers.authorization?.startsWith("Bearer ") - ? req.headers.authorization.slice("Bearer ".length) - : undefined; - const token = headerToken ?? readCookie(req, "aureon_token"); + const token = jwt.sign({ userId: user.id }, process.env.JWT_SECRET || 'fallback_secret', { expiresIn: '24h' }); - if (!token) { - return res.status(401).json({ error: "Access token required" }); + res.json({ token, user: { id: user.id, email: user.email, name: user.name } }); + } catch (err) { + if (err instanceof z.ZodError) { + return res.status(400).json({ errors: err.errors }); + } + res.status(500).json({ error: (err as Error).message }); } +}); +// 4. Auth: Profile (Protected) +app.get('/api/auth/me', authenticate as express.RequestHandler, async (req: AuthRequest, res: Response) => { try { - const decoded = jwt.verify(token, getJwtSecret()) as JwtPayload; - req.userId = decoded.userId; - return next(); - } catch { - return res.status(403).json({ error: "Invalid or expired token" }); + const user = await prisma.user.findUnique({ where: { id: req.userId } }); + if (!user) { + return res.status(404).json({ error: 'User not found' }); + } + res.json({ id: user.id, email: user.email, name: user.name }); + } catch (err) { + res.status(500).json({ error: (err as Error).message }); } -} - -function publicUser(user: { id: string; email: string; name: string | null }) { - return { - id: user.id, - email: user.email, - name: user.name, - }; -} +}); -app.get("/api/health", async (_req: Request, res: Response) => { +// 5. Products: List & Filter +app.get('/api/products', async (req: Request, res: Response) => { try { - await prisma.$queryRaw`SELECT 1`; - return res.json({ - status: "healthy", - database: "connected", - timestamp: new Date().toISOString(), + const { cat, sort, q } = req.query; + + const whereClause: any = {}; + if (cat) { + whereClause.category = String(cat); + } + if (q) { + whereClause.OR = [ + { name: { contains: String(q), mode: 'insensitive' } }, + { brand: { contains: String(q), mode: 'insensitive' } }, + { description: { contains: String(q), mode: 'insensitive' } } + ]; + } + + let orderBy: any = {}; + if (sort === 'price-low') { + orderBy = { price: 'asc' }; + } else if (sort === 'price-high') { + orderBy = { price: 'desc' }; + } else if (sort === 'new') { + orderBy = { id: 'desc' }; + } else if (sort === 'rating') { + orderBy = { rating: 'desc' }; + } else { + orderBy = { id: 'asc' }; + } + + const products = await prisma.product.findMany({ + where: whereClause, + orderBy: orderBy, }); - } catch (error) { - return res.status(503).json({ - status: "degraded", - database: "disconnected", - error: error instanceof Error ? error.message : "Unknown database error", - timestamp: new Date().toISOString(), + + res.json(products); + } catch (err) { + res.status(500).json({ error: (err as Error).message }); + } +}); + +// 6. Products: Get by ID +app.get('/api/products/:id', async (req: Request, res: Response) => { + try { + const id = parseInt(req.params.id); + if (isNaN(id)) { + return res.status(400).json({ error: 'Invalid product ID' }); + } + + const product = await prisma.product.findUnique({ + where: { id }, + include: { + reviews: { + orderBy: { createdAt: 'desc' } + } + } }); + + if (!product) { + return res.status(404).json({ error: 'Product not found' }); + } + + res.json(product); + } catch (err) { + res.status(500).json({ error: (err as Error).message }); } }); -app.post("/api/auth/register", async (req: Request, res: Response) => { +// Zod schemas for order & review +const reviewSchema = z.object({ + rating: z.number().min(1).max(5), + comment: z.string().min(3), +}); + +const orderItemSchema = z.object({ + productId: z.number(), + quantity: z.number().min(1), +}); + +const orderSchema = z.object({ + items: z.array(orderItemSchema), + paymentMethod: z.string(), +}); + +// 7. Products: Submit Review (Protected) +app.post('/api/products/:id/reviews', authenticate as express.RequestHandler, async (req: AuthRequest, res: Response) => { try { - const body = registerSchema.parse(req.body); - const email = body.email.toLowerCase(); - const existing = await prisma.user.findUnique({ where: { email } }); + const productId = parseInt(req.params.id); + if (isNaN(productId)) { + return res.status(400).json({ error: 'Invalid product ID' }); + } - if (existing) { - return res.status(409).json({ error: "Email already in use" }); + const body = reviewSchema.parse(req.body); + + const user = await prisma.user.findUnique({ where: { id: req.userId } }); + if (!user) { + return res.status(404).json({ error: 'User not found' }); } - const passwordHash = await bcrypt.hash(body.password, 12); - const user = await prisma.user.create({ + const product = await prisma.product.findUnique({ where: { id: productId } }); + if (!product) { + return res.status(404).json({ error: 'Product not found' }); + } + + // Create review + const review = await prisma.review.create({ data: { - email, - password: passwordHash, - name: body.name, - }, - select: { - id: true, - email: true, - name: true, - }, + productId, + userId: user.id, + userName: user.name || 'Anonymous Connoisseur', + rating: body.rating, + comment: body.comment, + } + }); + + // Recalculate average rating + const allReviews = await prisma.review.findMany({ where: { productId } }); + const avgRating = allReviews.reduce((sum, r) => sum + r.rating, 0) / allReviews.length; + + await prisma.product.update({ + where: { id: productId }, + data: { + rating: parseFloat(avgRating.toFixed(1)), + reviewsCount: allReviews.length, + } }); - const token = jwt.sign({ userId: user.id }, getJwtSecret(), { expiresIn: "24h" }); - setAuthCookie(res, token); - return res.status(201).json({ token, user: publicUser(user) }); - } catch (error) { - if (error instanceof z.ZodError) { - return res.status(400).json({ error: "Invalid registration details", issues: error.errors }); + res.status(201).json(review); + } catch (err) { + if (err instanceof z.ZodError) { + return res.status(400).json({ errors: err.errors }); } - return res.status(500).json({ error: error instanceof Error ? error.message : "Registration failed" }); + res.status(500).json({ error: (err as Error).message }); } }); -app.post("/api/auth/login", async (req: Request, res: Response) => { +// 8. Orders: Place Order +app.post('/api/orders', async (req: Request, res: Response) => { try { - const body = loginSchema.parse(req.body); - const user = await prisma.user.findUnique({ - where: { email: body.email.toLowerCase() }, - }); + const body = orderSchema.parse(req.body); - if (!user) { - return res.status(401).json({ error: "Invalid email or password" }); + // Optional auth token retrieval + let userId: string | undefined; + const authHeader = req.headers.authorization; + const token = authHeader && authHeader.split(' ')[1]; + if (token) { + try { + const decoded = jwt.verify(token, process.env.JWT_SECRET || 'fallback_secret') as { userId: string }; + userId = decoded.userId; + } catch (e) { + // Continue as guest + } } - const validPassword = await bcrypt.compare(body.password, user.password); - if (!validPassword) { - return res.status(401).json({ error: "Invalid email or password" }); + // Fetch and check all products + let totalAmount = 0; + const lineItems: { productId: number; quantity: number; price: number }[] = []; + + for (const item of body.items) { + const product = await prisma.product.findUnique({ where: { id: item.productId } }); + if (!product) { + return res.status(404).json({ error: `Product with ID ${item.productId} not found` }); + } + totalAmount += product.price * item.quantity; + lineItems.push({ + productId: product.id, + quantity: item.quantity, + price: product.price, + }); } - const token = jwt.sign({ userId: user.id }, getJwtSecret(), { expiresIn: "24h" }); - setAuthCookie(res, token); + // Create order inside transaction + const order = await prisma.$transaction(async (tx) => { + const ord = await tx.order.create({ + data: { + userId: userId || null, + totalAmount, + paymentMethod: body.paymentMethod, + status: 'PAID', // Monolith mock completes payment immediately + } + }); - return res.json({ token, user: publicUser(user) }); - } catch (error) { - if (error instanceof z.ZodError) { - return res.status(400).json({ error: "Invalid login details", issues: error.errors }); + for (const line of lineItems) { + await tx.orderItem.create({ + data: { + orderId: ord.id, + productId: line.productId, + quantity: line.quantity, + price: line.price, + } + }); + } + + return ord; + }); + + res.status(201).json(order); + } catch (err) { + if (err instanceof z.ZodError) { + return res.status(400).json({ errors: err.errors }); } - return res.status(500).json({ error: error instanceof Error ? error.message : "Login failed" }); + res.status(500).json({ error: (err as Error).message }); } }); -app.get("/api/auth/me", authenticate, async (req: AuthRequest, res: Response) => { +// 9. Orders: Get History (Protected) +app.get('/api/orders', authenticate as express.RequestHandler, async (req: AuthRequest, res: Response) => { try { - const user = await prisma.user.findUnique({ - where: { id: req.userId }, - select: { - id: true, - email: true, - name: true, + const orders = await prisma.order.findMany({ + where: { userId: req.userId }, + include: { + items: { + include: { + product: true + } + } }, + orderBy: { createdAt: 'desc' } }); - if (!user) { - return res.status(404).json({ error: "User not found" }); - } - - return res.json({ user: publicUser(user) }); - } catch (error) { - return res.status(500).json({ error: error instanceof Error ? error.message : "Profile lookup failed" }); + res.json(orders); + } catch (err) { + res.status(500).json({ error: (err as Error).message }); } }); diff --git a/package.json b/package.json index 34a406e..377dfbb 100644 --- a/package.json +++ b/package.json @@ -106,5 +106,8 @@ "overrides": { "vite": "8.1.0" } + }, + "prisma": { + "seed": "node --loader ts-node/esm prisma/seed.ts" } } diff --git a/prisma/schema.prisma b/prisma/schema.prisma index d0b8a72..f15d57a 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -13,6 +13,66 @@ model User { email String @unique password String name String? + role String @default("USER") createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + reviews Review[] + orders Order[] +} + +model Product { + id Int @id @default(autoincrement()) + brand String + name String + price Float + oldPrice Float + discount Int + rating Float @default(0) + reviewsCount Int @default(0) + badge String? + stock String + premium Boolean @default(false) + emoji String + category String + description String + features String[] + specs Json + colors String[] + tags String[] + reviews Review[] + orderItems OrderItem[] +} + +model Review { + id String @id @default(uuid()) + productId Int + product Product @relation(fields: [productId], references: [id], onDelete: Cascade) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + userName String + rating Int + comment String + createdAt DateTime @default(now()) +} + +model Order { + id String @id @default(uuid()) + userId String? + user User? @relation(fields: [userId], references: [id], onDelete: SetNull) + totalAmount Float + status String @default("PENDING") + paymentMethod String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + items OrderItem[] +} + +model OrderItem { + id String @id @default(uuid()) + orderId String + order Order @relation(fields: [orderId], references: [id], onDelete: Cascade) + productId Int + product Product @relation(fields: [productId], references: [id], onDelete: Restrict) + quantity Int + price Float } diff --git a/prisma/seed.ts b/prisma/seed.ts new file mode 100644 index 0000000..3cc2d17 --- /dev/null +++ b/prisma/seed.ts @@ -0,0 +1,46 @@ +import { PrismaClient } from '@prisma/client'; +import { ALL_PRODUCTS } from '../src/app/data/products.ts'; + +const prisma = new PrismaClient(); + +async function main() { + console.log('Clearing existing products...'); + await prisma.product.deleteMany({}); + + console.log('Seeding products from frontend dataset...'); + for (const product of ALL_PRODUCTS) { + await prisma.product.create({ + data: { + id: product.id, + brand: product.brand, + name: product.name, + price: product.price, + oldPrice: product.oldPrice, + discount: product.discount, + rating: product.rating, + reviewsCount: product.reviews, + badge: product.badge, + stock: product.stock, + premium: product.premium, + emoji: product.emoji, + category: product.category, + description: product.description, + features: product.features, + specs: product.specs, + colors: product.colors, + tags: product.tags, + }, + }); + } + + console.log('Seeding completed successfully.'); +} + +main() + .catch((e) => { + console.error('Error seeding database:', e); + process.exit(1); + }) + .finally(async () => { + await prisma.$disconnect(); + });