diff --git a/api/index.ts b/api/index.ts index 0c2f6fb..4e9ec55 100644 --- a/api/index.ts +++ b/api/index.ts @@ -43,7 +43,7 @@ app.get('/api/health', async (req: Request, res: Response) => { } }); -// Zod schemas +// Zod schemas for Auth const registerSchema = z.object({ email: z.string().email(), password: z.string().min(6), @@ -73,7 +73,8 @@ app.post('/api/auth/register', async (req: Request, res: Response) => { }, }); - res.status(201).json({ id: user.id, email: user.email, name: user.name }); + 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 }); @@ -120,4 +121,230 @@ app.get('/api/auth/me', authenticate as express.RequestHandler, async (req: Auth } }); +// 5. Products: List & Filter +app.get('/api/products', async (req: Request, res: Response) => { + try { + 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, + }); + + 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 }); + } +}); + +// 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 productId = parseInt(req.params.id); + if (isNaN(productId)) { + return res.status(400).json({ error: 'Invalid product ID' }); + } + + 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 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: { + 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, + } + }); + + res.status(201).json(review); + } catch (err) { + if (err instanceof z.ZodError) { + return res.status(400).json({ errors: err.errors }); + } + res.status(500).json({ error: (err as Error).message }); + } +}); + +// 8. Orders: Place Order +app.post('/api/orders', async (req: Request, res: Response) => { + try { + const body = orderSchema.parse(req.body); + + // 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 + } + } + + // 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, + }); + } + + // 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 + } + }); + + 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 }); + } + res.status(500).json({ error: (err as Error).message }); + } +}); + +// 9. Orders: Get History (Protected) +app.get('/api/orders', authenticate as express.RequestHandler, async (req: AuthRequest, res: Response) => { + try { + const orders = await prisma.order.findMany({ + where: { userId: req.userId }, + include: { + items: { + include: { + product: true + } + } + }, + orderBy: { createdAt: 'desc' } + }); + + res.json(orders); + } catch (err) { + res.status(500).json({ error: (err as Error).message }); + } +}); + export default app; diff --git a/package.json b/package.json index eede5e7..377baa3 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(); + });