support for filtering and updating caliber

This commit is contained in:
2026-01-08 15:26:22 -05:00
parent 50992a03c4
commit d7408d41a3
14 changed files with 334 additions and 43 deletions

View File

@@ -29,11 +29,12 @@ public class CatalogController {
@RequestParam(required = false) String partRole,
@RequestParam(required = false) List<String> partRoles,
@RequestParam(required = false, name = "brand") List<String> brands,
@RequestParam(required = false, name = "caliber") List<String> calibers,
@RequestParam(required = false) String q,
Pageable pageable
) {
Pageable safe = sanitizeCatalogPageable(pageable);
return catalogQueryService.getOptions(platform, partRole, partRoles, brands, q, safe);
return catalogQueryService.getOptions(platform, partRole, partRoles, brands, calibers, q, safe);
}
private Pageable sanitizeCatalogPageable(Pageable pageable) {

View File

@@ -37,20 +37,22 @@ public class CaliberEnrichmentService {
*/
@Transactional
public RunResult runRules(int limit) {
// Adjust Product entity package if needed:
// IMPORTANT: Product must be a mapped @Entity named "Product"
List<Object[]> rows = em.createQuery("""
select p.id, p.name, p.description
from Product p
where p.deletedAt is null
and not exists (
select 1 from ProductEnrichment e
where e.productId = p.id
and e.enrichmentType = group.goforward.battlbuilder.enrichment.EnrichmentType.CALIBER
and e.status in ('PENDING_REVIEW','APPROVED')
)
order by p.id desc
""", Object[].class)
select p.id, p.name, p.description
from Product p
where p.deletedAt is null
and not exists (
select 1 from ProductEnrichment e
where e.productId = p.id
and e.enrichmentType = :etype
and e.status in (:s1, :s2)
)
order by p.id desc
""", Object[].class)
.setParameter("etype", EnrichmentType.CALIBER)
.setParameter("s1", EnrichmentStatus.PENDING_REVIEW)
.setParameter("s2", EnrichmentStatus.APPROVED)
.setMaxResults(limit)
.getResultList();

View File

@@ -77,6 +77,9 @@ public class Product {
@Column(name = "classified_at")
private Instant classifiedAt;
@Column(name = "caliber_locked", nullable = false)
private Boolean caliberLocked = false;
@Column(name = "part_role_locked", nullable = false)
private Boolean partRoleLocked = false;
@@ -227,6 +230,9 @@ public class Product {
this.platformLocked = platformLocked;
}
public Boolean getCaliberLocked() { return caliberLocked; }
public void setCaliberLocked(Boolean caliberLocked) { this.caliberLocked = caliberLocked; }
public String getRawCategoryKey() { return rawCategoryKey; }
public void setRawCategoryKey(String rawCategoryKey) {
this.rawCategoryKey = rawCategoryKey;

View File

@@ -8,6 +8,7 @@ public enum PartRoleSource {
MERCHANT_MAP,
OVERRIDE,
RULES,
UNKNOWN
UNKNOWN,
ADMIN
}

View File

@@ -8,6 +8,7 @@ import org.springframework.data.jpa.domain.Specification;
import jakarta.persistence.criteria.JoinType;
import java.util.List;
import java.util.Objects;
public class CatalogProductSpecifications {
@@ -39,6 +40,24 @@ public class CatalogProductSpecifications {
};
}
public static Specification<Product> caliberIn(List<String> calibers) {
return (root, query, cb) -> {
if (calibers == null || calibers.isEmpty()) return cb.conjunction();
// normalize + drop blanks
var cleaned = calibers.stream()
.filter(Objects::nonNull)
.map(String::trim)
.filter(s -> !s.isBlank())
.distinct()
.toList();
if (cleaned.isEmpty()) return cb.conjunction();
return root.get("caliber").in(cleaned);
};
}
public static Specification<Product> queryLike(String q) {
final String like = "%" + q.toLowerCase().trim() + "%";
return (root, query, cb) -> {

View File

@@ -0,0 +1,19 @@
package group.goforward.battlbuilder.security;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import java.util.UUID;
@Component
public class Authz {
public UUID requireUserUuid() {
Authentication a = SecurityContextHolder.getContext().getAuthentication();
if (a == null || !a.isAuthenticated() || a.getPrincipal() == null) {
throw new RuntimeException("Unauthorized");
}
return UUID.fromString(a.getPrincipal().toString()); // principal is UUID string in your filter
}
}

View File

@@ -15,6 +15,7 @@ public interface CatalogQueryService {
String partRole,
List<String> partRoles,
List<String> brands,
List<String> calibers,
String q,
Pageable pageable
);

View File

@@ -8,8 +8,12 @@ import group.goforward.battlbuilder.web.dto.admin.BulkUpdateResult;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
public interface AdminProductService {
Page<ProductAdminRowDto> search(AdminProductSearchRequest request, Pageable pageable);
import java.util.List;
public interface AdminProductService {
Page<ProductAdminRowDto> search(AdminProductSearchRequest request, Pageable pageable);
BulkUpdateResult bulkUpdate(ProductBulkUpdateRequest request);
List<String> distinctRoles(AdminProductSearchRequest request);
List<String> distinctCalibers(AdminProductSearchRequest request);
}

View File

@@ -1,6 +1,7 @@
package group.goforward.battlbuilder.services.admin.impl;
import group.goforward.battlbuilder.model.Product;
import group.goforward.battlbuilder.model.enums.PartRoleSource;
import group.goforward.battlbuilder.repos.ProductRepository;
import group.goforward.battlbuilder.services.admin.AdminProductService;
import group.goforward.battlbuilder.domain.specs.ProductSpecifications;
@@ -9,12 +10,27 @@ import group.goforward.battlbuilder.web.dto.admin.BulkUpdateResult;
import group.goforward.battlbuilder.web.dto.admin.ProductAdminRowDto;
import group.goforward.battlbuilder.web.dto.admin.ProductBulkUpdateRequest;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import jakarta.persistence.TypedQuery;
import jakarta.persistence.criteria.CriteriaBuilder;
import jakarta.persistence.criteria.CriteriaQuery;
import jakarta.persistence.criteria.Predicate;
import jakarta.persistence.criteria.Root;
import java.util.ArrayList;
import java.util.List;
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;
import java.time.Instant;
@Service
@Transactional
public class AdminProductServiceImpl implements AdminProductService {
@@ -70,10 +86,52 @@ public class AdminProductServiceImpl implements AdminProductService {
changed = true;
}
// --- CALIBER update with lock semantics ---
if (request.getCaliber() != null) {
boolean calLocked = Boolean.TRUE.equals(p.getCaliberLocked());
boolean forceCal = Boolean.TRUE.equals(request.getForceCaliberUpdate());
if (calLocked && !forceCal) {
skippedLocked++;
} else {
String next = request.getCaliber().trim();
if (next.isEmpty()) next = null;
if ((next == null && p.getCaliber() != null) || (next != null && !next.equals(p.getCaliber()))) {
p.setCaliber(next);
// optional group
if (request.getCaliberGroup() != null) {
String g = request.getCaliberGroup().trim();
p.setCaliberGroup(g.isEmpty() ? null : g);
}
// audit fields (keep consistent with role)
p.setClassifierVersion("admin-ui");
p.setClassifiedAt(Instant.now());
p.setClassificationReason(
request.getClassificationReason() != null
? request.getClassificationReason()
: "Admin bulk override"
);
changed = true;
}
}
}
// --- apply caliberLocked toggle (even if caliber isn't being changed) ---
if (request.getCaliberLocked() != null) {
if (!request.getCaliberLocked().equals(p.getCaliberLocked())) {
p.setCaliberLocked(request.getCaliberLocked());
changed = true;
}
}
// --- platform update with lock semantics ---
if (request.getPlatform() != null) {
boolean isLocked = Boolean.TRUE.equals(p.getPlatformLocked());
boolean override = Boolean.TRUE.equals(request.getPlatformLocked()); // request says "I'm allowed to touch locked ones"
boolean override = Boolean.TRUE.equals(request.getPlatformLocked()); // caller says "ok to touch locked ones"
if (isLocked && !override) {
skippedLocked++;
@@ -93,21 +151,122 @@ public class AdminProductServiceImpl implements AdminProductService {
}
}
// --- ROLE update with lock semantics ---
if (request.getPartRole() != null) {
boolean roleLocked = Boolean.TRUE.equals(p.getPartRoleLocked());
boolean force = Boolean.TRUE.equals(request.getForceRoleUpdate());
if (roleLocked && !force) {
skippedLocked++;
} else {
if (!request.getPartRole().equals(p.getPartRole())) {
p.setPartRole(request.getPartRole());
p.setPartRoleSource(PartRoleSource.ADMIN);
p.setClassifiedAt(Instant.now());
p.setClassifierVersion("admin-ui");
p.setClassificationReason(
request.getClassificationReason() != null
? request.getClassificationReason()
: "Admin bulk override"
);
changed = true;
}
}
}
// --- apply partRoleLocked toggle (even if partRole isn't being changed) ---
if (request.getPartRoleLocked() != null) {
if (!request.getPartRoleLocked().equals(p.getPartRoleLocked())) {
p.setPartRoleLocked(request.getPartRoleLocked());
changed = true;
}
}
if (changed) updated++;
}
productRepository.saveAll(products);
productRepository.flush(); // ✅ ensures UPDATEs are executed now
productRepository.flush();
var check = productRepository.findAllById(request.getProductIds());
for (var p : check) {
System.out.println(
"AFTER FLUSH id=" + p.getId()
+ " platform=" + p.getPlatform()
+ " platformLocked=" + p.getPlatformLocked()
);
System.out.println("AFTER id=" + p.getId()
+ " role=" + p.getPartRole()
+ " roleLocked=" + p.getPartRoleLocked()
+ " source=" + p.getPartRoleSource()
+ " reason=" + p.getClassificationReason());
}
return new BulkUpdateResult(updated, skippedLocked);
}
@PersistenceContext
private EntityManager em;
@Override
@Transactional(readOnly = true)
public List<String> distinctRoles(AdminProductSearchRequest request) {
var spec = ProductSpecifications.adminSearch(request);
CriteriaBuilder cb = em.getCriteriaBuilder();
CriteriaQuery<String> cq = cb.createQuery(String.class);
Root<Product> root = cq.from(Product.class);
// Base predicate from your existing admin search spec
Predicate base = spec != null ? spec.toPredicate(root, cq, cb) : cb.conjunction();
// Only real values
Predicate notNull = cb.isNotNull(root.get("partRole"));
Predicate notBlank = cb.notEqual(cb.trim(root.get("partRole")), "");
cq.select(root.get("partRole"))
.distinct(true)
.where(cb.and(base, notNull, notBlank))
.orderBy(cb.asc(root.get("partRole")));
TypedQuery<String> q = em.createQuery(cq);
List<String> raw = q.getResultList();
// Defensive: normalize + de-dupe in case DB has whitespace variants
List<String> out = new ArrayList<>();
for (String r : raw) {
if (r == null) continue;
String t = r.trim();
if (t.isEmpty()) continue;
if (!out.contains(t)) out.add(t);
}
return out;
}
@Override
@Transactional(readOnly = true)
public List<String> distinctCalibers(AdminProductSearchRequest request) {
var spec = ProductSpecifications.adminSearch(request);
CriteriaBuilder cb = em.getCriteriaBuilder();
CriteriaQuery<String> cq = cb.createQuery(String.class);
Root<Product> root = cq.from(Product.class);
Predicate base = spec != null ? spec.toPredicate(root, cq, cb) : cb.conjunction();
Predicate notNull = cb.isNotNull(root.get("caliber"));
Predicate notBlank = cb.notEqual(cb.trim(root.get("caliber")), "");
cq.select(root.get("caliber"))
.distinct(true)
.where(cb.and(base, notNull, notBlank))
.orderBy(cb.asc(root.get("caliber")));
List<String> raw = em.createQuery(cq).getResultList();
// normalize + de-dupe
List<String> out = new ArrayList<>();
for (String c : raw) {
if (c == null) continue;
String t = c.trim();
if (t.isEmpty()) continue;
if (!out.contains(t)) out.add(t);
}
return out;
}
}

View File

@@ -42,6 +42,7 @@ public class CatalogQueryServiceImpl implements CatalogQueryService {
String partRole,
List<String> partRoles,
List<String> brands,
List<String> calibers,
String q,
Pageable pageable
) {
@@ -68,6 +69,10 @@ public class CatalogQueryServiceImpl implements CatalogQueryService {
spec = spec.and(CatalogProductSpecifications.brandNameIn(brands));
}
if (calibers != null && !calibers.isEmpty()) {
spec = spec.and(CatalogProductSpecifications.caliberIn(calibers));
}
if (q != null && !q.isBlank()) {
spec = spec.and(CatalogProductSpecifications.queryLike(q));
}

View File

@@ -5,11 +5,13 @@ import group.goforward.battlbuilder.web.dto.admin.AdminProductSearchRequest;
import group.goforward.battlbuilder.web.dto.admin.ProductBulkUpdateRequest;
import group.goforward.battlbuilder.web.dto.admin.BulkUpdateResult;
import group.goforward.battlbuilder.web.dto.admin.ProductAdminRowDto;
import group.goforward.battlbuilder.web.dto.admin.ProductFacetsDto;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
@RestController
@@ -22,20 +24,21 @@ public class AdminProductController {
this.adminProductService = adminProductService;
}
/**
* Admin product list (paged + filterable)
*/
@GetMapping
public Page<ProductAdminRowDto> search(
AdminProductSearchRequest request,
Pageable pageable
) {
public Page<ProductAdminRowDto> search(AdminProductSearchRequest request, Pageable pageable) {
return adminProductService.search(request, pageable);
}
/**
* Bulk admin actions (disable, hide, lock, etc.)
*/
@GetMapping("/roles")
public List<String> roles(AdminProductSearchRequest request) {
return adminProductService.distinctRoles(request);
}
@GetMapping("/calibers")
public List<String> calibers(AdminProductSearchRequest request) {
return adminProductService.distinctCalibers(request);
}
@PatchMapping("/bulk")
public Map<String, Object> bulkUpdate(@RequestBody ProductBulkUpdateRequest request) {
BulkUpdateResult result = adminProductService.bulkUpdate(request);
@@ -44,4 +47,12 @@ public class AdminProductController {
"skippedLockedCount", result.skippedLockedCount()
);
}
@PostMapping("/facets")
public ProductFacetsDto facets(@RequestBody AdminProductSearchRequest request) {
return new ProductFacetsDto(
adminProductService.distinctRoles(request),
adminProductService.distinctCalibers(request)
);
}
}

View File

@@ -23,6 +23,10 @@ public class ProductAdminRowDto {
private String platform;
private String partRole;
private String caliber;
private String caliberGroup;
private Boolean caliberLocked;
private String brandName;
private ImportStatus importStatus;
@@ -45,25 +49,21 @@ public class 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.caliber = p.getCaliber();
dto.caliberGroup = p.getCaliberGroup();
dto.caliberLocked = p.getCaliberLocked();
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();
@@ -120,6 +120,15 @@ public class ProductAdminRowDto {
this.partRole = partRole;
}
public String getCaliber() { return caliber; }
public void setCaliber(String caliber) { this.caliber = caliber; }
public String getCaliberGroup() { return caliberGroup; }
public void setCaliberGroup(String caliberGroup) { this.caliberGroup = caliberGroup; }
public Boolean getCaliberLocked() { return caliberLocked; }
public void setCaliberLocked(Boolean caliberLocked) { this.caliberLocked = caliberLocked; }
public String getBrandName() {
return brandName;
}

View File

@@ -10,13 +10,23 @@ public class ProductBulkUpdateRequest {
private Set<Integer> productIds;
private String platform;
private Boolean platformLocked;
private String caliber;
private Boolean caliberLocked;
private String caliberGroup;
private Boolean forceCaliberUpdate;
private ProductVisibility visibility;
private ProductStatus status;
private String partRole;
private Boolean partRoleLocked;
private String classificationReason;
private Boolean builderEligible;
private Boolean adminLocked;
private String adminNote;
private Boolean forceRoleUpdate;
public Boolean getForceRoleUpdate() { return forceRoleUpdate; }
public void setForceRoleUpdate(Boolean forceRoleUpdate) { this.forceRoleUpdate = forceRoleUpdate; }
// --- getters/setters ---
@@ -43,4 +53,40 @@ public class ProductBulkUpdateRequest {
public Boolean getPlatformLocked() { return platformLocked; }
public void setPlatformLocked(Boolean platformLocked) { this.platformLocked = platformLocked; }
public String getCaliber() { return caliber; }
public void setCaliber(String caliber) { this.caliber = caliber; }
public String getCaliberGroup() { return caliberGroup; }
public void setCaliberGroup(String caliberGroup) { this.caliberGroup = caliberGroup; }
public Boolean getCaliberLocked() { return caliberLocked; }
public void setCaliberLocked(Boolean caliberLocked) { this.caliberLocked = caliberLocked; }
public Boolean getForceCaliberUpdate() { return forceCaliberUpdate; }
public void setForceCaliberUpdate(Boolean forceCaliberUpdate) { this.forceCaliberUpdate = forceCaliberUpdate; }
public String getPartRole() {
return partRole;
}
public void setPartRole(String partRole) {
this.partRole = partRole;
}
public Boolean getPartRoleLocked() {
return partRoleLocked;
}
public void setPartRoleLocked(Boolean partRoleLocked) {
this.partRoleLocked = partRoleLocked;
}
public String getClassificationReason() {
return classificationReason;
}
public void setClassificationReason(String classificationReason) {
this.classificationReason = classificationReason;
}
}

View File

@@ -0,0 +1,8 @@
package group.goforward.battlbuilder.web.dto.admin;
import java.util.List;
public record ProductFacetsDto(
List<String> roles,
List<String> calibers
) {}