Proyecto E-commerce - Parte 2
Implementamos los módulos de productos, categorías y reviews.
Schema Prisma
prisma
// prisma/schema.prismamodel Category { id String @id @default(uuid()) name String @unique slug String @unique products Product[] createdAt DateTime @default(now())} model Product { id String @id @default(uuid()) name String slug String @unique description String? price Decimal @db.Decimal(10, 2) stock Int @default(0) images String[] featured Boolean @default(false) category Category @relation(fields: [categoryId], references: [id]) categoryId String reviews Review[] orderItems OrderItem[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@index([categoryId]) @@index([slug])} model Review { id String @id @default(uuid()) rating Int comment String? user User @relation(fields: [userId], references: [id]) userId String product Product @relation(fields: [productId], references: [id]) productId String createdAt DateTime @default(now()) @@unique([userId, productId])}Product Service
typescript
// src/services/product.service.tsimport { Prisma } from '@prisma/client';import { prisma } from '../config/database';import { AppError } from '../errors/AppError';import { cacheService } from './cache.service'; interface FindAllParams { page?: number; limit?: number; search?: string; categoryId?: string; minPrice?: number; maxPrice?: number; featured?: boolean; sortBy?: string; order?: 'asc' | 'desc';} export class ProductService { async findAll(params: FindAllParams) { const { page = 1, limit = 20, search, categoryId, minPrice, maxPrice, featured, sortBy = 'createdAt', order = 'desc', } = params; const cacheKey = `products:${JSON.stringify(params)}`; const cached = await cacheService.get(cacheKey); if (cached) return cached; const where: Prisma.ProductWhereInput = {}; if (search) { where.OR = [ { name: { contains: search, mode: 'insensitive' } }, { description: { contains: search, mode: 'insensitive' } }, ]; } if (categoryId) where.categoryId = categoryId; if (featured !== undefined) where.featured = featured; if (minPrice || maxPrice) { where.price = {}; if (minPrice) where.price.gte = minPrice; if (maxPrice) where.price.lte = maxPrice; } const skip = (page - 1) * limit; const [products, total] = await Promise.all([ prisma.product.findMany({ where, skip, take: limit, orderBy: { [sortBy]: order }, include: { category: true, _count: { select: { reviews: true } }, }, }), prisma.product.count({ where }), ]); const result = { data: products, pagination: { page, limit, total, totalPages: Math.ceil(total / limit) }, }; await cacheService.set(cacheKey, result, 1800); return result; } async findBySlug(slug: string) { const product = await prisma.product.findUnique({ where: { slug }, include: { category: true, reviews: { include: { user: { select: { id: true, name: true } } }, orderBy: { createdAt: 'desc' }, take: 10, }, }, }); if (!product) throw new AppError('Producto no encontrado', 404); return product; } async create(data: CreateProductDTO) { const slug = this.generateSlug(data.name); const exists = await prisma.product.findUnique({ where: { slug } }); if (exists) throw new AppError('Producto ya existe', 409); const product = await prisma.product.create({ data: { ...data, slug }, include: { category: true }, }); await cacheService.delPattern('products:*'); return product; } async update(id: string, data: UpdateProductDTO) { await this.findById(id); const product = await prisma.product.update({ where: { id }, data, include: { category: true }, }); await cacheService.delPattern('products:*'); return product; } async delete(id: string) { await this.findById(id); await prisma.product.delete({ where: { id } }); await cacheService.delPattern('products:*'); } private generateSlug(name: string) { return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, ''); } private async findById(id: string) { const product = await prisma.product.findUnique({ where: { id } }); if (!product) throw new AppError('Producto no encontrado', 404); return product; }}Category Service
typescript
// src/services/category.service.tsimport { prisma } from '../config/database';import { AppError } from '../errors/AppError'; export class CategoryService { async findAll() { return prisma.category.findMany({ include: { _count: { select: { products: true } } }, orderBy: { name: 'asc' }, }); } async findBySlug(slug: string) { const category = await prisma.category.findUnique({ where: { slug }, include: { products: { take: 10 } }, }); if (!category) throw new AppError('Categoría no encontrada', 404); return category; } async create(data: { name: string }) { const slug = data.name.toLowerCase().replace(/\s+/g, '-'); return prisma.category.create({ data: { ...data, slug } }); } async delete(id: string) { const hasProducts = await prisma.product.count({ where: { categoryId: id } }); if (hasProducts > 0) { throw new AppError('Categoría tiene productos', 400); } return prisma.category.delete({ where: { id } }); }}Review Service
typescript
// src/services/review.service.tsimport { prisma } from '../config/database';import { AppError } from '../errors/AppError'; export class ReviewService { async create(userId: string, productId: string, data: { rating: number; comment?: string }) { const exists = await prisma.review.findUnique({ where: { userId_productId: { userId, productId } }, }); if (exists) throw new AppError('Ya has dejado una reseña', 409); return prisma.review.create({ data: { ...data, userId, productId }, include: { user: { select: { id: true, name: true } } }, }); } async delete(id: string, userId: string, isAdmin: boolean) { const review = await prisma.review.findUnique({ where: { id } }); if (!review) throw new AppError('Reseña no encontrada', 404); if (!isAdmin && review.userId !== userId) { throw new AppError('No autorizado', 403); } return prisma.review.delete({ where: { id } }); }}Rutas
typescript
// src/routes/product.routes.tsconst router = Router(); router.get('/', asyncHandler(controller.index));router.get('/:slug', asyncHandler(controller.show));router.post('/', authenticate, authorize('ADMIN'), validate(createSchema), asyncHandler(controller.store));router.put('/:id', authenticate, authorize('ADMIN'), validate(updateSchema), asyncHandler(controller.update));router.delete('/:id', authenticate, authorize('ADMIN'), asyncHandler(controller.destroy));router.post('/:id/reviews', authenticate, validate(reviewSchema), asyncHandler(controller.addReview)); export { router as productRoutes };Resumen Parte 2
- ✅ CRUD de productos con cache
- ✅ CRUD de categorías
- ✅ Sistema de reviews
- ✅ Filtros y paginación
Próxima parte: Módulo de Pedidos y Finalización! 🚀