running finally..

This commit is contained in:
2025-12-04 14:43:07 -05:00
parent 74a5c42e26
commit 3d1501cc87
4 changed files with 113 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

@@ -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

@@ -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)
);
} }
} }