Compare commits

...

7 Commits

18 changed files with 553 additions and 127 deletions

31
.idea/dataSources.xml generated Normal file
View File

@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
<data-source source="LOCAL" name="r710" uuid="e6a29f5c-71d9-45f0-931b-554bcf8a94ba">
<driver-ref>postgresql</driver-ref>
<synchronize>true</synchronize>
<jdbc-driver>org.postgresql.Driver</jdbc-driver>
<jdbc-url>jdbc:postgresql://r710.dev.gofwd.group:5433/postgres</jdbc-url>
<jdbc-additional-properties>
<property name="com.intellij.clouds.kubernetes.db.host.port" />
<property name="com.intellij.clouds.kubernetes.db.enabled" value="false" />
<property name="com.intellij.clouds.kubernetes.db.container.port" />
</jdbc-additional-properties>
<working-dir>$ProjectFileDir$</working-dir>
</data-source>
<data-source source="LOCAL" name="ss_builder@r710.gofwd.group" uuid="e0fa459b-2f6c-45f1-9c41-66423c870df9">
<driver-ref>postgresql</driver-ref>
<synchronize>true</synchronize>
<imported>true</imported>
<remarks>$PROJECT_DIR$/src/main/resources/application.properties</remarks>
<jdbc-driver>org.postgresql.Driver</jdbc-driver>
<jdbc-url>jdbc:postgresql://r710.gofwd.group:5433/ss_builder</jdbc-url>
<jdbc-additional-properties>
<property name="com.intellij.clouds.kubernetes.db.host.port" />
<property name="com.intellij.clouds.kubernetes.db.enabled" value="false" />
<property name="com.intellij.clouds.kubernetes.db.container.port" />
</jdbc-additional-properties>
<working-dir>$ProjectFileDir$</working-dir>
</data-source>
</component>
</project>

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

@@ -0,0 +1,34 @@
package group.goforward.ballistic.controllers;
import group.goforward.ballistic.repos.PartCategoryRepository;
import group.goforward.ballistic.web.dto.admin.PartCategoryDto;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/categories")
@CrossOrigin // you can tighten origins later
public class CategoryController {
private final PartCategoryRepository partCategories;
public CategoryController(PartCategoryRepository partCategories) {
this.partCategories = partCategories;
}
@GetMapping
public List<PartCategoryDto> list() {
return partCategories.findAllByOrderByGroupNameAscSortOrderAscNameAsc()
.stream()
.map(pc -> new PartCategoryDto(
pc.getId(),
pc.getSlug(),
pc.getName(),
pc.getDescription(),
pc.getGroupName(),
pc.getSortOrder()
))
.toList();
}
}

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,98 @@
// src/main/java/group/goforward/ballistic/model/CategoryMapping.java
package group.goforward.ballistic.model;
import jakarta.persistence.*;
import java.time.OffsetDateTime;
@Entity
@Table(name = "category_mappings")
public class CategoryMapping {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "id", nullable = false)
private Integer id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "merchant_id", nullable = false)
private Merchant merchant;
@Column(name = "raw_category_path", nullable = false)
private String rawCategoryPath;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "part_category_id")
private PartCategory partCategory;
@Column(name = "created_at", nullable = false)
private OffsetDateTime createdAt = OffsetDateTime.now();
@Column(name = "updated_at", nullable = false)
private OffsetDateTime updatedAt = OffsetDateTime.now();
@PrePersist
public void onCreate() {
OffsetDateTime now = OffsetDateTime.now();
if (createdAt == null) {
createdAt = now;
}
if (updatedAt == null) {
updatedAt = now;
}
}
@PreUpdate
public void onUpdate() {
this.updatedAt = OffsetDateTime.now();
}
// --- getters & setters ---
public Integer getId() {
return id;
}
public void setId(Integer id) {
this.id = id;
}
public Merchant getMerchant() {
return merchant;
}
public void setMerchant(Merchant merchant) {
this.merchant = merchant;
}
public String getRawCategoryPath() {
return rawCategoryPath;
}
public void setRawCategoryPath(String rawCategoryPath) {
this.rawCategoryPath = rawCategoryPath;
}
public PartCategory getPartCategory() {
return partCategory;
}
public void setPartCategory(PartCategory partCategory) {
this.partCategory = partCategory;
}
public OffsetDateTime getCreatedAt() {
return createdAt;
}
public void setCreatedAt(OffsetDateTime createdAt) {
this.createdAt = createdAt;
}
public OffsetDateTime getUpdatedAt() {
return updatedAt;
}
public void setUpdatedAt(OffsetDateTime updatedAt) {
this.updatedAt = updatedAt;
}
}

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

@@ -14,6 +14,25 @@ import group.goforward.ballistic.model.ProductConfiguration;
@Entity @Entity
@Table(name = "products") @Table(name = "products")
@NamedQuery(name="Products.findByPlatformWithBrand", query= "" +
"SELECT p FROM Product p" +
" JOIN FETCH p.brand b" +
" WHERE p.platform = :platform" +
" AND p.deletedAt IS NULL")
@NamedQuery(name="Product.findByPlatformAndPartRoleInWithBrand", query= "" +
"SELECT p FROM Product p JOIN FETCH p.brand b" +
" WHERE p.platform = :platform" +
" AND p.partRole IN :roles" +
" AND p.deletedAt IS NULL")
@NamedQuery(name="Product.findProductsbyBrandByOffers", query="" +
" SELECT DISTINCT p FROM Product p" +
" LEFT JOIN FETCH p.brand b" +
" LEFT JOIN FETCH p.offers o" +
" WHERE p.platform = :platform" +
" AND p.deletedAt IS NULL")
public class Product { public class Product {
@Id @Id

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

@@ -33,6 +33,9 @@ public interface ProductRepository extends JpaRepository<Product, Integer> {
""") """)
List<Product> findByPlatformWithBrand(@Param("platform") String platform); List<Product> findByPlatformWithBrand(@Param("platform") String platform);
@Query(name="Products.findByPlatformWithBrand")
List<Product> findByPlatformWithBrandNQ(@Param("platform") String platform);
@Query(""" @Query("""
SELECT p SELECT p
FROM Product p FROM Product p

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,11 @@
// src/main/java/group/goforward/ballistic/web/dto/admin/MerchantCategoryMappingDto.java
package group.goforward.ballistic.web.dto.admin;
public record MerchantCategoryMappingDto(
Integer id,
Integer merchantId,
String merchantName,
String rawCategoryPath,
Integer partCategoryId,
String partCategoryName
) {}

View File

@@ -0,0 +1,6 @@
package group.goforward.ballistic.web.dto.admin;
public record SimpleMerchantDto(
Integer id,
String name
) { }

View File

@@ -0,0 +1,5 @@
package group.goforward.ballistic.web.dto.admin;
public record UpdateMerchantCategoryMappingRequest(
Integer partCategoryId
) {}

View File

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