diff --git a/src/main/java/group/goforward/ballistic/configuration/SecurityConfig.java b/src/main/java/group/goforward/ballistic/configuration/SecurityConfig.java index 3a0a3c4..2b3f98d 100644 --- a/src/main/java/group/goforward/ballistic/configuration/SecurityConfig.java +++ b/src/main/java/group/goforward/ballistic/configuration/SecurityConfig.java @@ -24,14 +24,19 @@ public class SecurityConfig { sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS) ) .authorizeHttpRequests(auth -> auth + // Auth endpoints always open .requestMatchers("/api/auth/**").permitAll() + // Swagger / docs .requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll() + // Health .requestMatchers("/actuator/health", "/actuator/info").permitAll() + // Public product endpoints .requestMatchers("/api/products/gunbuilder/**").permitAll() + // Everything else (for now) also open – we can tighten later .anyRequest().permitAll() ); diff --git a/src/main/java/group/goforward/ballistic/controllers/admin/AdminCategoryMappingController.java b/src/main/java/group/goforward/ballistic/controllers/admin/AdminCategoryMappingController.java index e7553cd..6a9e5e1 100644 --- a/src/main/java/group/goforward/ballistic/controllers/admin/AdminCategoryMappingController.java +++ b/src/main/java/group/goforward/ballistic/controllers/admin/AdminCategoryMappingController.java @@ -1,13 +1,15 @@ 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.repos.CategoryMappingRepository; +import group.goforward.ballistic.repos.MerchantRepository; import group.goforward.ballistic.repos.PartCategoryRepository; -import group.goforward.ballistic.web.dto.admin.PartRoleMappingDto; -import group.goforward.ballistic.web.dto.admin.PartRoleMappingRequest; +import group.goforward.ballistic.web.dto.admin.MerchantCategoryMappingDto; +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.ResponseEntity; import org.springframework.web.bind.annotation.*; import org.springframework.web.server.ResponseStatusException; @@ -15,111 +17,101 @@ import java.util.List; @RestController @RequestMapping("/api/admin/category-mappings") -@CrossOrigin +@CrossOrigin // you can tighten origins later public class AdminCategoryMappingController { private final CategoryMappingRepository categoryMappingRepository; + private final MerchantRepository merchantRepository; private final PartCategoryRepository partCategoryRepository; public AdminCategoryMappingController( CategoryMappingRepository categoryMappingRepository, + MerchantRepository merchantRepository, PartCategoryRepository partCategoryRepository ) { this.categoryMappingRepository = categoryMappingRepository; + this.merchantRepository = merchantRepository; this.partCategoryRepository = partCategoryRepository; } - // GET /api/admin/category-mappings?platform=AR-15 - @GetMapping - public List list( - @RequestParam(name = "platform", defaultValue = "AR-15") String platform - ) { - List mappings = - categoryMappingRepository.findBySourceTypeAndPlatformOrderById("PART_ROLE", platform); - - return mappings.stream() - .map(this::toDto) + /** + * Merchants that have at least one category_mappings row. + * Used for the "All Merchants" dropdown in the UI. + */ + @GetMapping("/merchants") + public List listMerchantsWithMappings() { + List merchants = categoryMappingRepository.findDistinctMerchantsWithMappings(); + return merchants.stream() + .map(m -> new SimpleMerchantDto(m.getId(), m.getName())) .toList(); } - // POST /api/admin/category-mappings - @PostMapping - public ResponseEntity create( - @RequestBody PartRoleMappingRequest request + /** + * List mappings for a specific merchant, or all mappings if no merchantId is provided. + * GET /api/admin/category-mappings?merchantId=1 + */ + @GetMapping + public List listByMerchant( + @RequestParam(name = "merchantId", required = false) Integer merchantId ) { - if (request.platform() == null || request.partRole() == null || request.categorySlug() == null) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "platform, partRole, and categorySlug are required"); + List mappings; + + 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()) - .orElseThrow(() -> new ResponseStatusException( - HttpStatus.BAD_REQUEST, - "Unknown category slug: " + request.categorySlug() - )); - - AffiliateCategoryMap mapping = new AffiliateCategoryMap(); - mapping.setSourceType("PART_ROLE"); - mapping.setSourceValue(request.partRole()); - mapping.setPlatform(request.platform()); - mapping.setPartCategory(category); - mapping.setNotes(request.notes()); - - AffiliateCategoryMap saved = categoryMappingRepository.save(mapping); - - return ResponseEntity.status(HttpStatus.CREATED).body(toDto(saved)); + return mappings.stream() + .map(cm -> new MerchantCategoryMappingDto( + cm.getId(), + cm.getMerchant().getId(), + cm.getMerchant().getName(), + cm.getRawCategoryPath(), + cm.getPartCategory() != null ? cm.getPartCategory().getId() : null, + cm.getPartCategory() != null ? cm.getPartCategory().getName() : null + )) + .toList(); } - // PUT /api/admin/category-mappings/{id} - @PutMapping("/{id}") - public PartRoleMappingDto update( + /** + * Update a single mapping's part_category. + * POST /api/admin/category-mappings/{id} + * Body: { "partCategoryId": 24 } + */ + @PostMapping("/{id}") + public MerchantCategoryMappingDto updateMapping( @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")); - if (request.platform() != null) { - mapping.setPlatform(request.platform()); - } - if (request.partRole() != null) { - 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()); + PartCategory partCategory = null; + if (request.partCategoryId() != null) { + partCategory = partCategoryRepository.findById(request.partCategoryId()) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST, "Part category not found")); } - AffiliateCategoryMap saved = categoryMappingRepository.save(mapping); - return toDto(saved); - } + mapping.setPartCategory(partCategory); + mapping = categoryMappingRepository.save(mapping); - // DELETE /api/admin/category-mappings/{id} - @DeleteMapping("/{id}") - @ResponseStatus(HttpStatus.NO_CONTENT) - public void delete(@PathVariable Integer id) { - if (!categoryMappingRepository.existsById(id)) { - throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Mapping not found"); - } - 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() + return new MerchantCategoryMappingDto( + mapping.getId(), + mapping.getMerchant().getId(), + mapping.getMerchant().getName(), + mapping.getRawCategoryPath(), + mapping.getPartCategory() != null ? mapping.getPartCategory().getId() : null, + mapping.getPartCategory() != null ? mapping.getPartCategory().getName() : null ); } + @PutMapping("/{id}") + public MerchantCategoryMappingDto updateMappingPut( + @PathVariable Integer id, + @RequestBody UpdateMerchantCategoryMappingRequest request + ) { + // just delegate so POST & PUT behave the same + return updateMapping(id, request); + } } \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/repos/CategoryMappingRepository.java b/src/main/java/group/goforward/ballistic/repos/CategoryMappingRepository.java index d483e05..b6132c0 100644 --- a/src/main/java/group/goforward/ballistic/repos/CategoryMappingRepository.java +++ b/src/main/java/group/goforward/ballistic/repos/CategoryMappingRepository.java @@ -1,29 +1,22 @@ 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.Query; import java.util.List; -import java.util.Optional; -public interface CategoryMappingRepository extends JpaRepository { +public interface CategoryMappingRepository extends JpaRepository { - // Match by source_type + source_value + platform (case-insensitive) - Optional findBySourceTypeAndSourceValueAndPlatformIgnoreCase( - String sourceType, - String sourceValue, - String platform - ); + // All mappings for a merchant, ordered nicely + List findByMerchantIdOrderByRawCategoryPathAsc(Integer merchantId); - // Fallback: match by source_type + source_value when platform is null/ignored - Optional findBySourceTypeAndSourceValueIgnoreCase( - String sourceType, - String sourceValue - ); - - // Used by AdminCategoryMappingController: list mappings for a given source_type + platform - List findBySourceTypeAndPlatformOrderById( - String sourceType, - String platform - ); + // Merchants that actually have mappings (for the dropdown) + @Query(""" + select distinct cm.merchant + from CategoryMapping cm + order by cm.merchant.name asc + """) + List findDistinctMerchantsWithMappings(); } \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/services/PartCategoryResolverService.java b/src/main/java/group/goforward/ballistic/services/PartCategoryResolverService.java index 252ad6f..31dd63a 100644 --- a/src/main/java/group/goforward/ballistic/services/PartCategoryResolverService.java +++ b/src/main/java/group/goforward/ballistic/services/PartCategoryResolverService.java @@ -1,8 +1,7 @@ package group.goforward.ballistic.services; -import group.goforward.ballistic.model.AffiliateCategoryMap; import group.goforward.ballistic.model.PartCategory; -import group.goforward.ballistic.repos.CategoryMappingRepository; +import group.goforward.ballistic.repos.PartCategoryRepository; import org.springframework.stereotype.Service; import java.util.Optional; @@ -10,29 +9,33 @@ import java.util.Optional; @Service public class PartCategoryResolverService { - private final CategoryMappingRepository categoryMappingRepository; + private final PartCategoryRepository partCategoryRepository; - public PartCategoryResolverService(CategoryMappingRepository categoryMappingRepository) { - this.categoryMappingRepository = categoryMappingRepository; + public PartCategoryResolverService(PartCategoryRepository partCategoryRepository) { + this.partCategoryRepository = partCategoryRepository; } /** - * Resolve a part category from a platform + partRole (what gunbuilder cares about). - * Returns Optional.empty() if we have no mapping yet. + * 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. */ public Optional resolveForPlatformAndPartRole(String platform, String partRole) { - // sourceType is a convention – you can also enum this - String sourceType = "PART_ROLE"; + if (partRole == null || partRole.isBlank()) { + return Optional.empty(); + } - // First try with platform - return categoryMappingRepository - .findBySourceTypeAndSourceValueAndPlatformIgnoreCase(sourceType, partRole, platform) - .map(AffiliateCategoryMap::getPartCategory) - // if that fails, fall back to ANY platform - .or(() -> - categoryMappingRepository - .findBySourceTypeAndSourceValueIgnoreCase(sourceType, partRole) - .map(AffiliateCategoryMap::getPartCategory) - ); + String normalizedSlug = partRole + .trim() + .toLowerCase() + .replace(" ", "-"); + + return partCategoryRepository.findBySlug(normalizedSlug); } } \ No newline at end of file