fixing part_role mapping

This commit is contained in:
2025-12-06 20:06:10 -05:00
parent 12d2ee53b5
commit b185abe28d
13 changed files with 404 additions and 45 deletions

View File

@@ -37,7 +37,8 @@ public class AdminPartRoleMappingController {
List<PartRoleMapping> mappings;
if (platform != null && !platform.isBlank()) {
mappings = partRoleMappingRepository.findByPlatformOrderByPartRoleAsc(platform);
mappings = partRoleMappingRepository
.findByPlatformAndDeletedAtIsNullOrderByPartRoleAsc(platform);
} else {
mappings = partRoleMappingRepository.findAll();
}

View File

@@ -1,6 +1,7 @@
package group.goforward.ballistic.model;
import jakarta.persistence.*;
import java.time.OffsetDateTime;
@Entity
@Table(name = "part_role_mappings")
@@ -14,15 +15,38 @@ public class PartRoleMapping {
private String platform; // e.g. "AR-15"
@Column(name = "part_role", nullable = false)
private String partRole; // e.g. "UPPER", "BARREL", etc.
private String partRole; // e.g. "LOWER_RECEIVER_STRIPPED"
@ManyToOne(optional = false)
@ManyToOne(optional = false, fetch = FetchType.LAZY)
@JoinColumn(name = "part_category_id")
private PartCategory partCategory;
@Column(columnDefinition = "text")
private String notes;
@Column(name = "created_at", updatable = false)
private OffsetDateTime createdAt;
@Column(name = "updated_at")
private OffsetDateTime updatedAt;
@Column(name = "deleted_at")
private OffsetDateTime deletedAt;
@PrePersist
protected void onCreate() {
OffsetDateTime now = OffsetDateTime.now();
this.createdAt = now;
this.updatedAt = now;
}
@PreUpdate
protected void onUpdate() {
this.updatedAt = OffsetDateTime.now();
}
// getters/setters
public Integer getId() {
return id;
}
@@ -62,4 +86,20 @@ public class PartRoleMapping {
public void setNotes(String notes) {
this.notes = notes;
}
public OffsetDateTime getCreatedAt() {
return createdAt;
}
public OffsetDateTime getUpdatedAt() {
return updatedAt;
}
public OffsetDateTime getDeletedAt() {
return deletedAt;
}
public void setDeletedAt(OffsetDateTime deletedAt) {
this.deletedAt = deletedAt;
}
}

View File

@@ -4,9 +4,29 @@ import group.goforward.ballistic.model.PartRoleMapping;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional;
public interface PartRoleMappingRepository extends JpaRepository<PartRoleMapping, Integer> {
// List mappings for a platform, ordered nicely for the UI
List<PartRoleMapping> findByPlatformOrderByPartRoleAsc(String platform);
// For resolver: one mapping per platform + partRole
Optional<PartRoleMapping> findFirstByPlatformAndPartRoleAndDeletedAtIsNull(
String platform,
String partRole
);
// Optional: debug / inspection
List<PartRoleMapping> findAllByPlatformAndPartRoleAndDeletedAtIsNull(
String platform,
String partRole
);
// This is the one PartRoleMappingService needs
List<PartRoleMapping> findByPlatformAndDeletedAtIsNullOrderByPartRoleAsc(
String platform
);
List<PartRoleMapping> findByPlatformAndPartCategory_SlugAndDeletedAtIsNull(
String platform,
String slug
);
}

View File

@@ -6,6 +6,7 @@ import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.Collection;
import java.util.List;
public interface ProductRepository extends JpaRepository<Product, Integer> {
@@ -33,8 +34,8 @@ public interface ProductRepository extends JpaRepository<Product, Integer> {
""")
List<Product> findByPlatformWithBrand(@Param("platform") String platform);
@Query(name="Products.findByPlatformWithBrand")
List<Product> findByPlatformWithBrandNQ(@Param("platform") String platform);
@Query(name = "Products.findByPlatformWithBrand")
List<Product> findByPlatformWithBrandNQ(@Param("platform") String platform);
@Query("""
SELECT p
@@ -50,7 +51,21 @@ List<Product> findByPlatformWithBrandNQ(@Param("platform") String platform);
);
// -------------------------------------------------
// Used by Gunbuilder service (if you wired this)
// Used by /api/gunbuilder/test-products-db
// -------------------------------------------------
@Query("""
SELECT p
FROM Product p
JOIN FETCH p.brand b
WHERE p.platform = :platform
AND p.deletedAt IS NULL
ORDER BY p.id
""")
List<Product> findTop5ByPlatformWithBrand(@Param("platform") String platform);
// -------------------------------------------------
// Used by GunbuilderProductService
// -------------------------------------------------
@Query("""
@@ -59,7 +74,11 @@ List<Product> findByPlatformWithBrandNQ(@Param("platform") String platform);
LEFT JOIN FETCH p.brand b
LEFT JOIN FETCH p.offers o
WHERE p.platform = :platform
AND p.partRole IN :partRoles
AND p.deletedAt IS NULL
""")
List<Product> findSomethingForGunbuilder(@Param("platform") String platform);
List<Product> findForGunbuilderByPlatformAndPartRoles(
@Param("platform") String platform,
@Param("partRoles") Collection<String> partRoles
);
}

View File

@@ -5,38 +5,77 @@ import group.goforward.ballistic.model.Product;
import group.goforward.ballistic.repos.ProductRepository;
import group.goforward.ballistic.web.dto.GunbuilderProductDto;
import org.springframework.stereotype.Service;
import group.goforward.ballistic.model.PartRoleMapping;
import group.goforward.ballistic.repos.PartRoleMappingRepository;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import java.util.List;
@Service
public class GunbuilderProductService {
private final ProductRepository productRepository;
private final PartCategoryResolverService partCategoryResolverService;
private final PartRoleMappingRepository partRoleMappingRepository;
public GunbuilderProductService(
ProductRepository productRepository,
PartCategoryResolverService partCategoryResolverService
PartCategoryResolverService partCategoryResolverService,
PartRoleMappingRepository partRoleMappingRepository
) {
this.productRepository = productRepository;
this.partCategoryResolverService = partCategoryResolverService;
this.partRoleMappingRepository = partRoleMappingRepository;
}
public List<GunbuilderProductDto> listGunbuilderProducts(String platform) {
/**
* Main builder endpoint.
* For now we ONLY support calls that provide partRoles,
* to avoid pulling the entire catalog into memory.
*/
public List<GunbuilderProductDto> listGunbuilderProducts(String platform, List<String> partRoles) {
List<Product> products = productRepository.findSomethingForGunbuilder(platform);
System.out.println(">>> GB: listGunbuilderProducts platform=" + platform
+ ", partRoles=" + partRoles);
if (partRoles == null || partRoles.isEmpty()) {
System.out.println(">>> GB: no partRoles provided, returning empty list");
return List.of();
}
List<Product> products =
productRepository.findForGunbuilderByPlatformAndPartRoles(platform, partRoles);
System.out.println(">>> GB: repo returned " + products.size() + " products");
Map<String, PartCategory> categoryCache = new HashMap<>();
return products.stream()
.map(p -> {
var maybeCategory = partCategoryResolverService
.resolveForPlatformAndPartRole(platform, p.getPartRole());
PartCategory cat = categoryCache.computeIfAbsent(
p.getPartRole(),
role -> partCategoryResolverService
.resolveForPlatformAndPartRole(platform, role)
.orElse(null)
);
if (maybeCategory.isEmpty()) {
// you can also log here
return null;
if (cat == null) {
System.out.println(">>> GB: NO CATEGORY for platform=" + platform
+ ", partRole=" + p.getPartRole()
+ ", productId=" + p.getId());
} else {
System.out.println(">>> GB: CATEGORY for productId=" + p.getId()
+ " -> slug=" + cat.getSlug()
+ ", group=" + cat.getGroupName());
}
PartCategory cat = maybeCategory.get();
// TEMP: do NOT drop products if category is null.
// Just mark them as "unmapped" so we can see them in the JSON.
String categorySlug = (cat != null) ? cat.getSlug() : "unmapped";
String categoryGroup = (cat != null) ? cat.getGroupName() : "Unmapped";
return new GunbuilderProductDto(
p.getId(),
@@ -47,11 +86,54 @@ public class GunbuilderProductService {
p.getBestOfferPrice(),
p.getMainImageUrl(),
p.getBestOfferBuyUrl(),
cat.getSlug(),
cat.getGroupName()
categorySlug,
categoryGroup
);
})
.filter(dto -> dto != null)
.toList();
}
public List<GunbuilderProductDto> listGunbuilderProductsByCategory(
String platform,
String categorySlug
) {
System.out.println(">>> GB: listGunbuilderProductsByCategory platform=" + platform
+ ", categorySlug=" + categorySlug);
if (platform == null || platform.isBlank()
|| categorySlug == null || categorySlug.isBlank()) {
System.out.println(">>> GB: missing platform or categorySlug, returning empty list");
return List.of();
}
List<PartRoleMapping> mappings =
partRoleMappingRepository.findByPlatformAndPartCategory_SlugAndDeletedAtIsNull(
platform,
categorySlug
);
List<String> partRoles = mappings.stream()
.map(PartRoleMapping::getPartRole)
.distinct()
.toList();
System.out.println(">>> GB: resolved " + partRoles.size()
+ " partRoles for categorySlug=" + categorySlug + " -> " + partRoles);
if (partRoles.isEmpty()) {
return List.of();
}
// Reuse the existing method that already does all the DTO + category resolution logic
return listGunbuilderProducts(platform, partRoles);
}
/**
* Tiny helper used ONLY by /test-products-db to prove DB wiring.
*/
public List<Product> getSampleProducts(String platform) {
// You already have this wired via ProductRepository.findTop5ByPlatformWithBrand
// If that method exists, keep using it; if not, you can stub a tiny query here.
return productRepository.findTop5ByPlatformWithBrand(platform);
}
}

View File

@@ -1,7 +1,8 @@
package group.goforward.ballistic.services;
import group.goforward.ballistic.model.PartCategory;
import group.goforward.ballistic.repos.PartCategoryRepository;
import group.goforward.ballistic.model.PartRoleMapping;
import group.goforward.ballistic.repos.PartRoleMappingRepository;
import org.springframework.stereotype.Service;
import java.util.Optional;
@@ -9,33 +10,29 @@ import java.util.Optional;
@Service
public class PartCategoryResolverService {
private final PartCategoryRepository partCategoryRepository;
private final PartRoleMappingRepository partRoleMappingRepository;
public PartCategoryResolverService(PartCategoryRepository partCategoryRepository) {
this.partCategoryRepository = partCategoryRepository;
public PartCategoryResolverService(PartRoleMappingRepository partRoleMappingRepository) {
this.partRoleMappingRepository = partRoleMappingRepository;
}
/**
* Resolve the canonical PartCategory for a given platform + partRole.
*
* For now we keep it simple:
* - We treat partRole as the slug (e.g. "barrel", "upper", "trigger").
* - Normalize to lower-kebab (spaces -> dashes, lowercased).
* - Look up by slug in part_categories.
*
* Later, if we want per-merchant / per-platform overrides using category_mappings,
* we can extend this method without changing callers.
* Resolve a PartCategory for a given platform + partRole.
* Example: ("AR-15", "LOWER_RECEIVER_STRIPPED") -> category "lower"
*/
public Optional<PartCategory> resolveForPlatformAndPartRole(String platform, String partRole) {
if (partRole == null || partRole.isBlank()) {
if (platform == null || partRole == null) {
return Optional.empty();
}
String normalizedSlug = partRole
.trim()
.toLowerCase()
.replace(" ", "-");
// Keep things case-sensitive since your DB values are already uppercase.
Optional<PartRoleMapping> mappingOpt =
partRoleMappingRepository.findFirstByPlatformAndPartRoleAndDeletedAtIsNull(
platform,
partRole
);
return partCategoryRepository.findBySlug(normalizedSlug);
return mappingOpt.map(PartRoleMapping::getPartCategory);
}
}

View File

@@ -0,0 +1,35 @@
package group.goforward.ballistic.services;
import group.goforward.ballistic.repos.PartRoleMappingRepository;
import group.goforward.ballistic.web.dto.admin.PartRoleMappingDto;
import group.goforward.ballistic.web.dto.PartRoleToCategoryDto;
import group.goforward.ballistic.web.mapper.PartRoleMappingMapper;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class PartRoleMappingService {
private final PartRoleMappingRepository repository;
public PartRoleMappingService(PartRoleMappingRepository repository) {
this.repository = repository;
}
public List<PartRoleMappingDto> getMappingsForPlatform(String platform) {
return repository
.findByPlatformAndDeletedAtIsNullOrderByPartRoleAsc(platform)
.stream()
.map(PartRoleMappingMapper::toDto)
.toList();
}
public List<PartRoleToCategoryDto> getRoleToCategoryMap(String platform) {
return repository
.findByPlatformAndDeletedAtIsNullOrderByPartRoleAsc(platform)
.stream()
.map(PartRoleMappingMapper::toRoleMapDto)
.toList();
}
}

View File

@@ -0,0 +1,84 @@
package group.goforward.ballistic.web;
import group.goforward.ballistic.services.GunbuilderProductService;
import group.goforward.ballistic.web.dto.GunbuilderProductDto;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.io.Serializable;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/gunbuilder")
public class GunbuilderProductController {
private final GunbuilderProductService gunbuilderProductService;
public GunbuilderProductController(GunbuilderProductService gunbuilderProductService) {
this.gunbuilderProductService = gunbuilderProductService;
System.out.println(">>> GunbuilderProductController initialized");
}
// 🔹 sanity check: is this controller even mapped?
@GetMapping("/ping")
public Map<String, String> ping() {
return Map.of("status", "ok", "source", "GunbuilderProductController");
}
// 🔹 super-dumb test: no DB, no service, just prove the route works
@GetMapping("/test-products")
public Map<String, Object> testProducts(@RequestParam String platform) {
System.out.println(">>> /api/gunbuilder/test-products hit for platform=" + platform);
Map<String, Object> m = new java.util.HashMap<>();
m.put("platform", platform);
m.put("note", "test endpoint only");
m.put("ok", true);
return m;
}
/**
* List products for the builder UI.
*
* Examples:
* GET /api/gunbuilder/products?platform=AR-15
* GET /api/gunbuilder/products?platform=AR-15&partRoles=LOWER_RECEIVER_STRIPPED&partRoles=LOWER_RECEIVER_COMPLETE
*/
@GetMapping("/products")
public List<GunbuilderProductDto> listProducts(
@RequestParam String platform,
@RequestParam(required = false) List<String> partRoles
) {
return gunbuilderProductService.listGunbuilderProducts(platform, partRoles);
}
@GetMapping("/products/by-category")
public List<GunbuilderProductDto> listProductsByCategory(
@RequestParam String platform,
@RequestParam String categorySlug
) {
return gunbuilderProductService.listGunbuilderProductsByCategory(platform, categorySlug);
}
// 🔹 DB test: hit repo via service and return a tiny view of products
@GetMapping("/test-products-db")
public List<Map<String, Object>> testProductsDb(@RequestParam String platform) {
System.out.println(">>> /api/gunbuilder/test-products-db hit for platform=" + platform);
var products = gunbuilderProductService.getSampleProducts(platform);
return products.stream()
.map(p -> {
Map<String, Object> m = new java.util.HashMap<>();
m.put("id", p.getId());
m.put("name", p.getName());
m.put("brand", p.getBrand() != null ? p.getBrand().getName() : null);
m.put("partRole", p.getPartRole());
return m;
})
.toList();
}
}

View File

@@ -0,0 +1,31 @@
package group.goforward.ballistic.web;
import group.goforward.ballistic.services.PartRoleMappingService;
import group.goforward.ballistic.web.dto.admin.PartRoleMappingDto;
import group.goforward.ballistic.web.dto.PartRoleToCategoryDto;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/part-role-mappings")
public class PartRoleMappingController {
private final PartRoleMappingService service;
public PartRoleMappingController(PartRoleMappingService service) {
this.service = service;
}
// Full view for admin UI
@GetMapping("/{platform}")
public List<PartRoleMappingDto> getMappings(@PathVariable String platform) {
return service.getMappingsForPlatform(platform);
}
// Thin mapping for the builder
@GetMapping("/{platform}/map")
public List<PartRoleToCategoryDto> getRoleMap(@PathVariable String platform) {
return service.getRoleToCategoryMap(platform);
}
}

View File

@@ -0,0 +1,7 @@
package group.goforward.ballistic.web.dto;
public record PartRoleToCategoryDto(
String platform,
String partRole,
String categorySlug // e.g. "lower", "barrel", "optic"
) {}

View File

@@ -4,7 +4,8 @@ public record PartRoleMappingDto(
Integer id,
String platform,
String partRole,
String categorySlug,
String groupName,
Integer partCategoryId,
String partCategorySlug,
String partCategoryName,
String notes
) {}

View File

@@ -0,0 +1,37 @@
package group.goforward.ballistic.web.mapper;
import group.goforward.ballistic.model.PartCategory;
import group.goforward.ballistic.model.PartRoleMapping;
import group.goforward.ballistic.web.dto.admin.PartRoleMappingDto;
import group.goforward.ballistic.web.dto.PartRoleToCategoryDto;
public final class PartRoleMappingMapper {
private PartRoleMappingMapper() {
// utility class
}
public static PartRoleMappingDto toDto(PartRoleMapping entity) {
PartCategory cat = entity.getPartCategory();
return new PartRoleMappingDto(
entity.getId(),
entity.getPlatform(),
entity.getPartRole(),
cat != null ? cat.getId() : null,
cat != null ? cat.getSlug() : null,
cat != null ? cat.getName() : null,
entity.getNotes()
);
}
public static PartRoleToCategoryDto toRoleMapDto(PartRoleMapping entity) {
PartCategory cat = entity.getPartCategory();
return new PartRoleToCategoryDto(
entity.getPlatform(),
entity.getPartRole(),
cat != null ? cat.getSlug() : null
);
}
}

View File

@@ -7,8 +7,13 @@ spring.datasource.driver-class-name=org.postgresql.Driver
# Hibernate properties
#spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect
#spring.jpa.show-sql=true
#spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect
security.jwt.secret=ballistic-test-secret-key-1234567890-ABCDEFGHIJKLNMOPQRST
security.jwt.access-token-minutes=2880
security.jwt.access-token-minutes=2880
# Temp disabling logging to find what I fucked up
spring.jpa.show-sql=false
logging.level.org.hibernate.SQL=warn
logging.level.org.hibernate.type.descriptor.sql.BasicBinder=warn