new admin endpoints and cleaned up product dto for faster response

This commit is contained in:
2025-12-30 09:06:37 -05:00
parent 30ac00ecf9
commit 91867ad189
15 changed files with 672 additions and 7 deletions

View File

@@ -70,7 +70,7 @@ public class SecurityConfig {
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration cfg = new CorsConfiguration();
cfg.setAllowedOrigins(List.of("http://localhost:3000"));
cfg.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
cfg.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
cfg.setAllowedHeaders(List.of("Authorization", "Content-Type"));
cfg.setExposedHeaders(List.of("Authorization"));
cfg.setAllowCredentials(true);

View File

@@ -6,6 +6,9 @@ import group.goforward.battlbuilder.web.dto.ProductSummaryDto;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.web.PageableDefault;
import java.util.List;
@@ -23,13 +26,14 @@ public class ProductV1Controller {
@GetMapping
@Cacheable(
value = "gunbuilderProductsV1",
key = "#platform + '::' + (#partRoles == null ? 'ALL' : #partRoles.toString())"
key = "#platform + '::' + (#partRoles == null ? 'ALL' : #partRoles.toString()) + '::' + #pageable.pageNumber + '::' + #pageable.pageSize"
)
public List<ProductSummaryDto> getProducts(
public Page<ProductSummaryDto> getProducts(
@RequestParam(defaultValue = "AR-15") String platform,
@RequestParam(required = false, name = "partRoles") List<String> partRoles
@RequestParam(required = false, name = "partRoles") List<String> partRoles,
@PageableDefault(size = 50) Pageable pageable
) {
return productQueryService.getProducts(platform, partRoles);
return productQueryService.getProductsPage(platform, partRoles, pageable);
}
@GetMapping("/{id}/offers")

View File

@@ -8,7 +8,6 @@ import java.util.Comparator;
import java.util.Objects;
import java.util.Set;
import java.util.HashSet;
import group.goforward.battlbuilder.model.PartRoleSource;
@Entity
@Table(name = "products")
@@ -130,6 +129,24 @@ public class Product {
public String getCaliberGroup() { return caliberGroup; }
public void setCaliberGroup(String caliberGroup) { this.caliberGroup = caliberGroup; }
/* Admin Management */
@Enumerated(EnumType.STRING)
@Column(name = "visibility", nullable = false)
private ProductVisibility visibility = ProductVisibility.PUBLIC;
@Column(name = "builder_eligible", nullable = false)
private Boolean builderEligible = true;
@Column(name = "admin_locked", nullable = false)
private Boolean adminLocked = false;
@Enumerated(EnumType.STRING)
@Column(name = "status", nullable = false)
private ProductStatus status = ProductStatus.ACTIVE;
@Column(name = "admin_note")
private String adminNote;
// --- lifecycle hooks ---
@PrePersist
public void prePersist() {
@@ -237,6 +254,22 @@ public class Product {
public Boolean getPartRoleLocked() { return partRoleLocked; }
public void setPartRoleLocked(Boolean partRoleLocked) { this.partRoleLocked = partRoleLocked; }
// --- Admin Getters/setters
public ProductVisibility getVisibility() { return visibility; }
public void setVisibility(ProductVisibility visibility) { this.visibility = visibility; }
public Boolean getBuilderEligible() { return builderEligible; }
public void setBuilderEligible(Boolean builderEligible) { this.builderEligible = builderEligible; }
public Boolean getAdminLocked() { return adminLocked; }
public void setAdminLocked(Boolean adminLocked) { this.adminLocked = adminLocked; }
public ProductStatus getStatus() { return status; }
public void setStatus(ProductStatus status) { this.status = status; }
public String getAdminNote() { return adminNote; }
public void setAdminNote(String adminNote) { this.adminNote = adminNote; }
// --- computed helpers ---
public BigDecimal getBestOfferPrice() {

View File

@@ -0,0 +1,7 @@
package group.goforward.battlbuilder.model;
public enum ProductStatus {
ACTIVE,
DISABLED,
ARCHIVED
}

View File

@@ -0,0 +1,6 @@
package group.goforward.battlbuilder.model;
public enum ProductVisibility {
PUBLIC,
HIDDEN
}

View File

@@ -10,12 +10,16 @@ import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import java.util.Collection;
import java.util.List;
import java.util.Map;
public interface ProductRepository extends JpaRepository<Product, Integer> {
public interface ProductRepository
extends JpaRepository<Product, Integer>,
JpaSpecificationExecutor<Product> {
/**
@@ -238,6 +242,20 @@ where po.product_id = p.id
List<Product> findByMerchantIdAndRawCategoryKey(@Param("merchantId") Integer merchantId,
@Param("rawCategoryKey") String rawCategoryKey);
@Query("SELECT p FROM Product p JOIN FETCH p.brand b WHERE p.deletedAt IS NULL")
Page<Product> findAllWithBrand(Pageable pageable);
@Query("SELECT p FROM Product p JOIN FETCH p.brand b WHERE p.platform = :platform AND p.deletedAt IS NULL")
Page<Product> findByPlatformWithBrand(String platform, Pageable pageable);
@Query("SELECT p FROM Product p JOIN FETCH p.brand b WHERE p.partRole IN :roles AND p.deletedAt IS NULL")
Page<Product> findByPartRoleInWithBrand(List<String> roles, Pageable pageable);
@Query("SELECT p FROM Product p JOIN FETCH p.brand b WHERE p.platform = :platform AND p.partRole IN :roles AND p.deletedAt IS NULL")
Page<Product> findByPlatformAndPartRoleInWithBrand(String platform, List<String> roles, Pageable pageable);
// -------------------------------------------------
// Admin import-status dashboard (summary)
// -------------------------------------------------

View File

@@ -5,6 +5,9 @@ import group.goforward.battlbuilder.web.dto.ProductSummaryDto;
import java.util.List;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
public interface ProductQueryService {
List<ProductSummaryDto> getProducts(String platform, List<String> partRoles);
@@ -12,4 +15,6 @@ public interface ProductQueryService {
List<ProductOfferDto> getOffersForProduct(Integer productId);
ProductSummaryDto getProductById(Integer productId);
Page<ProductSummaryDto> getProductsPage(String platform, List<String> partRoles, Pageable pageable);
}

View File

@@ -0,0 +1,18 @@
package group.goforward.battlbuilder.services.admin;
import group.goforward.battlbuilder.web.dto.admin.AdminProductSearchRequest;
import group.goforward.battlbuilder.web.dto.admin.ProductAdminRowDto;
import group.goforward.battlbuilder.web.dto.admin.ProductBulkUpdateRequest;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
public interface AdminProductService {
Page<ProductAdminRowDto> search(
AdminProductSearchRequest request,
Pageable pageable
);
int bulkUpdate(ProductBulkUpdateRequest request);
}

View File

@@ -0,0 +1,65 @@
package group.goforward.battlbuilder.services.admin.impl;
import group.goforward.battlbuilder.model.Product;
import group.goforward.battlbuilder.repos.ProductRepository;
import group.goforward.battlbuilder.services.admin.AdminProductService;
import group.goforward.battlbuilder.specs.ProductSpecifications;
import group.goforward.battlbuilder.web.dto.admin.AdminProductSearchRequest;
import group.goforward.battlbuilder.web.dto.admin.ProductAdminRowDto;
import group.goforward.battlbuilder.web.dto.admin.ProductBulkUpdateRequest;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@Transactional
public class AdminProductServiceImpl implements AdminProductService {
private final ProductRepository productRepository;
public AdminProductServiceImpl(ProductRepository productRepository) {
this.productRepository = productRepository;
}
@Override
public Page<ProductAdminRowDto> search(
AdminProductSearchRequest request,
Pageable pageable
) {
Specification<Product> spec =
ProductSpecifications.adminSearch(request);
return productRepository
.findAll(spec, pageable)
.map(ProductAdminRowDto::fromEntity);
}
@Override
public int bulkUpdate(ProductBulkUpdateRequest request) {
var products = productRepository.findAllById(request.getProductIds());
products.forEach(p -> {
if (request.getVisibility() != null) {
p.setVisibility(request.getVisibility());
}
if (request.getStatus() != null) {
p.setStatus(request.getStatus());
}
if (request.getBuilderEligible() != null) {
p.setBuilderEligible(request.getBuilderEligible());
}
if (request.getAdminLocked() != null) {
p.setAdminLocked(request.getAdminLocked());
}
if (request.getAdminNote() != null) {
p.setAdminNote(request.getAdminNote());
}
});
productRepository.saveAll(products);
return products.size();
}
}

View File

@@ -13,6 +13,15 @@ import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.util.*;
import java.util.stream.Collectors;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.Pageable;
import java.util.stream.Collectors;
import java.util.Collections;
import java.util.Map;
import java.util.List;
import java.util.ArrayList;
@Service
public class ProductQueryServiceImpl implements ProductQueryService {
@@ -69,6 +78,57 @@ public class ProductQueryServiceImpl implements ProductQueryService {
.toList();
}
@Override
public Page<ProductSummaryDto> getProductsPage(String platform, List<String> partRoles, Pageable pageable) {
final boolean allPlatforms = platform != null && platform.equalsIgnoreCase("ALL");
Page<Product> productPage;
if (partRoles == null || partRoles.isEmpty()) {
productPage = allPlatforms
? productRepository.findAllWithBrand(pageable)
: productRepository.findByPlatformWithBrand(platform, pageable);
} else {
productPage = allPlatforms
? productRepository.findByPartRoleInWithBrand(partRoles, pageable)
: productRepository.findByPlatformAndPartRoleInWithBrand(platform, partRoles, pageable);
}
List<Product> products = productPage.getContent();
if (products.isEmpty()) {
return Page.empty(pageable);
}
List<Integer> productIds = products.stream().map(Product::getId).toList();
// Only fetch offers for THIS PAGE of products
List<ProductOffer> allOffers = productOfferRepository.findByProduct_IdIn(productIds);
Map<Integer, List<ProductOffer>> offersByProductId = allOffers.stream()
.filter(o -> o.getProduct() != null && o.getProduct().getId() != null)
.collect(Collectors.groupingBy(o -> o.getProduct().getId()));
List<ProductSummaryDto> dtos = products.stream()
.map(p -> {
List<ProductOffer> offersForProduct =
offersByProductId.getOrDefault(p.getId(), Collections.emptyList());
ProductOffer bestOffer = pickBestOffer(offersForProduct);
BigDecimal price = bestOffer != null ? bestOffer.getEffectivePrice() : null;
String buyUrl = bestOffer != null ? bestOffer.getBuyUrl() : null;
return ProductMapper.toSummary(p, price, buyUrl);
})
.toList();
return new PageImpl<>(dtos, pageable, productPage.getTotalElements());
}
//
// Product Offers
//
@Override
public List<ProductOfferDto> getOffersForProduct(Integer productId) {
// ✅ canonical repo method

View File

@@ -0,0 +1,113 @@
package group.goforward.battlbuilder.specs;
import group.goforward.battlbuilder.model.ImportStatus;
import group.goforward.battlbuilder.model.Product;
import group.goforward.battlbuilder.model.ProductStatus;
import group.goforward.battlbuilder.model.ProductVisibility;
import group.goforward.battlbuilder.web.dto.admin.AdminProductSearchRequest;
import org.springframework.data.jpa.domain.Specification;
import jakarta.persistence.criteria.Predicate;
import java.util.ArrayList;
import java.util.List;
public final class ProductSpecifications {
private ProductSpecifications() {}
public static Specification<Product> adminSearch(AdminProductSearchRequest req) {
return (root, query, cb) -> {
List<Predicate> predicates = new ArrayList<>();
// Always exclude soft-deleted
predicates.add(cb.isNull(root.get("deletedAt")));
if (req == null) {
return cb.and(predicates.toArray(new Predicate[0]));
}
if (hasText(req.getPlatform())) {
predicates.add(cb.equal(root.get("platform"), req.getPlatform()));
}
if (hasText(req.getPartRole())) {
// If you normalize partRole elsewhere, keep it consistent here.
predicates.add(cb.equal(cb.lower(root.get("partRole")), req.getPartRole().toLowerCase()));
}
if (req.getImportStatus() != null) {
predicates.add(cb.equal(root.get("importStatus"), req.getImportStatus()));
}
if (req.getVisibility() != null) {
predicates.add(cb.equal(root.get("visibility"), req.getVisibility()));
}
if (req.getStatus() != null) {
predicates.add(cb.equal(root.get("status"), req.getStatus()));
}
if (req.getBuilderEligible() != null) {
predicates.add(cb.equal(root.get("builderEligible"), req.getBuilderEligible()));
}
if (req.getAdminLocked() != null) {
predicates.add(cb.equal(root.get("adminLocked"), req.getAdminLocked()));
}
if (hasText(req.getQ())) {
String like = "%" + req.getQ().trim().toLowerCase() + "%";
Predicate name = cb.like(cb.lower(root.get("name")), like);
Predicate slug = cb.like(cb.lower(root.get("slug")), like);
// mpn/upc are nullable
Predicate mpn = cb.like(cb.lower(cb.coalesce(root.get("mpn"), "")), like);
Predicate upc = cb.like(cb.lower(cb.coalesce(root.get("upc"), "")), like);
predicates.add(cb.or(name, slug, mpn, upc));
}
return cb.and(predicates.toArray(new Predicate[0]));
};
}
// --- reusable small specs (optional, but nice to have) ---
public static Specification<Product> notDeleted() {
return (root, query, cb) -> cb.isNull(root.get("deletedAt"));
}
public static Specification<Product> platform(String platform) {
return (root, query, cb) -> cb.equal(root.get("platform"), platform);
}
public static Specification<Product> partRoleEquals(String partRole) {
return (root, query, cb) -> cb.equal(cb.lower(root.get("partRole")), partRole.toLowerCase());
}
public static Specification<Product> importStatus(ImportStatus status) {
return (root, query, cb) -> cb.equal(root.get("importStatus"), status);
}
public static Specification<Product> visibility(ProductVisibility visibility) {
return (root, query, cb) -> cb.equal(root.get("visibility"), visibility);
}
public static Specification<Product> status(ProductStatus status) {
return (root, query, cb) -> cb.equal(root.get("status"), status);
}
public static Specification<Product> builderEligible(Boolean builderEligible) {
return (root, query, cb) -> cb.equal(root.get("builderEligible"), builderEligible);
}
public static Specification<Product> adminLocked(Boolean adminLocked) {
return (root, query, cb) -> cb.equal(root.get("adminLocked"), adminLocked);
}
private static boolean hasText(String s) {
return s != null && !s.trim().isEmpty();
}
}

View File

@@ -0,0 +1,45 @@
package group.goforward.battlbuilder.web.admin;
import group.goforward.battlbuilder.services.admin.AdminProductService;
import group.goforward.battlbuilder.web.dto.admin.AdminProductSearchRequest;
import group.goforward.battlbuilder.web.dto.admin.ProductBulkUpdateRequest;
import group.goforward.battlbuilder.web.dto.admin.ProductAdminRowDto;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
@RestController
@RequestMapping("/api/v1/admin/products")
public class AdminProductController {
private final AdminProductService adminProductService;
public AdminProductController(AdminProductService adminProductService) {
this.adminProductService = adminProductService;
}
/**
* Admin product list (paged + filterable)
*/
@GetMapping
public Page<ProductAdminRowDto> search(
AdminProductSearchRequest request,
Pageable pageable
) {
return adminProductService.search(request, pageable);
}
/**
* Bulk admin actions (disable, hide, lock, etc.)
*/
@PatchMapping("/bulk")
public Map<String, Object> bulkUpdate(
@RequestBody ProductBulkUpdateRequest request
) {
int updated = adminProductService.bulkUpdate(request);
return Map.of("updatedCount", updated);
}
}

View File

@@ -0,0 +1,49 @@
package group.goforward.battlbuilder.web.dto.admin;
import group.goforward.battlbuilder.model.ImportStatus;
import group.goforward.battlbuilder.model.ProductStatus;
import group.goforward.battlbuilder.model.ProductVisibility;
/**
* Bound from query params on:
* GET /api/v1/admin/products?platform=AR-15&q=mini&visibility=PUBLIC...
*/
public class AdminProductSearchRequest {
private String q;
private String platform;
private String partRole;
private ImportStatus importStatus;
private ProductVisibility visibility;
private ProductStatus status;
private Boolean builderEligible;
private Boolean adminLocked;
// --- getters/setters ---
public String getQ() { return q; }
public void setQ(String q) { this.q = q; }
public String getPlatform() { return platform; }
public void setPlatform(String platform) { this.platform = platform; }
public String getPartRole() { return partRole; }
public void setPartRole(String partRole) { this.partRole = partRole; }
public ImportStatus getImportStatus() { return importStatus; }
public void setImportStatus(ImportStatus importStatus) { this.importStatus = importStatus; }
public ProductVisibility getVisibility() { return visibility; }
public void setVisibility(ProductVisibility visibility) { this.visibility = visibility; }
public ProductStatus getStatus() { return status; }
public void setStatus(ProductStatus status) { this.status = status; }
public Boolean getBuilderEligible() { return builderEligible; }
public void setBuilderEligible(Boolean builderEligible) { this.builderEligible = builderEligible; }
public Boolean getAdminLocked() { return adminLocked; }
public void setAdminLocked(Boolean adminLocked) { this.adminLocked = adminLocked; }
}

View File

@@ -0,0 +1,203 @@
package group.goforward.battlbuilder.web.dto.admin;
import group.goforward.battlbuilder.model.ImportStatus;
import group.goforward.battlbuilder.model.Product;
import group.goforward.battlbuilder.model.ProductStatus;
import group.goforward.battlbuilder.model.ProductVisibility;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.UUID;
/**
* Lightweight row for the admin products table.
* Avoids fetching offers unless you explicitly want it.
*/
public class ProductAdminRowDto {
private Integer id;
private UUID uuid;
private String name;
private String slug;
private String platform;
private String partRole;
private String brandName;
private ImportStatus importStatus;
private ProductVisibility visibility;
private ProductStatus status;
private Boolean builderEligible;
private Boolean adminLocked;
private String adminNote;
private String mainImageUrl;
private Instant createdAt;
private Instant updatedAt;
public static ProductAdminRowDto fromEntity(Product p) {
ProductAdminRowDto dto = new ProductAdminRowDto();
dto.id = p.getId();
dto.uuid = p.getUuid();
dto.name = p.getName();
dto.slug = p.getSlug();
dto.platform = p.getPlatform();
dto.partRole = p.getPartRole();
dto.brandName = (p.getBrand() != null) ? p.getBrand().getName() : null;
dto.importStatus = p.getImportStatus();
dto.visibility = p.getVisibility();
dto.status = p.getStatus();
dto.builderEligible = p.getBuilderEligible();
dto.adminLocked = p.getAdminLocked();
dto.adminNote = p.getAdminNote();
dto.mainImageUrl = p.getMainImageUrl();
dto.createdAt = p.getCreatedAt();
dto.updatedAt = p.getUpdatedAt();
return dto;
}
// --- getters/setters ---
public Integer getId() {
return id;
}
public void setId(Integer id) {
this.id = id;
}
public UUID getUuid() {
return uuid;
}
public void setUuid(UUID uuid) {
this.uuid = uuid;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getSlug() {
return slug;
}
public void setSlug(String slug) {
this.slug = slug;
}
public String getPlatform() {
return platform;
}
public void setPlatform(String platform) {
this.platform = platform;
}
public String getPartRole() {
return partRole;
}
public void setPartRole(String partRole) {
this.partRole = partRole;
}
public String getBrandName() {
return brandName;
}
public void setBrandName(String brandName) {
this.brandName = brandName;
}
public ImportStatus getImportStatus() {
return importStatus;
}
public void setImportStatus(ImportStatus importStatus) {
this.importStatus = importStatus;
}
public ProductVisibility getVisibility() {
return visibility;
}
public void setVisibility(ProductVisibility visibility) {
this.visibility = visibility;
}
public ProductStatus getStatus() {
return status;
}
public void setStatus(ProductStatus status) {
this.status = status;
}
public Boolean getBuilderEligible() {
return builderEligible;
}
public void setBuilderEligible(Boolean builderEligible) {
this.builderEligible = builderEligible;
}
public Boolean getAdminLocked() {
return adminLocked;
}
public void setAdminLocked(Boolean adminLocked) {
this.adminLocked = adminLocked;
}
public String getAdminNote() {
return adminNote;
}
public void setAdminNote(String adminNote) {
this.adminNote = adminNote;
}
public String getMainImageUrl() {
return mainImageUrl;
}
public void setMainImageUrl(String mainImageUrl) {
this.mainImageUrl = mainImageUrl;
}
public Instant getCreatedAt() {
return createdAt;
}
public void setCreatedAt(Instant createdAt) {
this.createdAt = createdAt;
}
public Instant getUpdatedAt() {
return updatedAt;
}
public void setUpdatedAt(Instant updatedAt) {
this.updatedAt = updatedAt;
}
}

View File

@@ -0,0 +1,39 @@
package group.goforward.battlbuilder.web.dto.admin;
import group.goforward.battlbuilder.model.ProductStatus;
import group.goforward.battlbuilder.model.ProductVisibility;
import java.util.Set;
public class ProductBulkUpdateRequest {
private Set<Integer> productIds;
private ProductVisibility visibility;
private ProductStatus status;
private Boolean builderEligible;
private Boolean adminLocked;
private String adminNote;
// --- getters/setters ---
public Set<Integer> getProductIds() { return productIds; }
public void setProductIds(Set<Integer> productIds) { this.productIds = productIds; }
public ProductVisibility getVisibility() { return visibility; }
public void setVisibility(ProductVisibility visibility) { this.visibility = visibility; }
public ProductStatus getStatus() { return status; }
public void setStatus(ProductStatus status) { this.status = status; }
public Boolean getBuilderEligible() { return builderEligible; }
public void setBuilderEligible(Boolean builderEligible) { this.builderEligible = builderEligible; }
public Boolean getAdminLocked() { return adminLocked; }
public void setAdminLocked(Boolean adminLocked) { this.adminLocked = adminLocked; }
public String getAdminNote() { return adminNote; }
public void setAdminNote(String adminNote) { this.adminNote = adminNote; }
}