diff --git a/apps/web/app/api/products/[id]/route.ts b/apps/web/app/api/products/[id]/route.ts new file mode 100644 index 0000000..44e6e38 --- /dev/null +++ b/apps/web/app/api/products/[id]/route.ts @@ -0,0 +1,286 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth'; +import { db } from '@/lib/db'; +import { z } from 'zod'; + +interface RouteContext { + params: Promise<{ id: string }>; +} + +// Validation schema for updating a product +const updateProductSchema = z.object({ + categoryId: z.string().cuid('Invalid category ID').optional(), + name: z.string().min(1, 'Product name is required').max(100).optional(), + description: z.string().max(1000).optional().nullable(), + sku: z.string().max(50).optional().nullable(), + price: z.number().nonnegative('Price must be non-negative').optional(), + costPrice: z.number().nonnegative('Cost price must be non-negative').optional().nullable(), + stock: z.number().int().min(0, 'Stock must be non-negative').optional(), + minStock: z.number().int().min(0, 'Minimum stock must be non-negative').optional(), + trackStock: z.boolean().optional(), + image: z.string().url().optional().nullable(), + isActive: z.boolean().optional(), +}); + +// GET /api/products/[id] - Get a single product by ID +export async function GET( + request: NextRequest, + context: RouteContext +) { + try { + const session = await getServerSession(authOptions); + + if (!session?.user) { + return NextResponse.json( + { error: 'Unauthorized' }, + { status: 401 } + ); + } + + const { id } = await context.params; + + const product = await db.product.findFirst({ + where: { + id, + organizationId: session.user.organizationId, + }, + include: { + category: { + select: { + id: true, + name: true, + description: true, + }, + }, + }, + }); + + if (!product) { + return NextResponse.json( + { error: 'Product not found' }, + { status: 404 } + ); + } + + // Add lowStock indicator + const productWithLowStock = { + ...product, + lowStock: product.trackStock && product.stock < product.minStock, + }; + + return NextResponse.json(productWithLowStock); + } catch (error) { + console.error('Error fetching product:', error); + return NextResponse.json( + { error: 'Failed to fetch product' }, + { status: 500 } + ); + } +} + +// PUT /api/products/[id] - Update a product +export async function PUT( + request: NextRequest, + context: RouteContext +) { + try { + const session = await getServerSession(authOptions); + + if (!session?.user) { + return NextResponse.json( + { error: 'Unauthorized' }, + { status: 401 } + ); + } + + // Check if user has admin role + const allowedRoles = ['SUPER_ADMIN', 'ORG_ADMIN', 'SITE_ADMIN']; + if (!allowedRoles.includes(session.user.role)) { + return NextResponse.json( + { error: 'Forbidden: Insufficient permissions' }, + { status: 403 } + ); + } + + const { id } = await context.params; + + // Verify product exists and belongs to user's organization + const existingProduct = await db.product.findFirst({ + where: { + id, + organizationId: session.user.organizationId, + }, + }); + + if (!existingProduct) { + return NextResponse.json( + { error: 'Product not found' }, + { status: 404 } + ); + } + + const body = await request.json(); + + // Validate input with Zod schema + const validationResult = updateProductSchema.safeParse(body); + if (!validationResult.success) { + return NextResponse.json( + { + error: 'Invalid product data', + details: validationResult.error.flatten().fieldErrors, + }, + { status: 400 } + ); + } + + const { + categoryId, + name, + description, + sku, + price, + costPrice, + stock, + minStock, + trackStock, + image, + isActive, + } = validationResult.data; + + // If categoryId is provided, verify it belongs to user's organization + if (categoryId) { + const category = await db.productCategory.findFirst({ + where: { + id: categoryId, + organizationId: session.user.organizationId, + }, + }); + + if (!category) { + return NextResponse.json( + { error: 'Category not found or does not belong to your organization' }, + { status: 404 } + ); + } + } + + // Check if SKU is unique within the organization (if changed) + if (sku && sku !== existingProduct.sku) { + const existingWithSku = await db.product.findFirst({ + where: { + organizationId: session.user.organizationId, + sku, + NOT: { id }, + }, + }); + + if (existingWithSku) { + return NextResponse.json( + { error: 'A product with this SKU already exists' }, + { status: 409 } + ); + } + } + + const product = await db.product.update({ + where: { id }, + data: { + ...(categoryId !== undefined && { categoryId }), + ...(name !== undefined && { name }), + ...(description !== undefined && { description }), + ...(sku !== undefined && { sku }), + ...(price !== undefined && { price }), + ...(costPrice !== undefined && { costPrice }), + ...(stock !== undefined && { stock }), + ...(minStock !== undefined && { minStock }), + ...(trackStock !== undefined && { trackStock }), + ...(image !== undefined && { image }), + ...(isActive !== undefined && { isActive }), + }, + include: { + category: { + select: { + id: true, + name: true, + description: true, + }, + }, + }, + }); + + // Add lowStock indicator + const productWithLowStock = { + ...product, + lowStock: product.trackStock && product.stock < product.minStock, + }; + + return NextResponse.json(productWithLowStock); + } catch (error) { + console.error('Error updating product:', error); + return NextResponse.json( + { error: 'Failed to update product' }, + { status: 500 } + ); + } +} + +// DELETE /api/products/[id] - Soft delete a product (set isActive = false) +export async function DELETE( + request: NextRequest, + context: RouteContext +) { + try { + const session = await getServerSession(authOptions); + + if (!session?.user) { + return NextResponse.json( + { error: 'Unauthorized' }, + { status: 401 } + ); + } + + // Check if user has admin role + const allowedRoles = ['SUPER_ADMIN', 'ORG_ADMIN', 'SITE_ADMIN']; + if (!allowedRoles.includes(session.user.role)) { + return NextResponse.json( + { error: 'Forbidden: Insufficient permissions' }, + { status: 403 } + ); + } + + const { id } = await context.params; + + // Verify product exists and belongs to user's organization + const existingProduct = await db.product.findFirst({ + where: { + id, + organizationId: session.user.organizationId, + }, + }); + + if (!existingProduct) { + return NextResponse.json( + { error: 'Product not found' }, + { status: 404 } + ); + } + + // Soft delete: set isActive to false + await db.product.update({ + where: { id }, + data: { isActive: false }, + }); + + return NextResponse.json( + { message: 'Product deleted successfully' }, + { status: 200 } + ); + } catch (error) { + console.error('Error deleting product:', error); + return NextResponse.json( + { error: 'Failed to delete product' }, + { status: 500 } + ); + } +} diff --git a/apps/web/app/api/products/[id]/stock/route.ts b/apps/web/app/api/products/[id]/stock/route.ts new file mode 100644 index 0000000..dc2aeb5 --- /dev/null +++ b/apps/web/app/api/products/[id]/stock/route.ts @@ -0,0 +1,152 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth'; +import { db } from '@/lib/db'; +import { z } from 'zod'; + +interface RouteContext { + params: Promise<{ id: string }>; +} + +// Validation schema for stock adjustment +const stockAdjustmentSchema = z.object({ + adjustment: z.number().int('Adjustment must be an integer'), + reason: z.string().min(1, 'Reason is required').max(500), +}); + +// POST /api/products/[id]/stock - Adjust product stock +export async function POST( + request: NextRequest, + context: RouteContext +) { + try { + const session = await getServerSession(authOptions); + + if (!session?.user) { + return NextResponse.json( + { error: 'Unauthorized' }, + { status: 401 } + ); + } + + // Check if user has appropriate role + const allowedRoles = ['SUPER_ADMIN', 'ORG_ADMIN', 'SITE_ADMIN', 'RECEPTIONIST']; + if (!allowedRoles.includes(session.user.role)) { + return NextResponse.json( + { error: 'Forbidden: Insufficient permissions' }, + { status: 403 } + ); + } + + const { id } = await context.params; + + // Verify product exists and belongs to user's organization + const existingProduct = await db.product.findFirst({ + where: { + id, + organizationId: session.user.organizationId, + }, + }); + + if (!existingProduct) { + return NextResponse.json( + { error: 'Product not found' }, + { status: 404 } + ); + } + + if (!existingProduct.isActive) { + return NextResponse.json( + { error: 'Cannot adjust stock for an inactive product' }, + { status: 400 } + ); + } + + if (!existingProduct.trackStock) { + return NextResponse.json( + { error: 'Stock tracking is disabled for this product' }, + { status: 400 } + ); + } + + const body = await request.json(); + + // Validate input with Zod schema + const validationResult = stockAdjustmentSchema.safeParse(body); + if (!validationResult.success) { + return NextResponse.json( + { + error: 'Invalid stock adjustment data', + details: validationResult.error.flatten().fieldErrors, + }, + { status: 400 } + ); + } + + const { adjustment, reason } = validationResult.data; + + // Calculate new stock + const newStock = existingProduct.stock + adjustment; + + // Validate stock doesn't go below 0 + if (newStock < 0) { + return NextResponse.json( + { + error: 'Stock cannot go below 0', + details: { + currentStock: existingProduct.stock, + requestedAdjustment: adjustment, + resultingStock: newStock, + }, + }, + { status: 400 } + ); + } + + // Update the product stock + const product = await db.product.update({ + where: { id }, + data: { + stock: newStock, + }, + include: { + category: { + select: { + id: true, + name: true, + description: true, + }, + }, + }, + }); + + // Log the stock adjustment (using console for now; could be a separate StockMovement table) + console.log('Stock adjustment:', { + productId: id, + productName: product.name, + previousStock: existingProduct.stock, + adjustment, + newStock, + reason, + adjustedBy: session.user.id, + adjustedAt: new Date().toISOString(), + }); + + // Add lowStock indicator + const productWithLowStock = { + ...product, + previousStock: existingProduct.stock, + adjustment, + reason, + lowStock: product.trackStock && product.stock < product.minStock, + }; + + return NextResponse.json(productWithLowStock); + } catch (error) { + console.error('Error adjusting product stock:', error); + return NextResponse.json( + { error: 'Failed to adjust product stock' }, + { status: 500 } + ); + } +} diff --git a/apps/web/app/api/products/categories/route.ts b/apps/web/app/api/products/categories/route.ts new file mode 100644 index 0000000..4122227 --- /dev/null +++ b/apps/web/app/api/products/categories/route.ts @@ -0,0 +1,137 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth'; +import { db } from '@/lib/db'; +import { z } from 'zod'; + +// Validation schema for creating a category +const createCategorySchema = z.object({ + name: z.string().min(1, 'Category name is required').max(100), + description: z.string().max(500).optional(), + displayOrder: z.number().int().min(0).optional().default(0), + isActive: z.boolean().optional().default(true), +}); + +// GET /api/products/categories - List all product categories +export async function GET(request: NextRequest) { + try { + const session = await getServerSession(authOptions); + + if (!session?.user) { + return NextResponse.json( + { error: 'Unauthorized' }, + { status: 401 } + ); + } + + const { searchParams } = new URL(request.url); + const includeInactive = searchParams.get('includeInactive') === 'true'; + + const categories = await db.productCategory.findMany({ + where: { + organizationId: session.user.organizationId, + ...(includeInactive ? {} : { isActive: true }), + }, + include: { + _count: { + select: { + products: true, + }, + }, + }, + orderBy: [ + { displayOrder: 'asc' }, + { name: 'asc' }, + ], + }); + + return NextResponse.json(categories); + } catch (error) { + console.error('Error fetching product categories:', error); + return NextResponse.json( + { error: 'Failed to fetch product categories' }, + { status: 500 } + ); + } +} + +// POST /api/products/categories - Create a new product category +export async function POST(request: NextRequest) { + try { + const session = await getServerSession(authOptions); + + if (!session?.user) { + return NextResponse.json( + { error: 'Unauthorized' }, + { status: 401 } + ); + } + + // Check if user has admin role + const allowedRoles = ['SUPER_ADMIN', 'ORG_ADMIN', 'SITE_ADMIN']; + if (!allowedRoles.includes(session.user.role)) { + return NextResponse.json( + { error: 'Forbidden: Insufficient permissions' }, + { status: 403 } + ); + } + + const body = await request.json(); + + // Validate input with Zod schema + const validationResult = createCategorySchema.safeParse(body); + if (!validationResult.success) { + return NextResponse.json( + { + error: 'Invalid category data', + details: validationResult.error.flatten().fieldErrors, + }, + { status: 400 } + ); + } + + const { name, description, displayOrder, isActive } = validationResult.data; + + // Check if category name already exists in this organization + const existingCategory = await db.productCategory.findUnique({ + where: { + organizationId_name: { + organizationId: session.user.organizationId, + name, + }, + }, + }); + + if (existingCategory) { + return NextResponse.json( + { error: 'A category with this name already exists' }, + { status: 409 } + ); + } + + const category = await db.productCategory.create({ + data: { + organizationId: session.user.organizationId, + name, + description: description || null, + displayOrder, + isActive, + }, + include: { + _count: { + select: { + products: true, + }, + }, + }, + }); + + return NextResponse.json(category, { status: 201 }); + } catch (error) { + console.error('Error creating product category:', error); + return NextResponse.json( + { error: 'Failed to create product category' }, + { status: 500 } + ); + } +} diff --git a/apps/web/app/api/products/route.ts b/apps/web/app/api/products/route.ts new file mode 100644 index 0000000..2c7b243 --- /dev/null +++ b/apps/web/app/api/products/route.ts @@ -0,0 +1,284 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth'; +import { db } from '@/lib/db'; +import { z } from 'zod'; + +// Validation schema for creating a product +const createProductSchema = z.object({ + siteId: z.string().cuid('Invalid site ID'), + categoryId: z.string().cuid('Invalid category ID'), + name: z.string().min(1, 'Product name is required').max(100), + description: z.string().max(1000).optional(), + sku: z.string().max(50).optional(), + price: z.number().nonnegative('Price must be non-negative'), + costPrice: z.number().nonnegative('Cost price must be non-negative').optional(), + stock: z.number().int().min(0, 'Stock must be non-negative'), + minStock: z.number().int().min(0, 'Minimum stock must be non-negative'), + trackStock: z.boolean().optional().default(true), + image: z.string().url().optional(), + isActive: z.boolean().optional().default(true), +}); + +// GET /api/products - List products with filters +export async function GET(request: NextRequest) { + try { + const session = await getServerSession(authOptions); + + if (!session?.user) { + return NextResponse.json( + { error: 'Unauthorized' }, + { status: 401 } + ); + } + + const { searchParams } = new URL(request.url); + const siteId = searchParams.get('siteId'); + const categoryId = searchParams.get('categoryId'); + const search = searchParams.get('search'); + const isActiveParam = searchParams.get('isActive'); + + // Non-SUPER_ADMIN users must provide a siteId or have a siteId assigned + if (session.user.role !== 'SUPER_ADMIN' && !siteId && !session.user.siteId) { + return NextResponse.json( + { error: 'siteId is required' }, + { status: 400 } + ); + } + + // Determine effective siteId + const effectiveSiteId = siteId || session.user.siteId; + + // If siteId is provided, verify it belongs to user's organization + if (effectiveSiteId) { + const site = await db.site.findFirst({ + where: { + id: effectiveSiteId, + organizationId: session.user.organizationId, + }, + }); + + if (!site) { + return NextResponse.json( + { error: 'Site not found or does not belong to your organization' }, + { status: 404 } + ); + } + } + + // Build the where clause + interface ProductWhereClause { + organizationId: string; + categoryId?: string; + isActive?: boolean; + name?: { + contains: string; + mode: 'insensitive'; + }; + } + + const whereClause: ProductWhereClause = { + organizationId: session.user.organizationId, + }; + + // Filter by category + if (categoryId) { + whereClause.categoryId = categoryId; + } + + // Filter by active status (default to true) + if (isActiveParam !== null) { + whereClause.isActive = isActiveParam !== 'false'; + } else { + whereClause.isActive = true; + } + + // Search by name + if (search) { + whereClause.name = { + contains: search, + mode: 'insensitive', + }; + } + + const products = await db.product.findMany({ + where: whereClause, + include: { + category: { + select: { + id: true, + name: true, + description: true, + }, + }, + }, + orderBy: [ + { category: { displayOrder: 'asc' } }, + { name: 'asc' }, + ], + }); + + // Add lowStock indicator to each product + const productsWithLowStock = products.map((product) => ({ + ...product, + lowStock: product.trackStock && product.stock < product.minStock, + })); + + return NextResponse.json(productsWithLowStock); + } catch (error) { + console.error('Error fetching products:', error); + return NextResponse.json( + { error: 'Failed to fetch products' }, + { status: 500 } + ); + } +} + +// POST /api/products - Create a new product +export async function POST(request: NextRequest) { + try { + const session = await getServerSession(authOptions); + + if (!session?.user) { + return NextResponse.json( + { error: 'Unauthorized' }, + { status: 401 } + ); + } + + // Check if user has admin role + const allowedRoles = ['SUPER_ADMIN', 'ORG_ADMIN', 'SITE_ADMIN']; + if (!allowedRoles.includes(session.user.role)) { + return NextResponse.json( + { error: 'Forbidden: Insufficient permissions' }, + { status: 403 } + ); + } + + const body = await request.json(); + + // Validate input with Zod schema + const validationResult = createProductSchema.safeParse(body); + if (!validationResult.success) { + return NextResponse.json( + { + error: 'Invalid product data', + details: validationResult.error.flatten().fieldErrors, + }, + { status: 400 } + ); + } + + const { + siteId, + categoryId, + name, + description, + sku, + price, + costPrice, + stock, + minStock, + trackStock, + image, + isActive, + } = validationResult.data; + + // Verify site belongs to user's organization + const site = await db.site.findFirst({ + where: { + id: siteId, + organizationId: session.user.organizationId, + }, + }); + + if (!site) { + return NextResponse.json( + { error: 'Site not found or does not belong to your organization' }, + { status: 404 } + ); + } + + // If user is SITE_ADMIN, verify they have access to this site + if (session.user.role === 'SITE_ADMIN' && session.user.siteId !== siteId) { + return NextResponse.json( + { error: 'Forbidden: You do not have access to this site' }, + { status: 403 } + ); + } + + // Verify category belongs to user's organization + const category = await db.productCategory.findFirst({ + where: { + id: categoryId, + organizationId: session.user.organizationId, + }, + }); + + if (!category) { + return NextResponse.json( + { error: 'Category not found or does not belong to your organization' }, + { status: 404 } + ); + } + + // Check if SKU is unique within the organization (if provided) + if (sku) { + const existingProduct = await db.product.findUnique({ + where: { + organizationId_sku: { + organizationId: session.user.organizationId, + sku, + }, + }, + }); + + if (existingProduct) { + return NextResponse.json( + { error: 'A product with this SKU already exists' }, + { status: 409 } + ); + } + } + + const product = await db.product.create({ + data: { + organizationId: session.user.organizationId, + categoryId, + name, + description: description || null, + sku: sku || null, + price, + costPrice: costPrice || null, + stock, + minStock, + trackStock, + image: image || null, + isActive, + }, + include: { + category: { + select: { + id: true, + name: true, + description: true, + }, + }, + }, + }); + + // Add lowStock indicator + const productWithLowStock = { + ...product, + lowStock: product.trackStock && product.stock < product.minStock, + }; + + return NextResponse.json(productWithLowStock, { status: 201 }); + } catch (error) { + console.error('Error creating product:', error); + return NextResponse.json( + { error: 'Failed to create product' }, + { status: 500 } + ); + } +}