new admin dashboard controllers for mappings

This commit is contained in:
2025-12-07 16:17:57 -05:00
parent d1c805d893
commit 0845443767
10 changed files with 395 additions and 127 deletions

View File

@@ -0,0 +1,25 @@
package group.goforward.ballistic.web;
import group.goforward.ballistic.services.AdminDashboardService;
import group.goforward.ballistic.web.dto.AdminDashboardOverviewDto;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/admin/dashboard")
public class AdminDashboardController {
private final AdminDashboardService adminDashboardService;
public AdminDashboardController(AdminDashboardService adminDashboardService) {
this.adminDashboardService = adminDashboardService;
}
@GetMapping("/overview")
public ResponseEntity<AdminDashboardOverviewDto> getOverview() {
AdminDashboardOverviewDto dto = adminDashboardService.getOverview();
return ResponseEntity.ok(dto);
}
}

View File

@@ -1,11 +1,14 @@
package group.goforward.ballistic.repos;
import group.goforward.ballistic.model.Brand;
import group.goforward.ballistic.model.ImportStatus;
import group.goforward.ballistic.model.Merchant;
import group.goforward.ballistic.model.MerchantCategoryMapping;
import group.goforward.ballistic.model.Brand;
import group.goforward.ballistic.model.Product;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Collection;
import java.util.List;
@@ -21,6 +24,8 @@ public interface ProductRepository extends JpaRepository<Product, Integer> {
List<Product> findAllByBrandAndUpc(Brand brand, String upc);
long countByImportStatus(ImportStatus importStatus);
boolean existsBySlug(String slug);
// -------------------------------------------------
@@ -28,12 +33,12 @@ public interface ProductRepository extends JpaRepository<Product, Integer> {
// -------------------------------------------------
@Query("""
SELECT p
FROM Product p
JOIN FETCH p.brand b
WHERE p.platform = :platform
AND p.deletedAt IS NULL
""")
SELECT p
FROM Product p
JOIN FETCH p.brand b
WHERE p.platform = :platform
AND p.deletedAt IS NULL
""")
List<Product> findByPlatformWithBrand(@Param("platform") String platform);
@Query(name = "Products.findByPlatformWithBrand")
@@ -57,13 +62,13 @@ public interface ProductRepository extends JpaRepository<Product, Integer> {
// -------------------------------------------------
@Query("""
SELECT p
FROM Product p
JOIN FETCH p.brand b
WHERE p.platform = :platform
AND p.deletedAt IS NULL
ORDER BY p.id
""")
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);
// -------------------------------------------------
@@ -72,15 +77,15 @@ public interface ProductRepository extends JpaRepository<Product, Integer> {
// -------------------------------------------------
@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.partRole IN :partRoles
AND p.importStatus = :status
AND p.deletedAt IS NULL
""")
SELECT DISTINCT p
FROM Product p
LEFT JOIN FETCH p.brand b
LEFT JOIN FETCH p.offers o
WHERE p.platform = :platform
AND p.partRole IN :partRoles
AND p.importStatus = :status
AND p.deletedAt IS NULL
""")
List<Product> findForGunbuilderByPlatformAndPartRoles(
@Param("platform") String platform,
@Param("partRoles") Collection<String> partRoles,
@@ -91,85 +96,111 @@ public interface ProductRepository extends JpaRepository<Product, Integer> {
// Admin import-status dashboard (summary)
// -------------------------------------------------
@Query("""
SELECT p.importStatus AS status, COUNT(p) AS count
FROM Product p
WHERE p.deletedAt IS NULL
GROUP BY p.importStatus
""")
SELECT p.importStatus AS status, COUNT(p) AS count
FROM Product p
WHERE p.deletedAt IS NULL
GROUP BY p.importStatus
""")
List<Map<String, Object>> aggregateByImportStatus();
// -------------------------------------------------
// Admin import-status dashboard (by merchant)
// -------------------------------------------------
@Query("""
SELECT m.name AS merchantName,
p.platform AS platform,
p.importStatus AS status,
COUNT(p) AS count
FROM Product p
JOIN p.offers o
JOIN o.merchant m
WHERE p.deletedAt IS NULL
GROUP BY m.name, p.platform, p.importStatus
""")
SELECT m.id AS merchantId,
m.name AS merchantName,
p.platform AS platform,
p.importStatus AS status,
COUNT(p) AS count
FROM Product p
JOIN p.offers o
JOIN o.merchant m
WHERE p.deletedAt IS NULL
GROUP BY m.id, m.name, p.platform, p.importStatus
ORDER BY m.name ASC, p.platform ASC, p.importStatus ASC
""")
List<Map<String, Object>> aggregateByMerchantAndStatus();
// -------------------------------------------------
// Admin: Unmapped category clusters
// -------------------------------------------------
@Query("""
SELECT p.rawCategoryKey AS rawCategoryKey,
m.name AS merchantName,
COUNT(DISTINCT p.id) AS productCount
FROM Product p
JOIN p.offers o
JOIN o.merchant m
WHERE p.deletedAt IS NULL
AND (p.importStatus = group.goforward.ballistic.model.ImportStatus.PENDING_MAPPING
OR p.partRole IS NULL
OR LOWER(p.partRole) = 'unknown')
AND p.rawCategoryKey IS NOT NULL
GROUP BY p.rawCategoryKey, m.name
ORDER BY productCount DESC
""")
SELECT p.rawCategoryKey AS rawCategoryKey,
m.name AS merchantName,
COUNT(DISTINCT p.id) AS productCount
FROM Product p
JOIN p.offers o
JOIN o.merchant m
WHERE p.deletedAt IS NULL
AND (p.importStatus = group.goforward.ballistic.model.ImportStatus.PENDING_MAPPING
OR p.partRole IS NULL
OR LOWER(p.partRole) = 'unknown')
AND p.rawCategoryKey IS NOT NULL
GROUP BY p.rawCategoryKey, m.name
ORDER BY productCount DESC
""")
List<Map<String, Object>> findUnmappedCategoryGroups();
@Query("""
SELECT p
FROM Product p
JOIN p.offers o
JOIN o.merchant m
WHERE p.deletedAt IS NULL
AND m.name = :merchantName
AND p.rawCategoryKey = :rawCategoryKey
ORDER BY p.id
""")
SELECT p
FROM Product p
JOIN p.offers o
JOIN o.merchant m
WHERE p.deletedAt IS NULL
AND m.name = :merchantName
AND p.rawCategoryKey = :rawCategoryKey
ORDER BY p.id
""")
List<Product> findExamplesForCategoryGroup(
@Param("merchantName") String merchantName,
@Param("rawCategoryKey") String rawCategoryKey
);
// -------------------------------------------------
// Admin Bulk Mapper: Merchant + rawCategoryKey buckets
// Mapping admin pending buckets (all merchants)
// -------------------------------------------------
@Query("""
SELECT m.id,
m.name,
p.rawCategoryKey,
COALESCE(mcm.mappedPartRole, '') AS mappedPartRole,
COUNT(DISTINCT p.id)
FROM Product p
JOIN p.offers o
JOIN o.merchant m
LEFT JOIN MerchantCategoryMapping mcm
ON mcm.merchant = m
AND mcm.rawCategory = p.rawCategoryKey
WHERE p.importStatus = :status
AND p.deletedAt IS NULL
GROUP BY m.id, m.name, p.rawCategoryKey, mcm.mappedPartRole
ORDER BY COUNT(DISTINCT p.id) DESC
""")
SELECT m.id AS merchantId,
m.name AS merchantName,
p.rawCategoryKey AS rawCategoryKey,
mcm.mappedPartRole AS mappedPartRole,
COUNT(DISTINCT p.id) AS productCount
FROM Product p
JOIN p.offers o
JOIN o.merchant m
LEFT JOIN MerchantCategoryMapping mcm
ON mcm.merchant.id = m.id
AND mcm.rawCategory = p.rawCategoryKey
WHERE p.importStatus = :status
GROUP BY m.id, m.name, p.rawCategoryKey, mcm.mappedPartRole
ORDER BY productCount DESC
""")
List<Object[]> findPendingMappingBuckets(
@Param("status") ImportStatus status
);
// -------------------------------------------------
// Mapping admin pending buckets for a single merchant
// -------------------------------------------------
@Query("""
SELECT m.id AS merchantId,
m.name AS merchantName,
p.rawCategoryKey AS rawCategoryKey,
mcm.mappedPartRole AS mappedPartRole,
COUNT(DISTINCT p.id) AS productCount
FROM Product p
JOIN p.offers o
JOIN o.merchant m
LEFT JOIN MerchantCategoryMapping mcm
ON mcm.merchant.id = m.id
AND mcm.rawCategory = p.rawCategoryKey
WHERE p.importStatus = :status
AND m.id = :merchantId
GROUP BY m.id, m.name, p.rawCategoryKey, mcm.mappedPartRole
ORDER BY productCount DESC
""")
List<Object[]> findPendingMappingBucketsForMerchant(
@Param("merchantId") Integer merchantId,
@Param("status") ImportStatus status
);
}

View File

@@ -0,0 +1,45 @@
package group.goforward.ballistic.services;
import group.goforward.ballistic.model.ImportStatus;
import group.goforward.ballistic.repos.MerchantCategoryMappingRepository;
import group.goforward.ballistic.repos.MerchantRepository;
import group.goforward.ballistic.repos.ProductRepository;
import group.goforward.ballistic.web.dto.AdminDashboardOverviewDto;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class AdminDashboardService {
private final ProductRepository productRepository;
private final MerchantRepository merchantRepository;
private final MerchantCategoryMappingRepository merchantCategoryMappingRepository;
public AdminDashboardService(
ProductRepository productRepository,
MerchantRepository merchantRepository,
MerchantCategoryMappingRepository merchantCategoryMappingRepository
) {
this.productRepository = productRepository;
this.merchantRepository = merchantRepository;
this.merchantCategoryMappingRepository = merchantCategoryMappingRepository;
}
@Transactional(readOnly = true)
public AdminDashboardOverviewDto getOverview() {
long totalProducts = productRepository.count();
long unmappedProducts = productRepository.countByImportStatus(ImportStatus.PENDING_MAPPING);
long mappedProducts = totalProducts - unmappedProducts;
long merchantCount = merchantRepository.count();
long categoryMappings = merchantCategoryMappingRepository.count();
return new AdminDashboardOverviewDto(
totalProducts,
mappedProducts,
unmappedProducts,
merchantCount,
categoryMappings
);
}
}

View File

@@ -29,19 +29,28 @@ public class MappingAdminService {
this.merchantRepository = merchantRepository;
}
/**
* Returns all pending mapping buckets across all merchants.
* Each row is:
* [0] merchantId (Integer)
* [1] merchantName (String)
* [2] rawCategoryKey (String)
* [3] mappedPartRole (String, currently null from query)
* [4] productCount (Long)
*/
@Transactional(readOnly = true)
public List<PendingMappingBucketDto> listPendingBuckets() {
List<Object[]> rows = productRepository.findPendingMappingBuckets(
ImportStatus.PENDING_MAPPING // use top-level enum
ImportStatus.PENDING_MAPPING
);
return rows.stream()
.map(row -> {
Integer merchantId = (Integer) row[0];
String merchantName = (String) row[1];
String rawCategoryKey = (String) row[2];
String mappedPartRole = (String) row[3];
Long count = (Long) row[4];
Integer merchantId = (Integer) row[0];
String merchantName = (String) row[1];
String rawCategoryKey = (String) row[2];
String mappedPartRole = (String) row[3];
Long count = (Long) row[4];
return new PendingMappingBucketDto(
merchantId,
@@ -54,6 +63,10 @@ public class MappingAdminService {
.toList();
}
/**
* Applies or updates a mapping for (merchant, rawCategoryKey) to a given partRole.
* Does NOT retroactively update Product rows; they will be updated on the next import.
*/
@Transactional
public void applyMapping(Integer merchantId, String rawCategoryKey, String mappedPartRole) {
if (merchantId == null || rawCategoryKey == null || mappedPartRole == null || mappedPartRole.isBlank()) {
@@ -75,9 +88,6 @@ public class MappingAdminService {
mapping.setMappedPartRole(mappedPartRole.trim());
merchantCategoryMappingRepository.save(mapping);
// NOTE:
// We're not touching existing Product rows here.
// They will pick up this mapping on the next merchant import,
// which keeps this endpoint fast and side-effect simple.
// Products will pick up this mapping on the next merchant import run.
}
}

View File

@@ -0,0 +1,73 @@
package group.goforward.ballistic.web.admin;
import group.goforward.ballistic.model.ImportStatus;
import group.goforward.ballistic.repos.ProductRepository;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/admin/import-status")
public class AdminImportStatusController {
private final ProductRepository productRepository;
public AdminImportStatusController(ProductRepository productRepository) {
this.productRepository = productRepository;
}
public record ImportSummaryDto(
long totalProducts,
long mappedProducts,
long pendingProducts
) {}
public record ByMerchantRowDto(
Integer merchantId,
String merchantName,
String platform,
ImportStatus status,
long count
) {}
@GetMapping("/summary")
public ImportSummaryDto summary() {
List<Map<String, Object>> rows = productRepository.aggregateByImportStatus();
long total = 0L;
long mapped = 0L;
long pending = 0L;
for (Map<String, Object> row : rows) {
ImportStatus status = (ImportStatus) row.get("status");
long count = ((Number) row.get("count")).longValue();
total += count;
if (status == ImportStatus.MAPPED) {
mapped += count;
} else if (status == ImportStatus.PENDING_MAPPING) {
pending += count;
}
}
return new ImportSummaryDto(total, mapped, pending);
}
@GetMapping("/by-merchant")
public List<ByMerchantRowDto> byMerchant() {
List<Map<String, Object>> rows = productRepository.aggregateByMerchantAndStatus();
return rows.stream()
.map(row -> new ByMerchantRowDto(
(Integer) row.get("merchantId"),
(String) row.get("merchantName"),
(String) row.get("platform"),
(ImportStatus) row.get("status"),
((Number) row.get("count")).longValue()
))
.toList();
}
}

View File

@@ -20,16 +20,26 @@ public class AdminMappingController {
@GetMapping("/pending-buckets")
public List<PendingMappingBucketDto> listPendingBuckets() {
// Simple: just delegate to service
return mappingAdminService.listPendingBuckets();
}
@PostMapping("/apply")
public ResponseEntity<?> applyMapping(@RequestBody Map<String, Object> body) {
Integer merchantId = (Integer) body.get("merchantId");
String rawCategoryKey = (String) body.get("rawCategoryKey");
String mappedPartRole = (String) body.get("mappedPartRole");
public record ApplyMappingRequest(
Integer merchantId,
String rawCategoryKey,
String mappedPartRole
) {}
@PostMapping("/apply")
public ResponseEntity<Map<String, Object>> applyMapping(
@RequestBody ApplyMappingRequest request
) {
mappingAdminService.applyMapping(
request.merchantId(),
request.rawCategoryKey(),
request.mappedPartRole()
);
mappingAdminService.applyMapping(merchantId, rawCategoryKey, mappedPartRole);
return ResponseEntity.ok(Map.of("ok", true));
}
}

View File

@@ -0,0 +1,35 @@
package group.goforward.ballistic.web.admin;
import group.goforward.ballistic.services.MerchantFeedImportService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
@RestController
@RequestMapping("/api/admin/merchants")
public class AdminMerchantController {
private final MerchantFeedImportService merchantFeedImportService;
public AdminMerchantController(MerchantFeedImportService merchantFeedImportService) {
this.merchantFeedImportService = merchantFeedImportService;
}
@PostMapping("/{merchantId}/import")
public ResponseEntity<Map<String, Object>> triggerFullImport(
@PathVariable Integer merchantId
) {
// Fire off the full import for this merchant.
// (Right now this is synchronous; later we can push to a queue if needed.)
merchantFeedImportService.importMerchantFeed(merchantId);
return ResponseEntity.accepted().body(
Map.of(
"ok", true,
"merchantId", merchantId,
"message", "Import triggered"
)
);
}
}

View File

@@ -6,6 +6,7 @@ import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/admin/mappings")
@@ -37,4 +38,30 @@ public class CategoryMappingAdminController {
);
return ResponseEntity.noContent().build();
}
// @RestController
// @RequestMapping("/api/admin/mapping")
// public static class AdminMappingController {
//
// private final MappingAdminService mappingAdminService;
//
// public AdminMappingController(MappingAdminService mappingAdminService) {
// this.mappingAdminService = mappingAdminService;
// }
//
// @GetMapping("/pending-buckets")
// public List<PendingMappingBucketDto> listPendingBuckets() {
// return mappingAdminService.listPendingBuckets();
// }
//
// @PostMapping("/apply")
// public ResponseEntity<?> applyMapping(@RequestBody Map<String, Object> body) {
// Integer merchantId = (Integer) body.get("merchantId");
// String rawCategoryKey = (String) body.get("rawCategoryKey");
// String mappedPartRole = (String) body.get("mappedPartRole");
//
// mappingAdminService.applyMapping(merchantId, rawCategoryKey, mappedPartRole);
// return ResponseEntity.ok(Map.of("ok", true));
// }
// }
}

View File

@@ -1,32 +0,0 @@
// src/main/java/group/goforward/ballistic/web/admin/ImportStatusAdminController.java
package group.goforward.ballistic.web.admin;
import group.goforward.ballistic.services.ImportStatusAdminService;
import group.goforward.ballistic.web.dto.ImportStatusByMerchantDto;
import group.goforward.ballistic.web.dto.ImportStatusSummaryDto;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
@RequestMapping("/api/admin/import-status")
public class ImportStatusAdminController {
private final ImportStatusAdminService service;
public ImportStatusAdminController(ImportStatusAdminService service) {
this.service = service;
}
@GetMapping("/summary")
public List<ImportStatusSummaryDto> summary() {
return service.summarizeByStatus();
}
@GetMapping("/by-merchant")
public List<ImportStatusByMerchantDto> byMerchant() {
return service.summarizeByMerchant();
}
}

View File

@@ -0,0 +1,44 @@
package group.goforward.ballistic.web.dto;
public class AdminDashboardOverviewDto {
private long totalProducts;
private long mappedProducts;
private long unmappedProducts;
private long merchantCount;
private long categoryMappingCount;
public AdminDashboardOverviewDto(
long totalProducts,
long mappedProducts,
long unmappedProducts,
long merchantCount,
long categoryMappingCount
) {
this.totalProducts = totalProducts;
this.mappedProducts = mappedProducts;
this.unmappedProducts = unmappedProducts;
this.merchantCount = merchantCount;
this.categoryMappingCount = categoryMappingCount;
}
public long getTotalProducts() {
return totalProducts;
}
public long getMappedProducts() {
return mappedProducts;
}
public long getUnmappedProducts() {
return unmappedProducts;
}
public long getMerchantCount() {
return merchantCount;
}
public long getCategoryMappingCount() {
return categoryMappingCount;
}
}