feat(api): add products and categories endpoints
Add API endpoints for the POS module: - GET/POST /api/products/categories - List and create product categories - GET/POST /api/products - List products with filters and create new products - GET/PUT/DELETE /api/products/[id] - CRUD operations for individual products - POST /api/products/[id]/stock - Adjust product stock with validation Features include: - Zod validation for all inputs - Organization-scoped access control - Low stock indicator (stock < minStock) - Soft delete for products (isActive = false) - Stock adjustment with reason tracking Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
286
apps/web/app/api/products/[id]/route.ts
Normal file
286
apps/web/app/api/products/[id]/route.ts
Normal file
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
152
apps/web/app/api/products/[id]/stock/route.ts
Normal file
152
apps/web/app/api/products/[id]/stock/route.ts
Normal file
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
137
apps/web/app/api/products/categories/route.ts
Normal file
137
apps/web/app/api/products/categories/route.ts
Normal file
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
284
apps/web/app/api/products/route.ts
Normal file
284
apps/web/app/api/products/route.ts
Normal file
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user