diff --git a/src/main/java/group/goforward/battlbuilder/controllers/api/v1/CatalogController.java b/src/main/java/group/goforward/battlbuilder/controllers/api/v1/CatalogController.java index 1f4960a..74784ca 100644 --- a/src/main/java/group/goforward/battlbuilder/controllers/api/v1/CatalogController.java +++ b/src/main/java/group/goforward/battlbuilder/controllers/api/v1/CatalogController.java @@ -29,11 +29,12 @@ public class CatalogController { @RequestParam(required = false) String partRole, @RequestParam(required = false) List partRoles, @RequestParam(required = false, name = "brand") List brands, + @RequestParam(required = false, name = "caliber") List 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) { diff --git a/src/main/java/group/goforward/battlbuilder/enrichment/service/CaliberEnrichmentService.java b/src/main/java/group/goforward/battlbuilder/enrichment/service/CaliberEnrichmentService.java index 55340bd..0c3beb8 100644 --- a/src/main/java/group/goforward/battlbuilder/enrichment/service/CaliberEnrichmentService.java +++ b/src/main/java/group/goforward/battlbuilder/enrichment/service/CaliberEnrichmentService.java @@ -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 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(); diff --git a/src/main/java/group/goforward/battlbuilder/model/Product.java b/src/main/java/group/goforward/battlbuilder/model/Product.java index a45695a..96714ab 100644 --- a/src/main/java/group/goforward/battlbuilder/model/Product.java +++ b/src/main/java/group/goforward/battlbuilder/model/Product.java @@ -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; diff --git a/src/main/java/group/goforward/battlbuilder/model/enums/PartRoleSource.java b/src/main/java/group/goforward/battlbuilder/model/enums/PartRoleSource.java index fe64a34..ccfe0f0 100644 --- a/src/main/java/group/goforward/battlbuilder/model/enums/PartRoleSource.java +++ b/src/main/java/group/goforward/battlbuilder/model/enums/PartRoleSource.java @@ -8,6 +8,7 @@ public enum PartRoleSource { MERCHANT_MAP, OVERRIDE, RULES, - UNKNOWN + UNKNOWN, + ADMIN } \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/repos/catalog/spec/CatalogProductSpecifications.java b/src/main/java/group/goforward/battlbuilder/repos/catalog/spec/CatalogProductSpecifications.java index 006c941..96fc5aa 100644 --- a/src/main/java/group/goforward/battlbuilder/repos/catalog/spec/CatalogProductSpecifications.java +++ b/src/main/java/group/goforward/battlbuilder/repos/catalog/spec/CatalogProductSpecifications.java @@ -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 caliberIn(List 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 queryLike(String q) { final String like = "%" + q.toLowerCase().trim() + "%"; return (root, query, cb) -> { diff --git a/src/main/java/group/goforward/battlbuilder/security/Authz.java b/src/main/java/group/goforward/battlbuilder/security/Authz.java new file mode 100644 index 0000000..e91a943 --- /dev/null +++ b/src/main/java/group/goforward/battlbuilder/security/Authz.java @@ -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 + } +} \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/services/CatalogQueryService.java b/src/main/java/group/goforward/battlbuilder/services/CatalogQueryService.java index 3aaa42f..eb33bed 100644 --- a/src/main/java/group/goforward/battlbuilder/services/CatalogQueryService.java +++ b/src/main/java/group/goforward/battlbuilder/services/CatalogQueryService.java @@ -15,6 +15,7 @@ public interface CatalogQueryService { String partRole, List partRoles, List brands, + List calibers, String q, Pageable pageable ); diff --git a/src/main/java/group/goforward/battlbuilder/services/admin/AdminProductService.java b/src/main/java/group/goforward/battlbuilder/services/admin/AdminProductService.java index 8158670..0299ba5 100644 --- a/src/main/java/group/goforward/battlbuilder/services/admin/AdminProductService.java +++ b/src/main/java/group/goforward/battlbuilder/services/admin/AdminProductService.java @@ -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 search(AdminProductSearchRequest request, Pageable pageable); +import java.util.List; +public interface AdminProductService { + + Page search(AdminProductSearchRequest request, Pageable pageable); BulkUpdateResult bulkUpdate(ProductBulkUpdateRequest request); + List distinctRoles(AdminProductSearchRequest request); + List distinctCalibers(AdminProductSearchRequest request); } \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/services/admin/impl/AdminProductServiceImpl.java b/src/main/java/group/goforward/battlbuilder/services/admin/impl/AdminProductServiceImpl.java index 97a7eb5..ed9ca06 100644 --- a/src/main/java/group/goforward/battlbuilder/services/admin/impl/AdminProductServiceImpl.java +++ b/src/main/java/group/goforward/battlbuilder/services/admin/impl/AdminProductServiceImpl.java @@ -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 distinctRoles(AdminProductSearchRequest request) { + var spec = ProductSpecifications.adminSearch(request); + + CriteriaBuilder cb = em.getCriteriaBuilder(); + CriteriaQuery cq = cb.createQuery(String.class); + Root 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 q = em.createQuery(cq); + List raw = q.getResultList(); + + // Defensive: normalize + de-dupe in case DB has whitespace variants + List 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 distinctCalibers(AdminProductSearchRequest request) { + var spec = ProductSpecifications.adminSearch(request); + + CriteriaBuilder cb = em.getCriteriaBuilder(); + CriteriaQuery cq = cb.createQuery(String.class); + Root 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 raw = em.createQuery(cq).getResultList(); + + // normalize + de-dupe + List 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; + } } \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/services/impl/CatalogQueryServiceImpl.java b/src/main/java/group/goforward/battlbuilder/services/impl/CatalogQueryServiceImpl.java index 33f6b48..54d4c15 100644 --- a/src/main/java/group/goforward/battlbuilder/services/impl/CatalogQueryServiceImpl.java +++ b/src/main/java/group/goforward/battlbuilder/services/impl/CatalogQueryServiceImpl.java @@ -42,6 +42,7 @@ public class CatalogQueryServiceImpl implements CatalogQueryService { String partRole, List partRoles, List brands, + List 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)); } diff --git a/src/main/java/group/goforward/battlbuilder/web/admin/AdminProductController.java b/src/main/java/group/goforward/battlbuilder/web/admin/AdminProductController.java index 568b888..84e4b3c 100644 --- a/src/main/java/group/goforward/battlbuilder/web/admin/AdminProductController.java +++ b/src/main/java/group/goforward/battlbuilder/web/admin/AdminProductController.java @@ -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 search( - AdminProductSearchRequest request, - Pageable pageable - ) { + public Page search(AdminProductSearchRequest request, Pageable pageable) { return adminProductService.search(request, pageable); } - /** - * Bulk admin actions (disable, hide, lock, etc.) - */ + @GetMapping("/roles") + public List roles(AdminProductSearchRequest request) { + return adminProductService.distinctRoles(request); + } + + @GetMapping("/calibers") + public List calibers(AdminProductSearchRequest request) { + return adminProductService.distinctCalibers(request); + } + @PatchMapping("/bulk") public Map 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) + ); + } } \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/web/dto/admin/ProductAdminRowDto.java b/src/main/java/group/goforward/battlbuilder/web/dto/admin/ProductAdminRowDto.java index e0c0290..cdba784 100644 --- a/src/main/java/group/goforward/battlbuilder/web/dto/admin/ProductAdminRowDto.java +++ b/src/main/java/group/goforward/battlbuilder/web/dto/admin/ProductAdminRowDto.java @@ -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; } diff --git a/src/main/java/group/goforward/battlbuilder/web/dto/admin/ProductBulkUpdateRequest.java b/src/main/java/group/goforward/battlbuilder/web/dto/admin/ProductBulkUpdateRequest.java index ed416c0..00aa4f5 100644 --- a/src/main/java/group/goforward/battlbuilder/web/dto/admin/ProductBulkUpdateRequest.java +++ b/src/main/java/group/goforward/battlbuilder/web/dto/admin/ProductBulkUpdateRequest.java @@ -10,13 +10,23 @@ public class ProductBulkUpdateRequest { private Set 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; + } } \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/web/dto/admin/ProductFacetsDto.java b/src/main/java/group/goforward/battlbuilder/web/dto/admin/ProductFacetsDto.java new file mode 100644 index 0000000..0a456e7 --- /dev/null +++ b/src/main/java/group/goforward/battlbuilder/web/dto/admin/ProductFacetsDto.java @@ -0,0 +1,8 @@ +package group.goforward.battlbuilder.web.dto.admin; + +import java.util.List; + +public record ProductFacetsDto( + List roles, + List calibers +) {} \ No newline at end of file