ProductController.java
package com.stocks.stockease.controller;
import java.util.List;
import java.util.Map;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import com.stocks.stockease.dto.ApiResponse;
import com.stocks.stockease.dto.PaginatedResponse;
import com.stocks.stockease.model.Product;
import com.stocks.stockease.repository.ProductRepository;
import jakarta.persistence.EntityNotFoundException;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.Positive;
/**
* REST controller for product inventory management.
*
* Provides endpoints for CRUD operations, pagination, searching, and stock analytics.
* All non-admin endpoints require USER or ADMIN role authentication via JWT.
* Admin-only endpoints (create, delete) require ADMIN role.
*
* @author Team StockEase
* @version 1.0
* @since 2025-01-01
*/
@RestController
@RequestMapping("/api/products")
public class ProductController {
private static final Logger log = LoggerFactory.getLogger(ProductController.class);
private final ProductRepository productRepository;
public ProductController(ProductRepository productRepository) {
this.productRepository = productRepository;
}
/**
* Retrieves all products sorted by ID.
*
* Loads entire inventory without pagination. Use {@link #getPagedProducts} for large datasets.
*
* @return list of all products ordered by ID ascending
*/
@GetMapping
@PreAuthorize("hasAnyRole('ADMIN', 'USER')")
public List<Product> getAllProducts() {
return productRepository.findAllOrderById();
}
/**
* Retrieves products with pagination support.
*
* Prevents loading entire table into memory. Returns metadata including total count
* and page information for client-side pagination controls.
*
* @param page zero-based page number (default: 0)
* @param size items per page (default: 10, must be positive)
* @return paginated response with product list and metadata
*/
@GetMapping("/paged")
@PreAuthorize("hasAnyRole('ADMIN', 'USER')")
public ResponseEntity<ApiResponse<PaginatedResponse<Product>>> getPagedProducts(
@RequestParam(defaultValue = "0") @Min(0) int page,
@RequestParam(defaultValue = "10") @Positive int size) {
// Validate parameters via @Min/@Positive annotations; invalid values trigger 400 error
Pageable pageable = PageRequest.of(page, size);
Page<Product> products = productRepository.findAll(pageable);
PaginatedResponse<Product> response = new PaginatedResponse<>(products);
return ResponseEntity.ok(new ApiResponse<>(true, "Paged products fetched successfully", response));
}
/**
* Retrieves a single product by ID.
*
* Returns 404 if product not found.
*
* @param id product identifier
* @return product details if found; 404 error response if not
*/
@GetMapping("/{id}")
@PreAuthorize("hasAnyRole('ADMIN', 'USER')")
public ResponseEntity<ApiResponse<Product>> getProductById(@PathVariable Long id) {
return productRepository.findById(id)
.map(product -> ResponseEntity.ok(new ApiResponse<>(true, "Product fetched successfully", product)))
.orElse(ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(new ApiResponse<>(false, "The product with ID " + id + " does not exist.", null)));
}
/**
* Creates a new product (ADMIN only).
*
* Validates all required fields (name, quantity, price). Calculates total stock value
* as quantity * price. Returns 400 if validation fails.
*
* @param product product data (name, quantity, price)
* @return created product with auto-generated ID
* @throws IllegalArgumentException if required fields missing or invalid
*/
@PostMapping
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<?> createProduct(@RequestBody(required = false) Product product) {
log.debug("Received request to create product: {}", product);
try {
// Validate all required fields present and non-empty
if (product == null || product.getName() == null || product.getName().isBlank() ||
product.getQuantity() == null || product.getPrice() == null) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", "Incomplete update. Please fill in all required fields."));
}
// Business rule: quantity cannot be negative (invalid stock state)
if (product.getQuantity() < 0) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", "Quantity cannot be negative."));
}
// Business rule: price must be positive (prevents free/negative cost items)
if (product.getPrice() <= 0) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", "Price must be greater than 0."));
}
// Database saves product and generates auto-increment ID
Product savedProduct = productRepository.save(product);
return ResponseEntity.ok(savedProduct);
} catch (Exception ex) {
log.error("Unexpected error occurred: ", ex);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("error", "An unexpected error occurred. Please try again later."));
}
}
/**
* Deletes a product by ID (ADMIN only).
*
* Returns 404 if product not found. Does not require product data in body.
*
* @param id product identifier to delete
* @return success message if deleted; error response if not found
*/
@DeleteMapping("/{id}")
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<ApiResponse<String>> deleteProduct(@PathVariable Long id) {
log.info("Entering deleteProduct method with ID: {}", id);
if (id == null) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(new ApiResponse<>(false, "ID must be provided in the request.", null));
}
// Check if product exists before attempting deletion (avoids orphaned references)
if (!productRepository.existsById(id)) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(new ApiResponse<>(false, "Cannot delete. Product with ID " + id + " does not exist.", null));
}
productRepository.deleteById(id);
return ResponseEntity.ok(
new ApiResponse<>(true, "Product with ID " + id + " has been successfully deleted.", null)
);
}
/**
* Retrieves products with critically low stock.
*
* Returns products where quantity < 5 (reorder threshold).
* Returns success message if all products adequately stocked.
*
* @return list of low-stock products; empty response if all stock levels sufficient
*/
@GetMapping("/low-stock")
@PreAuthorize("hasAnyRole('ADMIN', 'USER')")
public ResponseEntity<?> getLowStockProducts() {
// Hardcoded threshold (5 items) - consider making configurable via application.properties
List<Product> lowStockProducts = productRepository.findByQuantityLessThan(5);
if (lowStockProducts.isEmpty()) {
return ResponseEntity.ok(Map.of("message", "All products are sufficiently stocked."));
}
return ResponseEntity.ok(lowStockProducts);
}
/**
* Searches products by name (case-insensitive substring match).
*
* Example: searching "apple" returns "Apple Juice", "APPLE", "Green Apple", etc.
* Returns 204 NO_CONTENT if no matches found.
*
* @param name search term (substring)
* @return matching products; empty response if none found
*/
@GetMapping("/search")
@PreAuthorize("hasAnyRole('ADMIN', 'USER')")
public ResponseEntity<?> searchProductsByName(@RequestParam String name) {
// Case-insensitive LIKE query via repository
List<Product> products = productRepository.findByNameContainingIgnoreCase(name);
if (products.isEmpty()) {
return ResponseEntity.status(HttpStatus.NO_CONTENT)
.body(Map.of("message", "No products found matching the name: " + name));
}
return ResponseEntity.ok(products);
}
/**
* Updates product quantity for a specific product.
*
* Accepts quantity in request body. Automatically recalculates total stock value
* (quantity * price). Prevents negative quantities.
*
* @param id product identifier
* @param request Map containing "quantity" field (integer)
* @return updated product; error if quantity invalid or product not found
*/
@PutMapping("/{id}/quantity")
@PreAuthorize("hasAnyRole('ADMIN', 'USER')")
public ResponseEntity<ApiResponse<Product>> updateQuantity(@PathVariable Long id, @RequestBody(required = false) Map<String, Object> request) {
try {
// Validate request payload structure
if (request == null || !request.containsKey("quantity") || request.get("quantity") == null) {
return ResponseEntity.badRequest()
.body(new ApiResponse<>(false, "Quantity field is missing or null.", null));
}
// Type check: quantity must be integer (prevents string/decimal injection)
Object quantityObj = request.get("quantity");
if (!(quantityObj instanceof Integer)) {
return ResponseEntity.badRequest()
.body(new ApiResponse<>(false, "Quantity must be a valid integer.", null));
}
// Business rule: quantity cannot be negative (invalid inventory state)
int newQuantity = (int) quantityObj;
if (newQuantity < 0) {
return ResponseEntity.badRequest()
.body(new ApiResponse<>(false, "Quantity cannot be negative.", null));
}
// Load product from DB; throws EntityNotFoundException if not found
Product product = productRepository.findById(id)
.orElseThrow(() -> new EntityNotFoundException("Product with ID " + id + " not found."));
// Setter automatically recalculates totalValue = quantity * price
product.setQuantity(newQuantity);
Product updatedProduct = productRepository.save(product);
return ResponseEntity.ok(new ApiResponse<>(true, "Quantity updated successfully", updatedProduct));
} catch (EntityNotFoundException ex) {
log.error("Product not found for ID: " + id, ex);
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(new ApiResponse<>(false, "Product not found.", null));
} catch (Exception ex) {
log.error("Unexpected error occurred while updating quantity for product with ID: " + id, ex);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(new ApiResponse<>(false, "An unexpected error occurred. Please try again later.", null));
}
}
/**
* Updates product price for a specific product.
*
* Accepts price in request body (decimal). Automatically recalculates total stock value
* (quantity * price). Prevents zero or negative prices.
*
* @param id product identifier
* @param request Map containing "price" field (number)
* @return updated product; error if price invalid or product not found
*/
@PutMapping("/{id}/price")
@PreAuthorize("hasAnyRole('ADMIN', 'USER')")
public ResponseEntity<ApiResponse<Product>> updatePrice(@PathVariable Long id, @RequestBody(required = false) Map<String, Object> request) {
try {
// Validate request payload structure
if (request == null || !request.containsKey("price") || request.get("price") == null) {
return ResponseEntity.badRequest()
.body(new ApiResponse<>(false, "Price field is missing or null.", null));
}
// Type check: price must be numeric (handles Integer, Double, BigDecimal via Number interface)
Object priceObj = request.get("price");
if (!(priceObj instanceof Number)) {
return ResponseEntity.badRequest()
.body(new ApiResponse<>(false, "Price must be a valid number.", null));
}
// Business rule: price must be positive (prevents free or negative cost items)
double newPrice = ((Number) priceObj).doubleValue();
if (newPrice <= 0) {
return ResponseEntity.badRequest()
.body(new ApiResponse<>(false, "Price must be greater than 0.", null));
}
// Load product from DB; throws EntityNotFoundException if not found
Product product = productRepository.findById(id)
.orElseThrow(() -> new EntityNotFoundException("Product with ID " + id + " not found."));
// Setter automatically recalculates totalValue = quantity * price
product.setPrice(newPrice);
Product updatedProduct = productRepository.save(product);
return ResponseEntity.ok(new ApiResponse<>(true, "Price updated successfully", updatedProduct));
} catch (EntityNotFoundException ex) {
log.error("Product not found for ID: " + id, ex);
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(new ApiResponse<>(false, "Product not found.", null));
} catch (Exception ex) {
log.error("Unexpected error occurred while updating price for product with ID: " + id, ex);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(new ApiResponse<>(false, "An unexpected error occurred. Please try again later.", null));
}
}
/**
* Updates product name for a specific product.
*
* Validates that name is non-empty. Uniqueness constraint enforced at database level.
*
* @param id product identifier
* @param request Map containing "name" field (string)
* @return updated product; error if name empty or product not found
*/
@PutMapping("/{id}/name")
@PreAuthorize("hasAnyRole('ADMIN', 'USER')")
public ResponseEntity<ApiResponse<Product>> updateName(@PathVariable Long id, @RequestBody Map<String, String> request) {
try {
// Validate name field: must be present and non-empty
if (!request.containsKey("name") || request.get("name").isBlank()) {
throw new IllegalArgumentException("Name is required and cannot be empty.");
}
String newName = request.get("name");
// Load product from DB; throws EntityNotFoundException if not found
Product product = productRepository.findById(id)
.orElseThrow(() -> new EntityNotFoundException("Product with ID " + id + " not found."));
product.setName(newName);
Product updatedProduct = productRepository.save(product);
return ResponseEntity.ok(new ApiResponse<>(true, "Name updated successfully", updatedProduct));
} catch (EntityNotFoundException ex) {
log.error("Product not found for ID: " + id, ex);
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(new ApiResponse<>(false, ex.getMessage(), null));
} catch (IllegalArgumentException ex) {
log.error("Invalid name provided for product with ID: " + id, ex);
return ResponseEntity.badRequest()
.body(new ApiResponse<>(false, ex.getMessage(), null));
} catch (Exception ex) {
log.error("Unexpected error occurred while updating name for product with ID: " + id, ex);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(new ApiResponse<>(false, "An unexpected error occurred. Please try again later.", null));
}
}
/**
* Calculates total inventory value across all products.
*
* Computes sum of (quantity * price) for all products.
* Useful for financial reporting and inventory valuation.
*
* @return total stock value as double
*/
@GetMapping("/total-stock-value")
@PreAuthorize("hasAnyRole('ADMIN', 'USER')")
public ResponseEntity<ApiResponse<Double>> getTotalStockValue() {
try {
// Custom aggregate query from repository - optimized at database level
double totalStockValue = productRepository.calculateTotalStockValue();
return ResponseEntity.ok(new ApiResponse<>(true, "Total stock value fetched successfully", totalStockValue));
} catch (Exception ex) {
log.error("Error calculating total stock value:", ex);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(new ApiResponse<>(false, "Failed to fetch total stock value.", null));
}
}
}