ProductController.java
package com.stocks.stockease.product.web;
import java.math.BigDecimal;
import java.security.Principal;
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.shared.ApiResponse;
import com.stocks.stockease.shared.PaginatedResponse;
import com.stocks.stockease.product.Product;
import com.stocks.stockease.product.ProductService;
import com.stocks.stockease.security.User;
import com.stocks.stockease.security.UserService;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.Positive;
import lombok.RequiredArgsConstructor;
/**
* REST controller for product inventory management.
*
* <p>Covers CRUD, partial field updates, pagination, search, and stock analytics.
* Full contract for every operation is defined in {@code docs/api/paths/products.yaml}.
* All endpoints require at minimum ROLE_USER; create and delete require ROLE_ADMIN.
*/
@RestController
@RequestMapping("/api/products")
@RequiredArgsConstructor
public class ProductController {
private static final Logger log = LoggerFactory.getLogger(ProductController.class);
private final ProductService productService;
private final UserService userService;
/** Resolves the authenticated principal to the user recorded against product changes. */
private User currentUser(Principal principal) {
return userService.findByUsername(principal.getName())
.orElseThrow(() -> new IllegalStateException("Authenticated user not found."));
}
/**
* Returns all products ordered by ID ascending.
*
* <p>Loads the entire catalogue into memory. Prefer {@link #getPagedProducts} for
* large datasets. Behavior defined in {@code docs/api/paths/products.yaml}.
*
* @return list of all {@link Product} entities ordered by ID
*/
@GetMapping
@PreAuthorize("hasAnyRole('ADMIN', 'USER')")
public List<Product> getAllProducts() {
return productService.getAllProducts();
}
/**
* Returns a paginated slice of the product catalogue.
*
* <p>Behavior defined in {@code docs/api/paths/products.yaml}.
*
* @param page zero-based page index (default 0)
* @param size items per page (default 10, must be positive)
* @return {@link PaginatedResponse} with product list and pagination 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) {
Pageable pageable = PageRequest.of(page, size);
Page<Product> products = productService.getPagedProducts(pageable);
PaginatedResponse<Product> response = new PaginatedResponse<>(products);
return ResponseEntity.ok(new ApiResponse<>(true, "Paged products fetched successfully", response));
}
/**
* Returns a single product by its ID.
*
* <p>Behavior defined in {@code docs/api/paths/products.yaml}.
*
* @param id product identifier
* @return HTTP 200 with the {@link Product}, or HTTP 404 if not found
*/
@GetMapping("/{id}")
@PreAuthorize("hasAnyRole('ADMIN', 'USER')")
public ResponseEntity<ApiResponse<Product>> getProductById(@PathVariable long id) {
return productService.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).
*
* <p>Validates that name is non-blank, quantity is non-negative, and purchasePrice is positive.
* Behavior defined in {@code docs/api/paths/products.yaml}.
*
* @param request product fields (name, quantity, purchasePrice)
* @return HTTP 200 with the persisted {@link Product} including its generated ID,
* or HTTP 400 if validation fails
*/
@PostMapping
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<?> createProduct(@Valid @RequestBody CreateProductRequest request) {
log.debug("Received request to create product: {}", request);
Product savedProduct = productService.create(request.getName(), request.getQuantity(), request.getPurchasePrice());
return ResponseEntity.ok(savedProduct);
}
/**
* Deletes a product by ID (ADMIN only).
*
* <p>Behavior defined in {@code docs/api/paths/products.yaml}.
*
* @param id product identifier to delete
* @param principal authenticated user, recorded against the deletion in the change log
* @return HTTP 200 on success, HTTP 404 if the product does not exist
*/
@DeleteMapping("/{id}")
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<ApiResponse<String>> deleteProduct(@PathVariable long id, Principal principal) {
log.info("Entering deleteProduct method with ID: {}", id);
if (!productService.deleteById(id, currentUser(principal))) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(new ApiResponse<>(false, "Cannot delete. Product with ID " + id + " does not exist.", null));
}
return ResponseEntity.ok(
new ApiResponse<>(true, "Product with ID " + id + " has been successfully deleted.", null)
);
}
/**
* Returns products with critically low stock.
*
* <p>Threshold is hardcoded at 5 units; consider externalising to
* {@code application.properties} if it needs to vary per environment.
* Behavior defined in {@code docs/api/paths/products.yaml}.
*
* @return list of low-stock {@link Product} entities, or a status message if all levels are sufficient
*/
@GetMapping("/low-stock")
@PreAuthorize("hasAnyRole('ADMIN', 'USER')")
public ResponseEntity<?> getLowStockProducts() {
List<Product> lowStockProducts = productService.findLowStock(5);
if (lowStockProducts.isEmpty()) {
return ResponseEntity.ok(Map.of("message", "All products are sufficiently stocked."));
}
return ResponseEntity.ok(lowStockProducts);
}
/**
* Searches products by name using a case-insensitive substring match.
*
* <p>For example, searching {@code "apple"} matches {@code "Apple Juice"}, {@code "APPLE"},
* and {@code "Green Apple"}. Behavior defined in {@code docs/api/paths/products.yaml}.
*
* @param name search term (substring, case-insensitive)
* @return HTTP 200 with matching products, or HTTP 204 if none found
*/
@GetMapping("/search")
@PreAuthorize("hasAnyRole('ADMIN', 'USER')")
public ResponseEntity<?> searchProductsByName(@RequestParam String name) {
List<Product> products = productService.searchByName(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 the stock quantity of a specific product.
*
* <p>{@code totalValue} in the response is computed from the updated quantity.
* Behavior defined in {@code docs/api/paths/products.yaml}.
*
* @param id product identifier
* @param request request body containing an integer {@code quantity} field
* @return HTTP 200 with the updated {@link Product}, or HTTP 400/404 on error
*/
@PutMapping("/{id}/quantity")
@PreAuthorize("hasAnyRole('ADMIN', 'USER')")
public ResponseEntity<ApiResponse<Product>> updateQuantity(@PathVariable long id, @Valid @RequestBody UpdateQuantityRequest request) {
Product updatedProduct = productService.updateQuantity(id, request.getQuantity());
return ResponseEntity.ok(new ApiResponse<>(true, "Quantity updated successfully", updatedProduct));
}
/**
* Updates the purchase price of a specific product.
*
* <p>{@code totalValue} in the response is computed from the updated price.
* Behavior defined in {@code docs/api/paths/products.yaml}.
*
* @param id product identifier
* @param request request body containing a numeric {@code purchasePrice} field
* @param principal authenticated user, recorded against the change in the change log
* @return HTTP 200 with the updated {@link Product}, or HTTP 400/404 on error
*/
@PutMapping("/{id}/price")
@PreAuthorize("hasAnyRole('ADMIN', 'USER')")
public ResponseEntity<ApiResponse<Product>> updatePrice(@PathVariable long id, @Valid @RequestBody UpdatePriceRequest request, Principal principal) {
Product updatedProduct = productService.updatePrice(id, BigDecimal.valueOf(request.getPurchasePrice()), currentUser(principal));
return ResponseEntity.ok(new ApiResponse<>(true, "Price updated successfully", updatedProduct));
}
/**
* Updates the name of a specific product.
*
* <p>Uniqueness is enforced at the database level. Behavior defined in
* {@code docs/api/paths/products.yaml}.
*
* @param id product identifier
* @param request request body containing a non-blank {@code name} field
* @param principal authenticated user, recorded against the change in the change log
* @return HTTP 200 with the updated {@link Product}, or HTTP 400/404 on error
*/
@PutMapping("/{id}/name")
@PreAuthorize("hasAnyRole('ADMIN', 'USER')")
public ResponseEntity<ApiResponse<Product>> updateName(@PathVariable long id, @Valid @RequestBody UpdateNameRequest request, Principal principal) {
Product updatedProduct = productService.updateName(id, request.getName(), currentUser(principal));
return ResponseEntity.ok(new ApiResponse<>(true, "Name updated successfully", updatedProduct));
}
/**
* Calculates the aggregate inventory value across all products.
*
* <p>Executes a database-level aggregate ({@code SUM(quantity * purchasePrice)}) via
* {@link ProductService#getTotalStockValue()}. Behavior defined in
* {@code docs/api/paths/products.yaml}.
*
* @return HTTP 200 with the total stock value as a {@link Double}
*/
@GetMapping("/total-stock-value")
@PreAuthorize("hasAnyRole('ADMIN', 'USER')")
public ResponseEntity<ApiResponse<Double>> getTotalStockValue() {
double totalStockValue = productService.getTotalStockValue();
return ResponseEntity.ok(new ApiResponse<>(true, "Total stock value fetched successfully", totalStockValue));
}
}