Merge remote-tracking branch 'origin/dont-lose-this' into sean

This commit is contained in:
2025-12-04 15:15:04 -05:00
10 changed files with 339 additions and 120 deletions

View File

@@ -24,14 +24,19 @@ public class SecurityConfig {
sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS) sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
) )
.authorizeHttpRequests(auth -> auth .authorizeHttpRequests(auth -> auth
// Auth endpoints always open // Auth endpoints always open
.requestMatchers("/api/auth/**").permitAll() .requestMatchers("/api/auth/**").permitAll()
// Swagger / docs // Swagger / docs
.requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll() .requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll()
// Health // Health
.requestMatchers("/actuator/health", "/actuator/info").permitAll() .requestMatchers("/actuator/health", "/actuator/info").permitAll()
// Public product endpoints // Public product endpoints
.requestMatchers("/api/products/gunbuilder/**").permitAll() .requestMatchers("/api/products/gunbuilder/**").permitAll()
// Everything else (for now) also open we can tighten later // Everything else (for now) also open we can tighten later
.anyRequest().permitAll() .anyRequest().permitAll()
); );

View File

@@ -1,13 +1,15 @@
package group.goforward.ballistic.controllers.admin; package group.goforward.ballistic.controllers.admin;
import group.goforward.ballistic.model.AffiliateCategoryMap; import group.goforward.ballistic.model.CategoryMapping;
import group.goforward.ballistic.model.Merchant;
import group.goforward.ballistic.model.PartCategory; import group.goforward.ballistic.model.PartCategory;
import group.goforward.ballistic.repos.CategoryMappingRepository; import group.goforward.ballistic.repos.CategoryMappingRepository;
import group.goforward.ballistic.repos.MerchantRepository;
import group.goforward.ballistic.repos.PartCategoryRepository; import group.goforward.ballistic.repos.PartCategoryRepository;
import group.goforward.ballistic.web.dto.admin.PartRoleMappingDto; import group.goforward.ballistic.web.dto.admin.MerchantCategoryMappingDto;
import group.goforward.ballistic.web.dto.admin.PartRoleMappingRequest; import group.goforward.ballistic.web.dto.admin.SimpleMerchantDto;
import group.goforward.ballistic.web.dto.admin.UpdateMerchantCategoryMappingRequest;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException; import org.springframework.web.server.ResponseStatusException;
@@ -15,111 +17,101 @@ import java.util.List;
@RestController @RestController
@RequestMapping("/api/admin/category-mappings") @RequestMapping("/api/admin/category-mappings")
@CrossOrigin @CrossOrigin // you can tighten origins later
public class AdminCategoryMappingController { public class AdminCategoryMappingController {
private final CategoryMappingRepository categoryMappingRepository; private final CategoryMappingRepository categoryMappingRepository;
private final MerchantRepository merchantRepository;
private final PartCategoryRepository partCategoryRepository; private final PartCategoryRepository partCategoryRepository;
public AdminCategoryMappingController( public AdminCategoryMappingController(
CategoryMappingRepository categoryMappingRepository, CategoryMappingRepository categoryMappingRepository,
MerchantRepository merchantRepository,
PartCategoryRepository partCategoryRepository PartCategoryRepository partCategoryRepository
) { ) {
this.categoryMappingRepository = categoryMappingRepository; this.categoryMappingRepository = categoryMappingRepository;
this.merchantRepository = merchantRepository;
this.partCategoryRepository = partCategoryRepository; this.partCategoryRepository = partCategoryRepository;
} }
// GET /api/admin/category-mappings?platform=AR-15 /**
@GetMapping * Merchants that have at least one category_mappings row.
public List<PartRoleMappingDto> list( * Used for the "All Merchants" dropdown in the UI.
@RequestParam(name = "platform", defaultValue = "AR-15") String platform */
) { @GetMapping("/merchants")
List<AffiliateCategoryMap> mappings = public List<SimpleMerchantDto> listMerchantsWithMappings() {
categoryMappingRepository.findBySourceTypeAndPlatformOrderById("PART_ROLE", platform); List<Merchant> merchants = categoryMappingRepository.findDistinctMerchantsWithMappings();
return merchants.stream()
return mappings.stream() .map(m -> new SimpleMerchantDto(m.getId(), m.getName()))
.map(this::toDto)
.toList(); .toList();
} }
// POST /api/admin/category-mappings /**
@PostMapping * List mappings for a specific merchant, or all mappings if no merchantId is provided.
public ResponseEntity<PartRoleMappingDto> create( * GET /api/admin/category-mappings?merchantId=1
@RequestBody PartRoleMappingRequest request */
@GetMapping
public List<MerchantCategoryMappingDto> listByMerchant(
@RequestParam(name = "merchantId", required = false) Integer merchantId
) { ) {
if (request.platform() == null || request.partRole() == null || request.categorySlug() == null) { List<CategoryMapping> mappings;
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "platform, partRole, and categorySlug are required");
if (merchantId != null) {
mappings = categoryMappingRepository.findByMerchantIdOrderByRawCategoryPathAsc(merchantId);
} else {
// fall back to all mappings; you can add a more specific repository method later if desired
mappings = categoryMappingRepository.findAll();
} }
PartCategory category = partCategoryRepository.findBySlug(request.categorySlug()) return mappings.stream()
.orElseThrow(() -> new ResponseStatusException( .map(cm -> new MerchantCategoryMappingDto(
HttpStatus.BAD_REQUEST, cm.getId(),
"Unknown category slug: " + request.categorySlug() cm.getMerchant().getId(),
)); cm.getMerchant().getName(),
cm.getRawCategoryPath(),
AffiliateCategoryMap mapping = new AffiliateCategoryMap(); cm.getPartCategory() != null ? cm.getPartCategory().getId() : null,
mapping.setSourceType("PART_ROLE"); cm.getPartCategory() != null ? cm.getPartCategory().getName() : null
mapping.setSourceValue(request.partRole()); ))
mapping.setPlatform(request.platform()); .toList();
mapping.setPartCategory(category);
mapping.setNotes(request.notes());
AffiliateCategoryMap saved = categoryMappingRepository.save(mapping);
return ResponseEntity.status(HttpStatus.CREATED).body(toDto(saved));
} }
// PUT /api/admin/category-mappings/{id} /**
@PutMapping("/{id}") * Update a single mapping's part_category.
public PartRoleMappingDto update( * POST /api/admin/category-mappings/{id}
* Body: { "partCategoryId": 24 }
*/
@PostMapping("/{id}")
public MerchantCategoryMappingDto updateMapping(
@PathVariable Integer id, @PathVariable Integer id,
@RequestBody PartRoleMappingRequest request @RequestBody UpdateMerchantCategoryMappingRequest request
) { ) {
AffiliateCategoryMap mapping = categoryMappingRepository.findById(id) CategoryMapping mapping = categoryMappingRepository.findById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Mapping not found")); .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Mapping not found"));
if (request.platform() != null) { PartCategory partCategory = null;
mapping.setPlatform(request.platform()); if (request.partCategoryId() != null) {
} partCategory = partCategoryRepository.findById(request.partCategoryId())
if (request.partRole() != null) { .orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST, "Part category not found"));
mapping.setSourceValue(request.partRole());
}
if (request.categorySlug() != null) {
PartCategory category = partCategoryRepository.findBySlug(request.categorySlug())
.orElseThrow(() -> new ResponseStatusException(
HttpStatus.BAD_REQUEST,
"Unknown category slug: " + request.categorySlug()
));
mapping.setPartCategory(category);
}
if (request.notes() != null) {
mapping.setNotes(request.notes());
} }
AffiliateCategoryMap saved = categoryMappingRepository.save(mapping); mapping.setPartCategory(partCategory);
return toDto(saved); mapping = categoryMappingRepository.save(mapping);
}
// DELETE /api/admin/category-mappings/{id} return new MerchantCategoryMappingDto(
@DeleteMapping("/{id}") mapping.getId(),
@ResponseStatus(HttpStatus.NO_CONTENT) mapping.getMerchant().getId(),
public void delete(@PathVariable Integer id) { mapping.getMerchant().getName(),
if (!categoryMappingRepository.existsById(id)) { mapping.getRawCategoryPath(),
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Mapping not found"); mapping.getPartCategory() != null ? mapping.getPartCategory().getId() : null,
} mapping.getPartCategory() != null ? mapping.getPartCategory().getName() : null
categoryMappingRepository.deleteById(id);
}
private PartRoleMappingDto toDto(AffiliateCategoryMap map) {
PartCategory cat = map.getPartCategory();
return new PartRoleMappingDto(
map.getId(),
map.getPlatform(),
map.getSourceValue(), // partRole
cat != null ? cat.getSlug() : null, // categorySlug
cat != null ? cat.getGroupName() : null,
map.getNotes()
); );
} }
@PutMapping("/{id}")
public MerchantCategoryMappingDto updateMappingPut(
@PathVariable Integer id,
@RequestBody UpdateMerchantCategoryMappingRequest request
) {
// just delegate so POST & PUT behave the same
return updateMapping(id, request);
}
} }

View File

@@ -0,0 +1,123 @@
package group.goforward.ballistic.controllers.admin;
import group.goforward.ballistic.model.PartCategory;
import group.goforward.ballistic.model.PartRoleMapping;
import group.goforward.ballistic.repos.PartCategoryRepository;
import group.goforward.ballistic.repos.PartRoleMappingRepository;
import group.goforward.ballistic.web.dto.admin.AdminPartRoleMappingDto;
import group.goforward.ballistic.web.dto.admin.CreatePartRoleMappingRequest;
import group.goforward.ballistic.web.dto.admin.UpdatePartRoleMappingRequest;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
@RestController
@RequestMapping("/api/admin/part-role-mappings")
@CrossOrigin
public class AdminPartRoleMappingController {
private final PartRoleMappingRepository partRoleMappingRepository;
private final PartCategoryRepository partCategoryRepository;
public AdminPartRoleMappingController(
PartRoleMappingRepository partRoleMappingRepository,
PartCategoryRepository partCategoryRepository
) {
this.partRoleMappingRepository = partRoleMappingRepository;
this.partCategoryRepository = partCategoryRepository;
}
// GET /api/admin/part-role-mappings?platform=AR-15
@GetMapping
public List<AdminPartRoleMappingDto> list(
@RequestParam(name = "platform", required = false) String platform
) {
List<PartRoleMapping> mappings;
if (platform != null && !platform.isBlank()) {
mappings = partRoleMappingRepository.findByPlatformOrderByPartRoleAsc(platform);
} else {
mappings = partRoleMappingRepository.findAll();
}
return mappings.stream()
.map(this::toDto)
.toList();
}
// POST /api/admin/part-role-mappings
@PostMapping
public AdminPartRoleMappingDto create(
@RequestBody CreatePartRoleMappingRequest request
) {
PartCategory category = partCategoryRepository.findBySlug(request.categorySlug())
.orElseThrow(() -> new ResponseStatusException(
HttpStatus.BAD_REQUEST,
"PartCategory not found for slug: " + request.categorySlug()
));
PartRoleMapping mapping = new PartRoleMapping();
mapping.setPlatform(request.platform());
mapping.setPartRole(request.partRole());
mapping.setPartCategory(category);
mapping.setNotes(request.notes());
mapping = partRoleMappingRepository.save(mapping);
return toDto(mapping);
}
// PUT /api/admin/part-role-mappings/{id}
@PutMapping("/{id}")
public AdminPartRoleMappingDto update(
@PathVariable Integer id,
@RequestBody UpdatePartRoleMappingRequest request
) {
PartRoleMapping mapping = partRoleMappingRepository.findById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Mapping not found"));
if (request.platform() != null) {
mapping.setPlatform(request.platform());
}
if (request.partRole() != null) {
mapping.setPartRole(request.partRole());
}
if (request.categorySlug() != null) {
PartCategory category = partCategoryRepository.findBySlug(request.categorySlug())
.orElseThrow(() -> new ResponseStatusException(
HttpStatus.BAD_REQUEST,
"PartCategory not found for slug: " + request.categorySlug()
));
mapping.setPartCategory(category);
}
if (request.notes() != null) {
mapping.setNotes(request.notes());
}
mapping = partRoleMappingRepository.save(mapping);
return toDto(mapping);
}
// DELETE /api/admin/part-role-mappings/{id}
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void delete(@PathVariable Integer id) {
if (!partRoleMappingRepository.existsById(id)) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Mapping not found");
}
partRoleMappingRepository.deleteById(id);
}
private AdminPartRoleMappingDto toDto(PartRoleMapping mapping) {
PartCategory cat = mapping.getPartCategory();
return new AdminPartRoleMappingDto(
mapping.getId(),
mapping.getPlatform(),
mapping.getPartRole(),
cat != null ? cat.getSlug() : null,
cat != null ? cat.getGroupName() : null,
mapping.getNotes()
);
}
}

View File

@@ -0,0 +1,65 @@
package group.goforward.ballistic.model;
import jakarta.persistence.*;
@Entity
@Table(name = "part_role_mappings")
public class PartRoleMapping {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;
@Column(nullable = false)
private String platform; // e.g. "AR-15"
@Column(name = "part_role", nullable = false)
private String partRole; // e.g. "UPPER", "BARREL", etc.
@ManyToOne(optional = false)
@JoinColumn(name = "part_category_id")
private PartCategory partCategory;
@Column(columnDefinition = "text")
private String notes;
public Integer getId() {
return id;
}
public void setId(Integer id) {
this.id = id;
}
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 PartCategory getPartCategory() {
return partCategory;
}
public void setPartCategory(PartCategory partCategory) {
this.partCategory = partCategory;
}
public String getNotes() {
return notes;
}
public void setNotes(String notes) {
this.notes = notes;
}
}

View File

@@ -1,29 +1,22 @@
package group.goforward.ballistic.repos; package group.goforward.ballistic.repos;
import group.goforward.ballistic.model.AffiliateCategoryMap; import group.goforward.ballistic.model.CategoryMapping;
import group.goforward.ballistic.model.Merchant;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import java.util.List; import java.util.List;
import java.util.Optional;
public interface CategoryMappingRepository extends JpaRepository<AffiliateCategoryMap, Integer> { public interface CategoryMappingRepository extends JpaRepository<CategoryMapping, Integer> {
// Match by source_type + source_value + platform (case-insensitive) // All mappings for a merchant, ordered nicely
Optional<AffiliateCategoryMap> findBySourceTypeAndSourceValueAndPlatformIgnoreCase( List<CategoryMapping> findByMerchantIdOrderByRawCategoryPathAsc(Integer merchantId);
String sourceType,
String sourceValue,
String platform
);
// Fallback: match by source_type + source_value when platform is null/ignored // Merchants that actually have mappings (for the dropdown)
Optional<AffiliateCategoryMap> findBySourceTypeAndSourceValueIgnoreCase( @Query("""
String sourceType, select distinct cm.merchant
String sourceValue from CategoryMapping cm
); order by cm.merchant.name asc
""")
// Used by AdminCategoryMappingController: list mappings for a given source_type + platform List<Merchant> findDistinctMerchantsWithMappings();
List<AffiliateCategoryMap> findBySourceTypeAndPlatformOrderById(
String sourceType,
String platform
);
} }

View File

@@ -0,0 +1,12 @@
package group.goforward.ballistic.repos;
import group.goforward.ballistic.model.PartRoleMapping;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface PartRoleMappingRepository extends JpaRepository<PartRoleMapping, Integer> {
// List mappings for a platform, ordered nicely for the UI
List<PartRoleMapping> findByPlatformOrderByPartRoleAsc(String platform);
}

View File

@@ -1,8 +1,7 @@
package group.goforward.ballistic.services; package group.goforward.ballistic.services;
import group.goforward.ballistic.model.AffiliateCategoryMap;
import group.goforward.ballistic.model.PartCategory; import group.goforward.ballistic.model.PartCategory;
import group.goforward.ballistic.repos.CategoryMappingRepository; import group.goforward.ballistic.repos.PartCategoryRepository;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.util.Optional; import java.util.Optional;
@@ -10,29 +9,33 @@ import java.util.Optional;
@Service @Service
public class PartCategoryResolverService { public class PartCategoryResolverService {
private final CategoryMappingRepository categoryMappingRepository; private final PartCategoryRepository partCategoryRepository;
public PartCategoryResolverService(CategoryMappingRepository categoryMappingRepository) { public PartCategoryResolverService(PartCategoryRepository partCategoryRepository) {
this.categoryMappingRepository = categoryMappingRepository; this.partCategoryRepository = partCategoryRepository;
} }
/** /**
* Resolve a part category from a platform + partRole (what gunbuilder cares about). * Resolve the canonical PartCategory for a given platform + partRole.
* Returns Optional.empty() if we have no mapping yet. *
* 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.
*/ */
public Optional<PartCategory> resolveForPlatformAndPartRole(String platform, String partRole) { public Optional<PartCategory> resolveForPlatformAndPartRole(String platform, String partRole) {
// sourceType is a convention you can also enum this if (partRole == null || partRole.isBlank()) {
String sourceType = "PART_ROLE"; return Optional.empty();
}
// First try with platform String normalizedSlug = partRole
return categoryMappingRepository .trim()
.findBySourceTypeAndSourceValueAndPlatformIgnoreCase(sourceType, partRole, platform) .toLowerCase()
.map(AffiliateCategoryMap::getPartCategory) .replace(" ", "-");
// if that fails, fall back to ANY platform
.or(() -> return partCategoryRepository.findBySlug(normalizedSlug);
categoryMappingRepository
.findBySourceTypeAndSourceValueIgnoreCase(sourceType, partRole)
.map(AffiliateCategoryMap::getPartCategory)
);
} }
} }

View File

@@ -0,0 +1,10 @@
package group.goforward.ballistic.web.dto.admin;
public record AdminPartRoleMappingDto(
Integer id,
String platform,
String partRole,
String categorySlug,
String groupName,
String notes
) {}

View File

@@ -0,0 +1,8 @@
package group.goforward.ballistic.web.dto.admin;
public record CreatePartRoleMappingRequest(
String platform,
String partRole,
String categorySlug,
String notes
) {}

View File

@@ -0,0 +1,8 @@
package group.goforward.ballistic.web.dto.admin;
public record UpdatePartRoleMappingRequest(
String platform,
String partRole,
String categorySlug,
String notes
) {}