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; package group.goforward.ballistic.repos;
import group.goforward.ballistic.model.Brand;
import group.goforward.ballistic.model.ImportStatus; 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 group.goforward.ballistic.model.Product;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query; import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param; import org.springframework.data.repository.query.Param;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Collection; import java.util.Collection;
import java.util.List; import java.util.List;
@@ -21,6 +24,8 @@ public interface ProductRepository extends JpaRepository<Product, Integer> {
List<Product> findAllByBrandAndUpc(Brand brand, String upc); List<Product> findAllByBrandAndUpc(Brand brand, String upc);
long countByImportStatus(ImportStatus importStatus);
boolean existsBySlug(String slug); boolean existsBySlug(String slug);
// ------------------------------------------------- // -------------------------------------------------
@@ -102,7 +107,8 @@ public interface ProductRepository extends JpaRepository<Product, Integer> {
// Admin import-status dashboard (by merchant) // Admin import-status dashboard (by merchant)
// ------------------------------------------------- // -------------------------------------------------
@Query(""" @Query("""
SELECT m.name AS merchantName, SELECT m.id AS merchantId,
m.name AS merchantName,
p.platform AS platform, p.platform AS platform,
p.importStatus AS status, p.importStatus AS status,
COUNT(p) AS count COUNT(p) AS count
@@ -110,7 +116,8 @@ public interface ProductRepository extends JpaRepository<Product, Integer> {
JOIN p.offers o JOIN p.offers o
JOIN o.merchant m JOIN o.merchant m
WHERE p.deletedAt IS NULL WHERE p.deletedAt IS NULL
GROUP BY m.name, p.platform, p.importStatus 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(); List<Map<String, Object>> aggregateByMerchantAndStatus();
@@ -150,26 +157,50 @@ public interface ProductRepository extends JpaRepository<Product, Integer> {
); );
// ------------------------------------------------- // -------------------------------------------------
// Admin Bulk Mapper: Merchant + rawCategoryKey buckets // Mapping admin pending buckets (all merchants)
// ------------------------------------------------- // -------------------------------------------------
@Query(""" @Query("""
SELECT m.id, SELECT m.id AS merchantId,
m.name, m.name AS merchantName,
p.rawCategoryKey, p.rawCategoryKey AS rawCategoryKey,
COALESCE(mcm.mappedPartRole, '') AS mappedPartRole, mcm.mappedPartRole AS mappedPartRole,
COUNT(DISTINCT p.id) COUNT(DISTINCT p.id) AS productCount
FROM Product p FROM Product p
JOIN p.offers o JOIN p.offers o
JOIN o.merchant m JOIN o.merchant m
LEFT JOIN MerchantCategoryMapping mcm LEFT JOIN MerchantCategoryMapping mcm
ON mcm.merchant = m ON mcm.merchant.id = m.id
AND mcm.rawCategory = p.rawCategoryKey AND mcm.rawCategory = p.rawCategoryKey
WHERE p.importStatus = :status WHERE p.importStatus = :status
AND p.deletedAt IS NULL
GROUP BY m.id, m.name, p.rawCategoryKey, mcm.mappedPartRole GROUP BY m.id, m.name, p.rawCategoryKey, mcm.mappedPartRole
ORDER BY COUNT(DISTINCT p.id) DESC ORDER BY productCount DESC
""") """)
List<Object[]> findPendingMappingBuckets( List<Object[]> findPendingMappingBuckets(
@Param("status") ImportStatus status @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,10 +29,19 @@ public class MappingAdminService {
this.merchantRepository = merchantRepository; 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) @Transactional(readOnly = true)
public List<PendingMappingBucketDto> listPendingBuckets() { public List<PendingMappingBucketDto> listPendingBuckets() {
List<Object[]> rows = productRepository.findPendingMappingBuckets( List<Object[]> rows = productRepository.findPendingMappingBuckets(
ImportStatus.PENDING_MAPPING // use top-level enum ImportStatus.PENDING_MAPPING
); );
return rows.stream() return rows.stream()
@@ -54,6 +63,10 @@ public class MappingAdminService {
.toList(); .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 @Transactional
public void applyMapping(Integer merchantId, String rawCategoryKey, String mappedPartRole) { public void applyMapping(Integer merchantId, String rawCategoryKey, String mappedPartRole) {
if (merchantId == null || rawCategoryKey == null || mappedPartRole == null || mappedPartRole.isBlank()) { if (merchantId == null || rawCategoryKey == null || mappedPartRole == null || mappedPartRole.isBlank()) {
@@ -75,9 +88,6 @@ public class MappingAdminService {
mapping.setMappedPartRole(mappedPartRole.trim()); mapping.setMappedPartRole(mappedPartRole.trim());
merchantCategoryMappingRepository.save(mapping); merchantCategoryMappingRepository.save(mapping);
// NOTE: // Products will pick up this mapping on the next merchant import run.
// 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.
} }
} }

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") @GetMapping("/pending-buckets")
public List<PendingMappingBucketDto> listPendingBuckets() { public List<PendingMappingBucketDto> listPendingBuckets() {
// Simple: just delegate to service
return mappingAdminService.listPendingBuckets(); return mappingAdminService.listPendingBuckets();
} }
@PostMapping("/apply") public record ApplyMappingRequest(
public ResponseEntity<?> applyMapping(@RequestBody Map<String, Object> body) { Integer merchantId,
Integer merchantId = (Integer) body.get("merchantId"); String rawCategoryKey,
String rawCategoryKey = (String) body.get("rawCategoryKey"); String mappedPartRole
String mappedPartRole = (String) body.get("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)); 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 org.springframework.web.bind.annotation.*;
import java.util.List; import java.util.List;
import java.util.Map;
@RestController @RestController
@RequestMapping("/api/admin/mappings") @RequestMapping("/api/admin/mappings")
@@ -37,4 +38,30 @@ public class CategoryMappingAdminController {
); );
return ResponseEntity.noContent().build(); 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;
}
}