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:
Ivan
2026-02-01 06:54:35 +00:00
parent cdf6e8ebe6
commit c298b23b35
4 changed files with 859 additions and 0 deletions

View 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 }
);
}
}

View 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 }
);
}
}

View 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 }
);
}
}

View 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 }
);
}
}