This commit is contained in:
2026-01-05 17:06:03 -05:00
parent 323d075982
commit 7c65311fad
118 changed files with 9835 additions and 9761 deletions

View File

@@ -1,87 +1,87 @@
package group.goforward.battlbuilder.catalog.classification;
import group.goforward.battlbuilder.model.PartRoleRule;
import group.goforward.battlbuilder.repo.PartRoleRuleRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import jakarta.annotation.PostConstruct;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.regex.Pattern;
@Component
public class PartRoleResolver {
private static final Logger log = LoggerFactory.getLogger(PartRoleResolver.class);
private final PartRoleRuleRepository repo;
private final List<CompiledRule> rules = new ArrayList<>();
public PartRoleResolver(PartRoleRuleRepository repo) {
this.repo = repo;
}
@PostConstruct
public void load() {
rules.clear();
List<PartRoleRule> active = repo.findAllByActiveTrueOrderByPriorityDescIdAsc();
for (PartRoleRule r : active) {
try {
rules.add(new CompiledRule(
r.getId(),
r.getTargetPlatform(),
Pattern.compile(r.getNameRegex(), Pattern.CASE_INSENSITIVE),
normalizeRole(r.getTargetPartRole())
));
} catch (Exception e) {
log.warn("Skipping invalid part role rule id={} regex={} err={}",
r.getId(), r.getNameRegex(), e.getMessage());
}
}
log.info("Loaded {} part role rules", rules.size());
}
public String resolve(String platform, String productName, String rawCategoryKey) {
String p = normalizePlatform(platform);
// we match primarily on productName; optionally also include rawCategoryKey in the text blob
String text = (productName == null ? "" : productName) +
" " +
(rawCategoryKey == null ? "" : rawCategoryKey);
for (CompiledRule r : rules) {
if (!r.appliesToPlatform(p)) continue;
if (r.pattern.matcher(text).find()) {
return r.targetPartRole; // already normalized
}
}
return null;
}
private static String normalizeRole(String role) {
if (role == null) return null;
String t = role.trim();
if (t.isEmpty()) return null;
return t.toLowerCase(Locale.ROOT).replace('_','-');
}
private static String normalizePlatform(String platform) {
if (platform == null) return null;
String t = platform.trim();
return t.isEmpty() ? null : t.toUpperCase(Locale.ROOT);
}
private record CompiledRule(Long id, String targetPlatform, Pattern pattern, String targetPartRole) {
boolean appliesToPlatform(String platform) {
if (targetPlatform == null || targetPlatform.isBlank()) return true;
if (platform == null) return false;
return targetPlatform.trim().equalsIgnoreCase(platform);
}
}
package group.goforward.battlbuilder.catalog.classification;
import group.goforward.battlbuilder.model.PartRoleRule;
import group.goforward.battlbuilder.repo.PartRoleRuleRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import jakarta.annotation.PostConstruct;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.regex.Pattern;
@Component
public class PartRoleResolver {
private static final Logger log = LoggerFactory.getLogger(PartRoleResolver.class);
private final PartRoleRuleRepository repo;
private final List<CompiledRule> rules = new ArrayList<>();
public PartRoleResolver(PartRoleRuleRepository repo) {
this.repo = repo;
}
@PostConstruct
public void load() {
rules.clear();
List<PartRoleRule> active = repo.findAllByActiveTrueOrderByPriorityDescIdAsc();
for (PartRoleRule r : active) {
try {
rules.add(new CompiledRule(
r.getId(),
r.getTargetPlatform(),
Pattern.compile(r.getNameRegex(), Pattern.CASE_INSENSITIVE),
normalizeRole(r.getTargetPartRole())
));
} catch (Exception e) {
log.warn("Skipping invalid part role rule id={} regex={} err={}",
r.getId(), r.getNameRegex(), e.getMessage());
}
}
log.info("Loaded {} part role rules", rules.size());
}
public String resolve(String platform, String productName, String rawCategoryKey) {
String p = normalizePlatform(platform);
// we match primarily on productName; optionally also include rawCategoryKey in the text blob
String text = (productName == null ? "" : productName) +
" " +
(rawCategoryKey == null ? "" : rawCategoryKey);
for (CompiledRule r : rules) {
if (!r.appliesToPlatform(p)) continue;
if (r.pattern.matcher(text).find()) {
return r.targetPartRole; // already normalized
}
}
return null;
}
private static String normalizeRole(String role) {
if (role == null) return null;
String t = role.trim();
if (t.isEmpty()) return null;
return t.toLowerCase(Locale.ROOT).replace('_','-');
}
private static String normalizePlatform(String platform) {
if (platform == null) return null;
String t = platform.trim();
return t.isEmpty() ? null : t.toUpperCase(Locale.ROOT);
}
private record CompiledRule(Long id, String targetPlatform, Pattern pattern, String targetPartRole) {
boolean appliesToPlatform(String platform) {
if (targetPlatform == null || targetPlatform.isBlank()) return true;
if (platform == null) return false;
return targetPlatform.trim().equalsIgnoreCase(platform);
}
}
}

View File

@@ -1,25 +1,25 @@
package group.goforward.battlbuilder.catalog.classification;
/**
* Result returned by PlatformResolver.
* <p>
* Any of the fields may be null — the importer will only overwrite
* product.platform, product.partRole, or product.configuration
* when the returned value is non-null AND non-blank.
*/
public record PlatformResolutionResult(
String platform,
String partRole,
String configuration
) {
public static PlatformResolutionResult empty() {
return new PlatformResolutionResult(null, null, null);
}
public boolean isEmpty() {
return (platform == null || platform.isBlank()) &&
(partRole == null || partRole.isBlank()) &&
(configuration == null || configuration.isBlank());
}
package group.goforward.battlbuilder.catalog.classification;
/**
* Result returned by PlatformResolver.
* <p>
* Any of the fields may be null — the importer will only overwrite
* product.platform, product.partRole, or product.configuration
* when the returned value is non-null AND non-blank.
*/
public record PlatformResolutionResult(
String platform,
String partRole,
String configuration
) {
public static PlatformResolutionResult empty() {
return new PlatformResolutionResult(null, null, null);
}
public boolean isEmpty() {
return (platform == null || platform.isBlank()) &&
(partRole == null || partRole.isBlank()) &&
(configuration == null || configuration.isBlank());
}
}

View File

@@ -1,139 +1,139 @@
package group.goforward.battlbuilder.catalog.classification;
import group.goforward.battlbuilder.model.PlatformRule;
import group.goforward.battlbuilder.repo.PlatformRuleRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import jakarta.annotation.PostConstruct;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.regex.Pattern;
/**
* Resolves a product's PLATFORM (e.g. AR-15, AR-10, NOT-SUPPORTED)
* using explicit DB-backed rules.
* <p>
* Conservative approach:
* - If a rule matches, return its target_platform
* - If nothing matches, return null and let the caller decide fallback behavior
*/
@Component
public class PlatformResolver {
private static final Logger log = LoggerFactory.getLogger(PlatformResolver.class);
public static final String NOT_SUPPORTED = "NOT-SUPPORTED";
private final PlatformRuleRepository repo;
private final List<CompiledRule> rules = new ArrayList<>();
public PlatformResolver(PlatformRuleRepository repo) {
this.repo = repo;
}
@PostConstruct
public void load() {
rules.clear();
List<PlatformRule> active = repo.findAllByActiveTrueOrderByPriorityDescIdAsc();
for (PlatformRule r : active) {
try {
Pattern rawCat = compileNullable(r.getRawCategoryPattern());
Pattern name = compileNullable(r.getNameRegex());
String target = normalizePlatform(r.getTargetPlatform());
// If a rule has no matchers, it's useless — skip it.
if (rawCat == null && name == null) {
log.warn("Skipping platform rule id={} because it has no patterns (raw_category_pattern/name_regex both blank)", r.getId());
continue;
}
if (target == null || target.isBlank()) {
log.warn("Skipping platform rule id={} because target_platform is blank", r.getId());
continue;
}
rules.add(new CompiledRule(
r.getId(),
r.getMerchantId(),
r.getBrandId(),
rawCat,
name,
target
));
} catch (Exception e) {
log.warn("Skipping invalid platform rule id={} err={}", r.getId(), e.getMessage());
}
}
log.info("Loaded {} platform rules", rules.size());
}
/**
* @return platform string (e.g. AR-15, AR-10, NOT-SUPPORTED) or null if no rule matches.
*/
public String resolve(Long merchantId, Long brandId, String productName, String rawCategoryKey) {
String text = safe(productName) + " " + safe(rawCategoryKey);
for (CompiledRule r : rules) {
if (!r.appliesToMerchant(merchantId)) continue;
if (!r.appliesToBrand(brandId)) continue;
if (r.matches(text)) {
return r.targetPlatform;
}
}
return null;
}
// -----------------------------
// Helpers
// -----------------------------
private static Pattern compileNullable(String regex) {
if (regex == null || regex.isBlank()) return null;
return Pattern.compile(regex, Pattern.CASE_INSENSITIVE);
}
private static String normalizePlatform(String platform) {
if (platform == null) return null;
String t = platform.trim();
return t.isEmpty() ? null : t.toUpperCase(Locale.ROOT);
}
private static String safe(String s) {
return s == null ? "" : s;
}
// -----------------------------
// Internal model
// -----------------------------
private record CompiledRule(
Long id,
Long merchantId,
Long brandId,
Pattern rawCategoryPattern,
Pattern namePattern,
String targetPlatform
) {
boolean appliesToMerchant(Long merchantId) {
return this.merchantId == null || this.merchantId.equals(merchantId);
}
boolean appliesToBrand(Long brandId) {
return this.brandId == null || this.brandId.equals(brandId);
}
boolean matches(String text) {
if (rawCategoryPattern != null && rawCategoryPattern.matcher(text).find()) return true;
if (namePattern != null && namePattern.matcher(text).find()) return true;
return false;
}
}
package group.goforward.battlbuilder.catalog.classification;
import group.goforward.battlbuilder.model.PlatformRule;
import group.goforward.battlbuilder.repo.PlatformRuleRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import jakarta.annotation.PostConstruct;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.regex.Pattern;
/**
* Resolves a product's PLATFORM (e.g. AR-15, AR-10, NOT-SUPPORTED)
* using explicit DB-backed rules.
* <p>
* Conservative approach:
* - If a rule matches, return its target_platform
* - If nothing matches, return null and let the caller decide fallback behavior
*/
@Component
public class PlatformResolver {
private static final Logger log = LoggerFactory.getLogger(PlatformResolver.class);
public static final String NOT_SUPPORTED = "NOT-SUPPORTED";
private final PlatformRuleRepository repo;
private final List<CompiledRule> rules = new ArrayList<>();
public PlatformResolver(PlatformRuleRepository repo) {
this.repo = repo;
}
@PostConstruct
public void load() {
rules.clear();
List<PlatformRule> active = repo.findAllByActiveTrueOrderByPriorityDescIdAsc();
for (PlatformRule r : active) {
try {
Pattern rawCat = compileNullable(r.getRawCategoryPattern());
Pattern name = compileNullable(r.getNameRegex());
String target = normalizePlatform(r.getTargetPlatform());
// If a rule has no matchers, it's useless — skip it.
if (rawCat == null && name == null) {
log.warn("Skipping platform rule id={} because it has no patterns (raw_category_pattern/name_regex both blank)", r.getId());
continue;
}
if (target == null || target.isBlank()) {
log.warn("Skipping platform rule id={} because target_platform is blank", r.getId());
continue;
}
rules.add(new CompiledRule(
r.getId(),
r.getMerchantId(),
r.getBrandId(),
rawCat,
name,
target
));
} catch (Exception e) {
log.warn("Skipping invalid platform rule id={} err={}", r.getId(), e.getMessage());
}
}
log.info("Loaded {} platform rules", rules.size());
}
/**
* @return platform string (e.g. AR-15, AR-10, NOT-SUPPORTED) or null if no rule matches.
*/
public String resolve(Long merchantId, Long brandId, String productName, String rawCategoryKey) {
String text = safe(productName) + " " + safe(rawCategoryKey);
for (CompiledRule r : rules) {
if (!r.appliesToMerchant(merchantId)) continue;
if (!r.appliesToBrand(brandId)) continue;
if (r.matches(text)) {
return r.targetPlatform;
}
}
return null;
}
// -----------------------------
// Helpers
// -----------------------------
private static Pattern compileNullable(String regex) {
if (regex == null || regex.isBlank()) return null;
return Pattern.compile(regex, Pattern.CASE_INSENSITIVE);
}
private static String normalizePlatform(String platform) {
if (platform == null) return null;
String t = platform.trim();
return t.isEmpty() ? null : t.toUpperCase(Locale.ROOT);
}
private static String safe(String s) {
return s == null ? "" : s;
}
// -----------------------------
// Internal model
// -----------------------------
private record CompiledRule(
Long id,
Long merchantId,
Long brandId,
Pattern rawCategoryPattern,
Pattern namePattern,
String targetPlatform
) {
boolean appliesToMerchant(Long merchantId) {
return this.merchantId == null || this.merchantId.equals(merchantId);
}
boolean appliesToBrand(Long brandId) {
return this.brandId == null || this.brandId.equals(brandId);
}
boolean matches(String text) {
if (rawCategoryPattern != null && rawCategoryPattern.matcher(text).find()) return true;
if (namePattern != null && namePattern.matcher(text).find()) return true;
return false;
}
}
}

View File

@@ -1,245 +1,245 @@
package group.goforward.battlbuilder.classification.admin;
import group.goforward.battlbuilder.classification.ClassificationResult;
import group.goforward.battlbuilder.classification.ProductClassifier;
import group.goforward.battlbuilder.model.MerchantCategoryMap;
import group.goforward.battlbuilder.model.PartRoleSource;
import group.goforward.battlbuilder.model.Product;
import group.goforward.battlbuilder.repo.ProductRepository;
import group.goforward.battlbuilder.repo.ProductOfferRepository;
import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Service;
import java.util.*;
@Service
public class ClassificationReconcileService {
private final ProductRepository productRepository;
private final ProductClassifier productClassifier;
private final ProductOfferRepository productOfferRepository;
public ClassificationReconcileService(ProductRepository productRepository,
ProductClassifier productClassifier,
ProductOfferRepository productOfferRepository) {
this.productRepository = productRepository;
this.productClassifier = productClassifier;
this.productOfferRepository = productOfferRepository;
}
public ReconcileResponse reconcile(ReconcileRequest req) {
int limit = req.limit();
boolean dryRun = req.dryRun();
// Page in chunks until we hit limit.
final int pageSize = Math.min(250, limit);
int scanned = 0;
int page = 0;
// Counts by reconcile outcome.
Map<String, Integer> counts = new LinkedHashMap<>();
counts.put("UNCHANGED", 0);
counts.put("WOULD_UPDATE", 0);
counts.put("LOCKED", 0);
counts.put("IGNORED", 0);
counts.put("UNMAPPED", 0);
counts.put("CONFLICT", 0);
counts.put("RULE_MATCHED", 0);
// Sample rows to inspect quickly in API response.
List<ReconcileDiffRow> samples = new ArrayList<>();
// Memoize merchant_category_map lookups across the whole reconcile run (kills mcm N+1)
Map<String, Optional<MerchantCategoryMap>> mappingMemo = new HashMap<>();
while (scanned < limit) {
var pageable = PageRequest.of(page, pageSize);
// Merchant is inferred via offers; this query limits products to those with offers for req.merchantId if provided.
var batch = productRepository.pageActiveProductsByOfferMerchant(req.merchantId(), req.platform(), pageable);
if (batch.isEmpty()) break;
// Avoid N+1: resolve primary merchant for ALL products in this page in one query
List<Integer> productIds = batch.getContent().stream()
.map(Product::getId)
.filter(Objects::nonNull)
.toList();
Map<Integer, Integer> primaryMerchantByProductId = new HashMap<>();
if (!productIds.isEmpty()) {
productOfferRepository.findPrimaryMerchantsByFirstSeenForProductIds(productIds)
.forEach(r -> primaryMerchantByProductId.put(r.getProductId(), r.getMerchantId()));
}
for (Product p : batch.getContent()) {
if (scanned >= limit) break;
// Optional: skip locked products unless includeLocked=true.
boolean locked = Boolean.TRUE.equals(p.getPartRoleLocked()) || Boolean.TRUE.equals(p.getPlatformLocked());
if (!req.includeLocked() && locked) {
counts.compute("LOCKED", (k, v) -> v + 1);
scanned++;
continue;
}
// Run the classifier (merchant_category_map + other logic inside classifier).
Integer resolvedMerchantId = primaryMerchantByProductId.get(p.getId());
ClassificationResult resolved = productClassifier.classifyProduct(p, resolvedMerchantId, mappingMemo);
if (resolved != null && resolved.source() != null && resolved.source().startsWith("rules_")) {
counts.compute("RULE_MATCHED", (k, v) -> v + 1);
}
// Compute diff status (dry-run only right now).
DiffOutcome outcome = diff(p, resolved);
counts.compute(outcome.status, (k, v) -> v + 1);
scanned++;
// Keep a small sample set for inspection.
boolean interestingStatus =
outcome.status.equals("WOULD_UPDATE")
|| outcome.status.equals("CONFLICT")
|| outcome.status.equals("UNMAPPED")
|| outcome.status.equals("IGNORED");
boolean ruleHit =
resolved != null
&& resolved.source() != null
&& resolved.source().startsWith("rules_");
// Keep a small sample set for inspection.
// Include rule hits even if the product ends up UNCHANGED, so we can verify rules are working.
if (samples.size() < 50 && (interestingStatus || ruleHit)) {
samples.add(toRow(p, resolved, outcome.status, outcome.meta));
}
}
if (!batch.hasNext()) break;
page++;
}
// Dry-run only right now—no writes.
return new ReconcileResponse(dryRun, scanned, counts, samples);
}
private static class DiffOutcome {
final String status;
final Map<String, Object> meta;
DiffOutcome(String status, Map<String, Object> meta) {
this.status = status;
this.meta = meta;
}
}
/**
* Compute reconcile status by comparing:
* - existing product role/source/locks
* - classifier-resolved role/source/confidence/reason
*/
private DiffOutcome diff(Product p, ClassificationResult resolved) {
String existingRole = trimToNull(p.getPartRole());
String resolvedRole = resolved == null ? null : trimToNull(resolved.partRole());
// Respect locks (never propose changes).
boolean locked = Boolean.TRUE.equals(p.getPartRoleLocked()) || Boolean.TRUE.equals(p.getPlatformLocked());
if (locked) {
return new DiffOutcome("LOCKED", Map.of("note", "partRoleLocked or platformLocked"));
}
// Treat known "ignored category" decisions as their own outcome bucket.
if (isIgnored(resolved)) {
return new DiffOutcome("IGNORED", Map.of(
"reason", resolved.reason(),
"source", resolved.source()
));
}
// No role resolved = still unmapped (needs merchant_category_map or rule-based inference later).
if (resolvedRole == null) {
return new DiffOutcome("UNMAPPED", Map.of(
"reason", resolved == null ? null : resolved.reason(),
"source", resolved == null ? null : resolved.source(),
"confidence", resolved == null ? null : resolved.confidence()
));
}
// Existing role missing but we resolved one => would update.
if (existingRole == null) {
return new DiffOutcome("WOULD_UPDATE", Map.of("from", null, "to", resolvedRole));
}
// Same role => unchanged.
if (existingRole.equalsIgnoreCase(resolvedRole)) {
return new DiffOutcome("UNCHANGED", Map.of());
}
// If existing role came from an override, flag as conflict (don't auto-clobber).
PartRoleSource existingSource = p.getPartRoleSource();
if (existingSource == PartRoleSource.OVERRIDE) {
return new DiffOutcome(
"CONFLICT",
Map.of("from", existingRole, "to", resolvedRole, "note", "existing source is OVERRIDE")
);
}
// Otherwise, its a normal drift that we would update in apply-mode.
return new DiffOutcome("WOULD_UPDATE", Map.of("from", existingRole, "to", resolvedRole));
}
/**
* Detect the "non-classifying category ignored" decisions from the classifier.
* Right now we key off the reason text prefix (fast + simple).
* Later we can formalize this via a dedicated source or meta flag.
*/
private boolean isIgnored(ClassificationResult r) {
return r != null && "ignored_category".equalsIgnoreCase(r.source());
}
private ReconcileDiffRow toRow(Product p, ClassificationResult resolved, String status, Map<String, Object> meta) {
// Extract the merchant used by classifier (if provided in meta).
Integer resolvedMerchantId = null;
if (resolved != null && resolved.meta() != null) {
Object v = resolved.meta().get("resolvedMerchantId");
if (v instanceof Integer i) resolvedMerchantId = i;
else if (v instanceof Number n) resolvedMerchantId = n.intValue();
else if (v instanceof String s) {
try { resolvedMerchantId = Integer.parseInt(s); } catch (Exception ignored) {}
}
}
return new ReconcileDiffRow(
p.getId(),
p.getName(),
p.getPlatform(),
p.getRawCategoryKey(),
resolvedMerchantId,
p.getPartRole(),
p.getPartRoleSource() == null ? null : p.getPartRoleSource().name(),
p.getPartRoleLocked(),
p.getPlatformLocked(),
resolved == null ? null : resolved.partRole(),
resolved == null ? null : resolved.source(),
resolved == null ? 0.0 : resolved.confidence(),
resolved == null ? null : resolved.reason(),
status,
meta == null ? Map.of() : meta
);
}
private static String trimToNull(String s) {
if (s == null) return null;
String t = s.trim();
return t.isEmpty() ? null : t;
}
package group.goforward.battlbuilder.classification.admin;
import group.goforward.battlbuilder.classification.ClassificationResult;
import group.goforward.battlbuilder.classification.ProductClassifier;
import group.goforward.battlbuilder.model.MerchantCategoryMap;
import group.goforward.battlbuilder.model.PartRoleSource;
import group.goforward.battlbuilder.model.Product;
import group.goforward.battlbuilder.repo.ProductRepository;
import group.goforward.battlbuilder.repo.ProductOfferRepository;
import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Service;
import java.util.*;
@Service
public class ClassificationReconcileService {
private final ProductRepository productRepository;
private final ProductClassifier productClassifier;
private final ProductOfferRepository productOfferRepository;
public ClassificationReconcileService(ProductRepository productRepository,
ProductClassifier productClassifier,
ProductOfferRepository productOfferRepository) {
this.productRepository = productRepository;
this.productClassifier = productClassifier;
this.productOfferRepository = productOfferRepository;
}
public ReconcileResponse reconcile(ReconcileRequest req) {
int limit = req.limit();
boolean dryRun = req.dryRun();
// Page in chunks until we hit limit.
final int pageSize = Math.min(250, limit);
int scanned = 0;
int page = 0;
// Counts by reconcile outcome.
Map<String, Integer> counts = new LinkedHashMap<>();
counts.put("UNCHANGED", 0);
counts.put("WOULD_UPDATE", 0);
counts.put("LOCKED", 0);
counts.put("IGNORED", 0);
counts.put("UNMAPPED", 0);
counts.put("CONFLICT", 0);
counts.put("RULE_MATCHED", 0);
// Sample rows to inspect quickly in API response.
List<ReconcileDiffRow> samples = new ArrayList<>();
// Memoize merchant_category_map lookups across the whole reconcile run (kills mcm N+1)
Map<String, Optional<MerchantCategoryMap>> mappingMemo = new HashMap<>();
while (scanned < limit) {
var pageable = PageRequest.of(page, pageSize);
// Merchant is inferred via offers; this query limits products to those with offers for req.merchantId if provided.
var batch = productRepository.pageActiveProductsByOfferMerchant(req.merchantId(), req.platform(), pageable);
if (batch.isEmpty()) break;
// Avoid N+1: resolve primary merchant for ALL products in this page in one query
List<Integer> productIds = batch.getContent().stream()
.map(Product::getId)
.filter(Objects::nonNull)
.toList();
Map<Integer, Integer> primaryMerchantByProductId = new HashMap<>();
if (!productIds.isEmpty()) {
productOfferRepository.findPrimaryMerchantsByFirstSeenForProductIds(productIds)
.forEach(r -> primaryMerchantByProductId.put(r.getProductId(), r.getMerchantId()));
}
for (Product p : batch.getContent()) {
if (scanned >= limit) break;
// Optional: skip locked products unless includeLocked=true.
boolean locked = Boolean.TRUE.equals(p.getPartRoleLocked()) || Boolean.TRUE.equals(p.getPlatformLocked());
if (!req.includeLocked() && locked) {
counts.compute("LOCKED", (k, v) -> v + 1);
scanned++;
continue;
}
// Run the classifier (merchant_category_map + other logic inside classifier).
Integer resolvedMerchantId = primaryMerchantByProductId.get(p.getId());
ClassificationResult resolved = productClassifier.classifyProduct(p, resolvedMerchantId, mappingMemo);
if (resolved != null && resolved.source() != null && resolved.source().startsWith("rules_")) {
counts.compute("RULE_MATCHED", (k, v) -> v + 1);
}
// Compute diff status (dry-run only right now).
DiffOutcome outcome = diff(p, resolved);
counts.compute(outcome.status, (k, v) -> v + 1);
scanned++;
// Keep a small sample set for inspection.
boolean interestingStatus =
outcome.status.equals("WOULD_UPDATE")
|| outcome.status.equals("CONFLICT")
|| outcome.status.equals("UNMAPPED")
|| outcome.status.equals("IGNORED");
boolean ruleHit =
resolved != null
&& resolved.source() != null
&& resolved.source().startsWith("rules_");
// Keep a small sample set for inspection.
// Include rule hits even if the product ends up UNCHANGED, so we can verify rules are working.
if (samples.size() < 50 && (interestingStatus || ruleHit)) {
samples.add(toRow(p, resolved, outcome.status, outcome.meta));
}
}
if (!batch.hasNext()) break;
page++;
}
// Dry-run only right now—no writes.
return new ReconcileResponse(dryRun, scanned, counts, samples);
}
private static class DiffOutcome {
final String status;
final Map<String, Object> meta;
DiffOutcome(String status, Map<String, Object> meta) {
this.status = status;
this.meta = meta;
}
}
/**
* Compute reconcile status by comparing:
* - existing product role/source/locks
* - classifier-resolved role/source/confidence/reason
*/
private DiffOutcome diff(Product p, ClassificationResult resolved) {
String existingRole = trimToNull(p.getPartRole());
String resolvedRole = resolved == null ? null : trimToNull(resolved.partRole());
// Respect locks (never propose changes).
boolean locked = Boolean.TRUE.equals(p.getPartRoleLocked()) || Boolean.TRUE.equals(p.getPlatformLocked());
if (locked) {
return new DiffOutcome("LOCKED", Map.of("note", "partRoleLocked or platformLocked"));
}
// Treat known "ignored category" decisions as their own outcome bucket.
if (isIgnored(resolved)) {
return new DiffOutcome("IGNORED", Map.of(
"reason", resolved.reason(),
"source", resolved.source()
));
}
// No role resolved = still unmapped (needs merchant_category_map or rule-based inference later).
if (resolvedRole == null) {
return new DiffOutcome("UNMAPPED", Map.of(
"reason", resolved == null ? null : resolved.reason(),
"source", resolved == null ? null : resolved.source(),
"confidence", resolved == null ? null : resolved.confidence()
));
}
// Existing role missing but we resolved one => would update.
if (existingRole == null) {
return new DiffOutcome("WOULD_UPDATE", Map.of("from", null, "to", resolvedRole));
}
// Same role => unchanged.
if (existingRole.equalsIgnoreCase(resolvedRole)) {
return new DiffOutcome("UNCHANGED", Map.of());
}
// If existing role came from an override, flag as conflict (don't auto-clobber).
PartRoleSource existingSource = p.getPartRoleSource();
if (existingSource == PartRoleSource.OVERRIDE) {
return new DiffOutcome(
"CONFLICT",
Map.of("from", existingRole, "to", resolvedRole, "note", "existing source is OVERRIDE")
);
}
// Otherwise, its a normal drift that we would update in apply-mode.
return new DiffOutcome("WOULD_UPDATE", Map.of("from", existingRole, "to", resolvedRole));
}
/**
* Detect the "non-classifying category ignored" decisions from the classifier.
* Right now we key off the reason text prefix (fast + simple).
* Later we can formalize this via a dedicated source or meta flag.
*/
private boolean isIgnored(ClassificationResult r) {
return r != null && "ignored_category".equalsIgnoreCase(r.source());
}
private ReconcileDiffRow toRow(Product p, ClassificationResult resolved, String status, Map<String, Object> meta) {
// Extract the merchant used by classifier (if provided in meta).
Integer resolvedMerchantId = null;
if (resolved != null && resolved.meta() != null) {
Object v = resolved.meta().get("resolvedMerchantId");
if (v instanceof Integer i) resolvedMerchantId = i;
else if (v instanceof Number n) resolvedMerchantId = n.intValue();
else if (v instanceof String s) {
try { resolvedMerchantId = Integer.parseInt(s); } catch (Exception ignored) {}
}
}
return new ReconcileDiffRow(
p.getId(),
p.getName(),
p.getPlatform(),
p.getRawCategoryKey(),
resolvedMerchantId,
p.getPartRole(),
p.getPartRoleSource() == null ? null : p.getPartRoleSource().name(),
p.getPartRoleLocked(),
p.getPlatformLocked(),
resolved == null ? null : resolved.partRole(),
resolved == null ? null : resolved.source(),
resolved == null ? 0.0 : resolved.confidence(),
resolved == null ? null : resolved.reason(),
status,
meta == null ? Map.of() : meta
);
}
private static String trimToNull(String s) {
if (s == null) return null;
String t = s.trim();
return t.isEmpty() ? null : t;
}
}

View File

@@ -1,474 +1,474 @@
package group.goforward.battlbuilder.classification.impl;
import group.goforward.battlbuilder.classification.ClassificationResult;
import group.goforward.battlbuilder.classification.ProductClassifier;
import group.goforward.battlbuilder.model.MerchantCategoryMap;
import group.goforward.battlbuilder.model.Product;
import group.goforward.battlbuilder.repo.MerchantCategoryMapRepository;
import group.goforward.battlbuilder.repo.ProductOfferRepository;
import org.springframework.stereotype.Component;
import java.util.LinkedHashMap;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.HashMap;
@Component
public class ProductClassifierImpl implements ProductClassifier {
/**
* Bump this whenever you change classification logic.
* Useful for debugging and for "reconcile" runs.
*/
private static final String VERSION = "2025.12.29";
/**
* Non-classifying "categories" some merchants use as merchandising labels.
* These are orthogonal to part role (condition/marketing), so we ignore them
* to avoid polluting merchant_category_map.
*/
// IMPORTANT:
// These tokens represent NON-SEMANTIC merchant categories.
// They should NEVER be mapped to part roles.
// If you're tempted to add them to merchant_category_map, add them here instead.
private static final Set<String> NON_CLASSIFYING_TOKENS = Set.of(
// marketing / promos
"sale",
"clearance",
"deal",
"special",
"markdown",
"promo",
// merchandising buckets
"general",
"apparel",
"accessories",
"parts",
"spare parts",
"shop all",
"lineup",
"collection",
// bundles / kits / sets
"bundle",
"kit",
"set",
"builder set",
// color / variant groupings
"colors",
"finish",
"variant",
// caliber / platform / type filters (not part roles)
"bolt action",
"ar15",
"ar-15",
"rifles",
"creedmoor",
"winchester",
"grendel",
"legend",
// promo shelves
"savings",
// brand shelves
"magpul",
// caliber shelves (not part roles)
"5.56",
"5.56 nato",
"223",
".223",
"223 wylde",
".223 wylde",
"wylde",
"nato"
);
private final MerchantCategoryMapRepository merchantCategoryMapRepository;
private final ProductOfferRepository productOfferRepository;
public ProductClassifierImpl(MerchantCategoryMapRepository merchantCategoryMapRepository,
ProductOfferRepository productOfferRepository) {
this.merchantCategoryMapRepository = merchantCategoryMapRepository;
this.productOfferRepository = productOfferRepository;
}
@Override
public ClassificationResult classifyProduct(Product product) {
// Backwards compatible path (existing callers)
return classifyProduct(product, null);
}
@Override
public ClassificationResult classifyProduct(Product product, Integer resolvedMerchantId) {
// ===== Guardrails =====
if (product == null || product.getId() == null) {
return ClassificationResult.unknown(VERSION, "Missing product or product.id.");
}
if (isBlank(product.getRawCategoryKey())) {
return ClassificationResult.unknown(VERSION, "Missing rawCategoryKey on product.");
}
// Normalize inputs early so we don't accidentally compare mismatched strings.
String rawCategory = normalizeRawCategory(product.getRawCategoryKey());
String platform = normalizePlatform(product.getPlatform()); // may be null
// Resolve merchant-of-record:
// - Prefer caller-provided merchantId (batch-resolved in reconcile to avoid N+1)
// - Fall back to DB lookup for normal runtime usage
Integer merchantId = resolvedMerchantId;
if (merchantId == null) {
merchantId = productOfferRepository
.findPrimaryMerchantIdByFirstSeen(product.getId())
.orElse(null);
}
// ===== Ignore non-classifying categories (e.g. Clearance/Blemished/Caliber shelves) =====
if (isNonClassifyingCategory(rawCategory)) {
return new ClassificationResult(
null,
"ignored_category",
"Ignored non-classifying merchant category: " + rawCategory,
VERSION,
0.0,
meta(
"resolvedMerchantId", merchantId,
"rawCategory", rawCategory,
"platform", platform
)
);
}
// If we need merchant mapping or rules, merchantId is required
if (merchantId == null) {
return ClassificationResult.unknown(
VERSION,
"No offers found for product; cannot determine merchant mapping."
);
}
// ===== Rule-based split: broad "Gas System" buckets =====
if (isGasSystemBucket(rawCategory)) {
String name = (product.getName() == null) ? "" : product.getName();
String n = name.toLowerCase(Locale.ROOT);
if (containsAny(n, "gas block + tube", "gas block and tube", "gas block w/ tube", "gas block with tube", "block/tube", "block + tube")) {
return new ClassificationResult(
"gas-block-tube-combo",
"rules_gas_system",
"Gas System bucket: inferred gas-block-tube-combo from name.",
VERSION,
0.92,
meta("resolvedMerchantId", merchantId, "rawCategory", rawCategory, "platform", platform, "rule", "gas-system:combo")
);
}
if (containsAny(n, "roll pin", "gas tube roll pin", "gastube roll pin", "tube roll pin")) {
return new ClassificationResult(
"gas-tube-roll-pin",
"rules_gas_system",
"Gas System bucket: inferred gas-tube-roll-pin from name.",
VERSION,
0.90,
meta("resolvedMerchantId", merchantId, "rawCategory", rawCategory, "platform", platform, "rule", "gas-system:roll-pin")
);
}
if (containsAny(n, "gas tube", "gastube")) {
return new ClassificationResult(
"gas-tube",
"rules_gas_system",
"Gas System bucket: inferred gas-tube from name.",
VERSION,
0.90,
meta("resolvedMerchantId", merchantId, "rawCategory", rawCategory, "platform", platform, "rule", "gas-system:tube")
);
}
if (containsAny(n, "gas block", "gasblock")) {
return new ClassificationResult(
"gas-block",
"rules_gas_system",
"Gas System bucket: inferred gas-block from name.",
VERSION,
0.90,
meta("resolvedMerchantId", merchantId, "rawCategory", rawCategory, "platform", platform, "rule", "gas-system:block")
);
}
return new ClassificationResult(
null,
"rules_gas_system",
"Gas System bucket: no confident keyword match (leave unmapped).",
VERSION,
0.0,
meta("resolvedMerchantId", merchantId, "rawCategory", rawCategory, "platform", platform, "rule", "gas-system:none")
);
}
// ===== Primary classification: merchant_category_map lookup =====
Optional<MerchantCategoryMap> best =
merchantCategoryMapRepository.findBest(merchantId, rawCategory, platform);
if (best.isPresent()) {
MerchantCategoryMap map = best.get();
String role = trimToNull(map.getCanonicalPartRole());
if (role == null) {
return new ClassificationResult(
null,
"merchant_mapping",
"Mapping found but canonicalPartRole is empty (needs admin mapping).",
VERSION,
0.20,
meta(
"resolvedMerchantId", merchantId,
"rawCategory", rawCategory,
"platform", platform,
"mapId", map.getId(),
"mapPlatform", map.getPlatform()
)
);
}
double confidence =
platform != null && platform.equalsIgnoreCase(trimToNull(map.getPlatform())) ? 0.98 :
"ANY".equalsIgnoreCase(trimToNull(map.getPlatform())) ? 0.93 :
map.getPlatform() == null ? 0.90 : 0.90;
return new ClassificationResult(
role,
"merchant_mapping",
"Mapped via merchant_category_map (canonical_part_role).",
VERSION,
confidence,
meta(
"resolvedMerchantId", merchantId,
"rawCategory", rawCategory,
"platform", platform,
"mapId", map.getId(),
"mapPlatform", map.getPlatform()
)
);
}
return new ClassificationResult(
null,
"merchant_mapping",
"No enabled mapping found for resolved merchant/rawCategory/platform.",
VERSION,
0.0,
meta(
"resolvedMerchantId", merchantId,
"rawCategory", rawCategory,
"platform", platform
)
);
}
@Override
public ClassificationResult classifyProduct(
Product product,
Integer resolvedMerchantId,
Map<String, Optional<MerchantCategoryMap>> mappingMemo
) {
// Safety: if caller passes null, fall back cleanly
if (mappingMemo == null) {
mappingMemo = new HashMap<>();
}
// ===== Guardrails =====
if (product == null || product.getId() == null) {
return ClassificationResult.unknown(VERSION, "Missing product or product.id.");
}
if (isBlank(product.getRawCategoryKey())) {
return ClassificationResult.unknown(VERSION, "Missing rawCategoryKey on product.");
}
String rawCategory = normalizeRawCategory(product.getRawCategoryKey());
String platform = normalizePlatform(product.getPlatform());
Integer merchantId = resolvedMerchantId;
if (merchantId == null) {
merchantId = productOfferRepository
.findPrimaryMerchantIdByFirstSeen(product.getId())
.orElse(null);
}
// Ignore merchandising/filters
if (isNonClassifyingCategory(rawCategory)) {
return new ClassificationResult(
null,
"ignored_category",
"Ignored non-classifying merchant category: " + rawCategory,
VERSION,
0.0,
meta("resolvedMerchantId", merchantId, "rawCategory", rawCategory, "platform", platform)
);
}
if (merchantId == null) {
return ClassificationResult.unknown(
VERSION,
"No offers found for product; cannot determine merchant mapping."
);
}
// ===== Gas System rules (unchanged) =====
if (isGasSystemBucket(rawCategory)) {
// reuse your existing logic by calling the 2-arg version
return classifyProduct(product, merchantId);
}
// ===== Memoized merchant_category_map lookup =====
// Key is normalized to maximize cache hits across equivalent strings.
String memoKey =
merchantId + "|" +
(platform == null ? "null" : platform.toUpperCase(Locale.ROOT)) + "|" +
rawCategory.toLowerCase(Locale.ROOT).trim();
final Integer mid = merchantId;
final String rc = rawCategory;
final String pl = platform;
Optional<MerchantCategoryMap> best = mappingMemo.computeIfAbsent(
memoKey,
k -> merchantCategoryMapRepository.findBest(mid, rc, pl)
);
if (best.isPresent()) {
MerchantCategoryMap map = best.get();
String role = trimToNull(map.getCanonicalPartRole());
if (role == null) {
return new ClassificationResult(
null,
"merchant_mapping",
"Mapping found but canonicalPartRole is empty (needs admin mapping).",
VERSION,
0.20,
meta(
"resolvedMerchantId", merchantId,
"rawCategory", rawCategory,
"platform", platform,
"mapId", map.getId(),
"mapPlatform", map.getPlatform()
)
);
}
double confidence =
platform != null && platform.equalsIgnoreCase(trimToNull(map.getPlatform())) ? 0.98 :
"ANY".equalsIgnoreCase(trimToNull(map.getPlatform())) ? 0.93 :
map.getPlatform() == null ? 0.90 : 0.90;
return new ClassificationResult(
role,
"merchant_mapping",
"Mapped via merchant_category_map (canonical_part_role).",
VERSION,
confidence,
meta(
"resolvedMerchantId", merchantId,
"rawCategory", rawCategory,
"platform", platform,
"mapId", map.getId(),
"mapPlatform", map.getPlatform()
)
);
}
return new ClassificationResult(
null,
"merchant_mapping",
"No enabled mapping found for resolved merchant/rawCategory/platform.",
VERSION,
0.0,
meta("resolvedMerchantId", merchantId, "rawCategory", rawCategory, "platform", platform)
);
}
/**
* Returns true if this raw category is a merchandising/condition label rather than a taxonomy path.
*/
private boolean isNonClassifyingCategory(String rawCategory) {
if (rawCategory == null) return false;
String v = rawCategory.toLowerCase(Locale.ROOT).trim();
// If it's a breadcrumb/taxonomy path, DO NOT treat it as a merchandising label.
// Example: "Gunsmithing > ... > Gun Care & Accessories > Ar-15 Complete Uppers"
if (v.contains(">")) return false;
// For flat categories, apply token matching.
return NON_CLASSIFYING_TOKENS.stream().anyMatch(v::contains);
}
/**
* Normalize merchant-provided category strings.
* Keep it light: trim + collapse whitespace.
* (If needed later: unify case or canonicalize separators.)
*/
private String normalizeRawCategory(String raw) {
if (raw == null) return null;
return raw.trim().replaceAll("\\s+", " ");
}
/**
* Normalize platform values. We keep it consistent with expected inputs like "AR-15".
* Uppercasing is okay because "AR-15" remains "AR-15".
*/
private String normalizePlatform(String p) {
if (p == null) return null;
String t = p.trim();
return t.isEmpty() ? null : t.toUpperCase(Locale.ROOT);
}
private boolean isBlank(String s) {
return s == null || s.trim().isEmpty();
}
private String trimToNull(String s) {
if (s == null) return null;
String t = s.trim();
return t.isEmpty() ? null : t;
}
private boolean isGasSystemBucket(String rawCategory) {
if (rawCategory == null) return false;
String v = rawCategory.toLowerCase(Locale.ROOT);
return v.contains("gas system");
}
private boolean containsAny(String haystack, String... needles) {
if (haystack == null) return false;
for (String n : needles) {
if (n != null && !n.isBlank() && haystack.contains(n)) return true;
}
return false;
}
/**
* Safe metadata builder.
* IMPORTANT: Map.of(...) throws if any key/value is null; this helper skips null entries.
*/
private static Map<String, Object> meta(Object... kv) {
Map<String, Object> m = new LinkedHashMap<>();
for (int i = 0; i < kv.length; i += 2) {
String k = (String) kv[i];
Object v = kv[i + 1];
if (k != null && v != null) m.put(k, v);
}
return m;
}
package group.goforward.battlbuilder.classification.impl;
import group.goforward.battlbuilder.classification.ClassificationResult;
import group.goforward.battlbuilder.classification.ProductClassifier;
import group.goforward.battlbuilder.model.MerchantCategoryMap;
import group.goforward.battlbuilder.model.Product;
import group.goforward.battlbuilder.repo.MerchantCategoryMapRepository;
import group.goforward.battlbuilder.repo.ProductOfferRepository;
import org.springframework.stereotype.Component;
import java.util.LinkedHashMap;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.HashMap;
@Component
public class ProductClassifierImpl implements ProductClassifier {
/**
* Bump this whenever you change classification logic.
* Useful for debugging and for "reconcile" runs.
*/
private static final String VERSION = "2025.12.29";
/**
* Non-classifying "categories" some merchants use as merchandising labels.
* These are orthogonal to part role (condition/marketing), so we ignore them
* to avoid polluting merchant_category_map.
*/
// IMPORTANT:
// These tokens represent NON-SEMANTIC merchant categories.
// They should NEVER be mapped to part roles.
// If you're tempted to add them to merchant_category_map, add them here instead.
private static final Set<String> NON_CLASSIFYING_TOKENS = Set.of(
// marketing / promos
"sale",
"clearance",
"deal",
"special",
"markdown",
"promo",
// merchandising buckets
"general",
"apparel",
"accessories",
"parts",
"spare parts",
"shop all",
"lineup",
"collection",
// bundles / kits / sets
"bundle",
"kit",
"set",
"builder set",
// color / variant groupings
"colors",
"finish",
"variant",
// caliber / platform / type filters (not part roles)
"bolt action",
"ar15",
"ar-15",
"rifles",
"creedmoor",
"winchester",
"grendel",
"legend",
// promo shelves
"savings",
// brand shelves
"magpul",
// caliber shelves (not part roles)
"5.56",
"5.56 nato",
"223",
".223",
"223 wylde",
".223 wylde",
"wylde",
"nato"
);
private final MerchantCategoryMapRepository merchantCategoryMapRepository;
private final ProductOfferRepository productOfferRepository;
public ProductClassifierImpl(MerchantCategoryMapRepository merchantCategoryMapRepository,
ProductOfferRepository productOfferRepository) {
this.merchantCategoryMapRepository = merchantCategoryMapRepository;
this.productOfferRepository = productOfferRepository;
}
@Override
public ClassificationResult classifyProduct(Product product) {
// Backwards compatible path (existing callers)
return classifyProduct(product, null);
}
@Override
public ClassificationResult classifyProduct(Product product, Integer resolvedMerchantId) {
// ===== Guardrails =====
if (product == null || product.getId() == null) {
return ClassificationResult.unknown(VERSION, "Missing product or product.id.");
}
if (isBlank(product.getRawCategoryKey())) {
return ClassificationResult.unknown(VERSION, "Missing rawCategoryKey on product.");
}
// Normalize inputs early so we don't accidentally compare mismatched strings.
String rawCategory = normalizeRawCategory(product.getRawCategoryKey());
String platform = normalizePlatform(product.getPlatform()); // may be null
// Resolve merchant-of-record:
// - Prefer caller-provided merchantId (batch-resolved in reconcile to avoid N+1)
// - Fall back to DB lookup for normal runtime usage
Integer merchantId = resolvedMerchantId;
if (merchantId == null) {
merchantId = productOfferRepository
.findPrimaryMerchantIdByFirstSeen(product.getId())
.orElse(null);
}
// ===== Ignore non-classifying categories (e.g. Clearance/Blemished/Caliber shelves) =====
if (isNonClassifyingCategory(rawCategory)) {
return new ClassificationResult(
null,
"ignored_category",
"Ignored non-classifying merchant category: " + rawCategory,
VERSION,
0.0,
meta(
"resolvedMerchantId", merchantId,
"rawCategory", rawCategory,
"platform", platform
)
);
}
// If we need merchant mapping or rules, merchantId is required
if (merchantId == null) {
return ClassificationResult.unknown(
VERSION,
"No offers found for product; cannot determine merchant mapping."
);
}
// ===== Rule-based split: broad "Gas System" buckets =====
if (isGasSystemBucket(rawCategory)) {
String name = (product.getName() == null) ? "" : product.getName();
String n = name.toLowerCase(Locale.ROOT);
if (containsAny(n, "gas block + tube", "gas block and tube", "gas block w/ tube", "gas block with tube", "block/tube", "block + tube")) {
return new ClassificationResult(
"gas-block-tube-combo",
"rules_gas_system",
"Gas System bucket: inferred gas-block-tube-combo from name.",
VERSION,
0.92,
meta("resolvedMerchantId", merchantId, "rawCategory", rawCategory, "platform", platform, "rule", "gas-system:combo")
);
}
if (containsAny(n, "roll pin", "gas tube roll pin", "gastube roll pin", "tube roll pin")) {
return new ClassificationResult(
"gas-tube-roll-pin",
"rules_gas_system",
"Gas System bucket: inferred gas-tube-roll-pin from name.",
VERSION,
0.90,
meta("resolvedMerchantId", merchantId, "rawCategory", rawCategory, "platform", platform, "rule", "gas-system:roll-pin")
);
}
if (containsAny(n, "gas tube", "gastube")) {
return new ClassificationResult(
"gas-tube",
"rules_gas_system",
"Gas System bucket: inferred gas-tube from name.",
VERSION,
0.90,
meta("resolvedMerchantId", merchantId, "rawCategory", rawCategory, "platform", platform, "rule", "gas-system:tube")
);
}
if (containsAny(n, "gas block", "gasblock")) {
return new ClassificationResult(
"gas-block",
"rules_gas_system",
"Gas System bucket: inferred gas-block from name.",
VERSION,
0.90,
meta("resolvedMerchantId", merchantId, "rawCategory", rawCategory, "platform", platform, "rule", "gas-system:block")
);
}
return new ClassificationResult(
null,
"rules_gas_system",
"Gas System bucket: no confident keyword match (leave unmapped).",
VERSION,
0.0,
meta("resolvedMerchantId", merchantId, "rawCategory", rawCategory, "platform", platform, "rule", "gas-system:none")
);
}
// ===== Primary classification: merchant_category_map lookup =====
Optional<MerchantCategoryMap> best =
merchantCategoryMapRepository.findBest(merchantId, rawCategory, platform);
if (best.isPresent()) {
MerchantCategoryMap map = best.get();
String role = trimToNull(map.getCanonicalPartRole());
if (role == null) {
return new ClassificationResult(
null,
"merchant_mapping",
"Mapping found but canonicalPartRole is empty (needs admin mapping).",
VERSION,
0.20,
meta(
"resolvedMerchantId", merchantId,
"rawCategory", rawCategory,
"platform", platform,
"mapId", map.getId(),
"mapPlatform", map.getPlatform()
)
);
}
double confidence =
platform != null && platform.equalsIgnoreCase(trimToNull(map.getPlatform())) ? 0.98 :
"ANY".equalsIgnoreCase(trimToNull(map.getPlatform())) ? 0.93 :
map.getPlatform() == null ? 0.90 : 0.90;
return new ClassificationResult(
role,
"merchant_mapping",
"Mapped via merchant_category_map (canonical_part_role).",
VERSION,
confidence,
meta(
"resolvedMerchantId", merchantId,
"rawCategory", rawCategory,
"platform", platform,
"mapId", map.getId(),
"mapPlatform", map.getPlatform()
)
);
}
return new ClassificationResult(
null,
"merchant_mapping",
"No enabled mapping found for resolved merchant/rawCategory/platform.",
VERSION,
0.0,
meta(
"resolvedMerchantId", merchantId,
"rawCategory", rawCategory,
"platform", platform
)
);
}
@Override
public ClassificationResult classifyProduct(
Product product,
Integer resolvedMerchantId,
Map<String, Optional<MerchantCategoryMap>> mappingMemo
) {
// Safety: if caller passes null, fall back cleanly
if (mappingMemo == null) {
mappingMemo = new HashMap<>();
}
// ===== Guardrails =====
if (product == null || product.getId() == null) {
return ClassificationResult.unknown(VERSION, "Missing product or product.id.");
}
if (isBlank(product.getRawCategoryKey())) {
return ClassificationResult.unknown(VERSION, "Missing rawCategoryKey on product.");
}
String rawCategory = normalizeRawCategory(product.getRawCategoryKey());
String platform = normalizePlatform(product.getPlatform());
Integer merchantId = resolvedMerchantId;
if (merchantId == null) {
merchantId = productOfferRepository
.findPrimaryMerchantIdByFirstSeen(product.getId())
.orElse(null);
}
// Ignore merchandising/filters
if (isNonClassifyingCategory(rawCategory)) {
return new ClassificationResult(
null,
"ignored_category",
"Ignored non-classifying merchant category: " + rawCategory,
VERSION,
0.0,
meta("resolvedMerchantId", merchantId, "rawCategory", rawCategory, "platform", platform)
);
}
if (merchantId == null) {
return ClassificationResult.unknown(
VERSION,
"No offers found for product; cannot determine merchant mapping."
);
}
// ===== Gas System rules (unchanged) =====
if (isGasSystemBucket(rawCategory)) {
// reuse your existing logic by calling the 2-arg version
return classifyProduct(product, merchantId);
}
// ===== Memoized merchant_category_map lookup =====
// Key is normalized to maximize cache hits across equivalent strings.
String memoKey =
merchantId + "|" +
(platform == null ? "null" : platform.toUpperCase(Locale.ROOT)) + "|" +
rawCategory.toLowerCase(Locale.ROOT).trim();
final Integer mid = merchantId;
final String rc = rawCategory;
final String pl = platform;
Optional<MerchantCategoryMap> best = mappingMemo.computeIfAbsent(
memoKey,
k -> merchantCategoryMapRepository.findBest(mid, rc, pl)
);
if (best.isPresent()) {
MerchantCategoryMap map = best.get();
String role = trimToNull(map.getCanonicalPartRole());
if (role == null) {
return new ClassificationResult(
null,
"merchant_mapping",
"Mapping found but canonicalPartRole is empty (needs admin mapping).",
VERSION,
0.20,
meta(
"resolvedMerchantId", merchantId,
"rawCategory", rawCategory,
"platform", platform,
"mapId", map.getId(),
"mapPlatform", map.getPlatform()
)
);
}
double confidence =
platform != null && platform.equalsIgnoreCase(trimToNull(map.getPlatform())) ? 0.98 :
"ANY".equalsIgnoreCase(trimToNull(map.getPlatform())) ? 0.93 :
map.getPlatform() == null ? 0.90 : 0.90;
return new ClassificationResult(
role,
"merchant_mapping",
"Mapped via merchant_category_map (canonical_part_role).",
VERSION,
confidence,
meta(
"resolvedMerchantId", merchantId,
"rawCategory", rawCategory,
"platform", platform,
"mapId", map.getId(),
"mapPlatform", map.getPlatform()
)
);
}
return new ClassificationResult(
null,
"merchant_mapping",
"No enabled mapping found for resolved merchant/rawCategory/platform.",
VERSION,
0.0,
meta("resolvedMerchantId", merchantId, "rawCategory", rawCategory, "platform", platform)
);
}
/**
* Returns true if this raw category is a merchandising/condition label rather than a taxonomy path.
*/
private boolean isNonClassifyingCategory(String rawCategory) {
if (rawCategory == null) return false;
String v = rawCategory.toLowerCase(Locale.ROOT).trim();
// If it's a breadcrumb/taxonomy path, DO NOT treat it as a merchandising label.
// Example: "Gunsmithing > ... > Gun Care & Accessories > Ar-15 Complete Uppers"
if (v.contains(">")) return false;
// For flat categories, apply token matching.
return NON_CLASSIFYING_TOKENS.stream().anyMatch(v::contains);
}
/**
* Normalize merchant-provided category strings.
* Keep it light: trim + collapse whitespace.
* (If needed later: unify case or canonicalize separators.)
*/
private String normalizeRawCategory(String raw) {
if (raw == null) return null;
return raw.trim().replaceAll("\\s+", " ");
}
/**
* Normalize platform values. We keep it consistent with expected inputs like "AR-15".
* Uppercasing is okay because "AR-15" remains "AR-15".
*/
private String normalizePlatform(String p) {
if (p == null) return null;
String t = p.trim();
return t.isEmpty() ? null : t.toUpperCase(Locale.ROOT);
}
private boolean isBlank(String s) {
return s == null || s.trim().isEmpty();
}
private String trimToNull(String s) {
if (s == null) return null;
String t = s.trim();
return t.isEmpty() ? null : t;
}
private boolean isGasSystemBucket(String rawCategory) {
if (rawCategory == null) return false;
String v = rawCategory.toLowerCase(Locale.ROOT);
return v.contains("gas system");
}
private boolean containsAny(String haystack, String... needles) {
if (haystack == null) return false;
for (String n : needles) {
if (n != null && !n.isBlank() && haystack.contains(n)) return true;
}
return false;
}
/**
* Safe metadata builder.
* IMPORTANT: Map.of(...) throws if any key/value is null; this helper skips null entries.
*/
private static Map<String, Object> meta(Object... kv) {
Map<String, Object> m = new LinkedHashMap<>();
for (int i = 0; i < kv.length; i += 2) {
String k = (String) kv[i];
Object v = kv[i + 1];
if (k != null && v != null) m.put(k, v);
}
return m;
}
}

View File

@@ -1,45 +1,45 @@
package group.goforward.battlbuilder.cli;
import group.goforward.battlbuilder.service.auth.impl.BetaInviteService;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;
@Component
@Profile("!prod")
@ConditionalOnProperty(
name = "app.beta.invite.run",
havingValue = "true"
)
public class BetaInviteCliRunner implements CommandLineRunner {
private final BetaInviteService inviteService;
@Value("${app.beta.invite.limit:0}")
private int limit;
@Value("${app.beta.invite.dryRun:true}")
private boolean dryRun;
@Value("${app.beta.invite.tokenMinutes:30}")
private int tokenMinutes;
public BetaInviteCliRunner(BetaInviteService inviteService) {
this.inviteService = inviteService;
}
@Override
public void run(String... args) {
int count = inviteService.inviteAllBetaUsers(tokenMinutes, limit, dryRun);
System.out.println(
"✅ Beta invite runner complete. processed=" + count + " dryRun=" + dryRun
);
// Exit so it behaves like a CLI command
System.exit(0);
}
package group.goforward.battlbuilder.cli;
import group.goforward.battlbuilder.service.auth.impl.BetaInviteService;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;
@Component
@Profile("!prod")
@ConditionalOnProperty(
name = "app.beta.invite.run",
havingValue = "true"
)
public class BetaInviteCliRunner implements CommandLineRunner {
private final BetaInviteService inviteService;
@Value("${app.beta.invite.limit:0}")
private int limit;
@Value("${app.beta.invite.dryRun:true}")
private boolean dryRun;
@Value("${app.beta.invite.tokenMinutes:30}")
private int tokenMinutes;
public BetaInviteCliRunner(BetaInviteService inviteService) {
this.inviteService = inviteService;
}
@Override
public void run(String... args) {
int count = inviteService.inviteAllBetaUsers(tokenMinutes, limit, dryRun);
System.out.println(
"✅ Beta invite runner complete. processed=" + count + " dryRun=" + dryRun
);
// Exit so it behaves like a CLI command
System.exit(0);
}
}

View File

@@ -1,11 +1,11 @@
/**
* Utility controller package for the BattlBuilder application.
* <p>
* Contains utility REST controller for email handling and
* health check operations.
*
* @author Forward Group, LLC
* @version 1.0
* @since 2025-12-10
*/
package group.goforward.battlbuilder.common;
/**
* Utility controller package for the BattlBuilder application.
* <p>
* Contains utility REST controller for email handling and
* health check operations.
*
* @author Forward Group, LLC
* @version 1.0
* @since 2025-12-10
*/
package group.goforward.battlbuilder.common;

View File

@@ -1,22 +1,22 @@
package group.goforward.battlbuilder.config;
import io.minio.MinioClient;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class MinioConfig {
@Bean
public MinioClient minioClient(
@Value("${minio.endpoint}") String endpoint,
@Value("${minio.access-key}") String accessKey,
@Value("${minio.secret-key}") String secretKey
) {
return MinioClient.builder()
.endpoint(endpoint)
.credentials(accessKey, secretKey)
.build();
}
}
package group.goforward.battlbuilder.config;
import io.minio.MinioClient;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class MinioConfig {
@Bean
public MinioClient minioClient(
@Value("${minio.endpoint}") String endpoint,
@Value("${minio.access-key}") String accessKey,
@Value("${minio.secret-key}") String secretKey
) {
return MinioClient.builder()
.endpoint(endpoint)
.credentials(accessKey, secretKey)
.build();
}
}

View File

@@ -1,17 +1,17 @@
/*
package group.goforward.battlbuilder.configuration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
@Configuration
public class PasswordConfig {
@Bean
public PasswordEncoder passwordEncoder() {
// // BCrypt default password
return new BCryptPasswordEncoder();
}
}*/
/*
package group.goforward.battlbuilder.configuration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
@Configuration
public class PasswordConfig {
@Bean
public PasswordEncoder passwordEncoder() {
// // BCrypt default password
return new BCryptPasswordEncoder();
}
}*/

View File

@@ -1,93 +1,93 @@
package group.goforward.battlbuilder.config;
import group.goforward.battlbuilder.security.JwtAuthenticationFilter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.AnonymousAuthenticationFilter;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.List;
@Configuration
@EnableWebSecurity
public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthenticationFilter;
public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter) {
this.jwtAuthenticationFilter = jwtAuthenticationFilter;
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable)
.cors(c -> c.configurationSource(corsConfigurationSource()))
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
// ----------------------------
// Public
// ----------------------------
.requestMatchers("/api/auth/**").permitAll()
.requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll()
.requestMatchers("/actuator/health", "/actuator/info").permitAll()
.requestMatchers("/api/products/gunbuilder/**").permitAll()
// Public builds feed + public build detail (1 path segment only)
.requestMatchers(HttpMethod.GET, "/api/v1/builds").permitAll()
.requestMatchers(HttpMethod.GET, "/api/v1/builds/*").permitAll()
// ----------------------------
// Protected
// ----------------------------
.requestMatchers("/api/v1/builds/me/**").authenticated()
.requestMatchers("/api/v1/admin/**").hasRole("ADMIN")
// Everything else (adjust later as you lock down)
.anyRequest().permitAll()
)
// run JWT before AnonymousAuth sets principal="anonymousUser"
.addFilterBefore(jwtAuthenticationFilter, AnonymousAuthenticationFilter.class);
return http.build();
}
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration cfg = new CorsConfiguration();
cfg.setAllowedOrigins(List.of("http://localhost:3000"));
cfg.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
cfg.setAllowedHeaders(List.of("Authorization", "Content-Type"));
cfg.setExposedHeaders(List.of("Authorization"));
cfg.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", cfg);
return source;
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration)
throws Exception {
return configuration.getAuthenticationManager();
}
package group.goforward.battlbuilder.config;
import group.goforward.battlbuilder.security.JwtAuthenticationFilter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.AnonymousAuthenticationFilter;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.List;
@Configuration
@EnableWebSecurity
public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthenticationFilter;
public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter) {
this.jwtAuthenticationFilter = jwtAuthenticationFilter;
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable)
.cors(c -> c.configurationSource(corsConfigurationSource()))
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
// ----------------------------
// Public
// ----------------------------
.requestMatchers("/api/auth/**").permitAll()
.requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll()
.requestMatchers("/actuator/health", "/actuator/info").permitAll()
.requestMatchers("/api/products/gunbuilder/**").permitAll()
// Public builds feed + public build detail (1 path segment only)
.requestMatchers(HttpMethod.GET, "/api/v1/builds").permitAll()
.requestMatchers(HttpMethod.GET, "/api/v1/builds/*").permitAll()
// ----------------------------
// Protected
// ----------------------------
.requestMatchers("/api/v1/builds/me/**").authenticated()
.requestMatchers("/api/v1/admin/**").hasRole("ADMIN")
// Everything else (adjust later as you lock down)
.anyRequest().permitAll()
)
// run JWT before AnonymousAuth sets principal="anonymousUser"
.addFilterBefore(jwtAuthenticationFilter, AnonymousAuthenticationFilter.class);
return http.build();
}
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration cfg = new CorsConfiguration();
cfg.setAllowedOrigins(List.of("http://localhost:3000"));
cfg.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
cfg.setAllowedHeaders(List.of("Authorization", "Content-Type"));
cfg.setExposedHeaders(List.of("Authorization"));
cfg.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", cfg);
return source;
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration)
throws Exception {
return configuration.getAuthenticationManager();
}
}

View File

@@ -1,11 +1,11 @@
/**
* Configuration package for the BattlBuilder application.
* <p>
* Contains Spring configuration classes for security, CORS, JPA,
* caching, and password encoding.
*
* @author Forward Group, LLC
* @version 1.0
* @since 2025-12-10
*/
package group.goforward.battlbuilder.config;
/**
* Configuration package for the BattlBuilder application.
* <p>
* Contains Spring configuration classes for security, CORS, JPA,
* caching, and password encoding.
*
* @author Forward Group, LLC
* @version 1.0
* @since 2025-12-10
*/
package group.goforward.battlbuilder.config;

View File

@@ -1,227 +1,227 @@
package group.goforward.battlbuilder.controller;
import group.goforward.battlbuilder.model.User;
import group.goforward.battlbuilder.repo.UserRepository;
import group.goforward.battlbuilder.security.JwtService;
import group.goforward.battlbuilder.service.auth.BetaAuthService;
import group.goforward.battlbuilder.web.dto.auth.AuthResponse;
import group.goforward.battlbuilder.web.dto.auth.BetaSignupRequest;
import group.goforward.battlbuilder.web.dto.auth.LoginRequest;
import group.goforward.battlbuilder.web.dto.auth.RegisterRequest;
import group.goforward.battlbuilder.web.dto.auth.TokenRequest;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.*;
import jakarta.servlet.http.HttpServletRequest;
import java.time.OffsetDateTime;
import java.util.Map;
import java.util.UUID;
@RestController
@RequestMapping({"/api/auth", "/api/v1/auth"})
@CrossOrigin
public class AuthController {
private final UserRepository users;
private final PasswordEncoder passwordEncoder;
private final JwtService jwtService;
private final BetaAuthService betaAuthService;
public AuthController(
UserRepository users,
PasswordEncoder passwordEncoder,
JwtService jwtService,
BetaAuthService betaAuthService
) {
this.users = users;
this.passwordEncoder = passwordEncoder;
this.jwtService = jwtService;
this.betaAuthService = betaAuthService;
}
// ---------------------------------------------------------------------
// Standard Auth
// ---------------------------------------------------------------------
@PostMapping("/register")
public ResponseEntity<?> register(
@RequestBody RegisterRequest request,
HttpServletRequest httpRequest
) {
String email = request.getEmail().trim().toLowerCase();
// ✅ Enforce acceptance
if (!Boolean.TRUE.equals(request.getAcceptedTos())) {
return ResponseEntity
.status(HttpStatus.BAD_REQUEST)
.body("Terms of Service acceptance is required");
}
if (users.existsByEmailIgnoreCaseAndDeletedAtIsNull(email)) {
return ResponseEntity
.status(HttpStatus.CONFLICT)
.body("Email is already registered");
}
User user = new User();
user.setUuid(UUID.randomUUID());
user.setEmail(email);
user.setPasswordHash(passwordEncoder.encode(request.getPassword()));
user.setPasswordSetAt(OffsetDateTime.now());
user.setDisplayName(request.getDisplayName());
user.setRole("USER");
user.setActive(true);
user.setCreatedAt(OffsetDateTime.now());
user.setUpdatedAt(OffsetDateTime.now());
// ✅ Record ToS acceptance evidence
String tosVersion = StringUtils.hasText(request.getTosVersion())
? request.getTosVersion().trim()
: "2025-12-27"; // keep in sync with your ToS page
user.setTosAcceptedAt(OffsetDateTime.now());
user.setTosVersion(tosVersion);
user.setTosIp(extractClientIp(httpRequest));
user.setTosUserAgent(httpRequest.getHeader("User-Agent"));
users.save(user);
String token = jwtService.generateToken(user);
return ResponseEntity
.status(HttpStatus.CREATED)
.body(new AuthResponse(
token,
user.getEmail(),
user.getDisplayName(),
user.getRole()
));
}
private String extractClientIp(HttpServletRequest request) {
String xff = request.getHeader("X-Forwarded-For");
if (StringUtils.hasText(xff)) {
// first IP in the list
return xff.split(",")[0].trim();
}
String realIp = request.getHeader("X-Real-IP");
if (StringUtils.hasText(realIp)) return realIp.trim();
return request.getRemoteAddr();
}
@PostMapping("/login")
public ResponseEntity<?> login(@RequestBody LoginRequest request) {
String email = request.getEmail().trim().toLowerCase();
User user = users.findByEmailIgnoreCaseAndDeletedAtIsNull(email)
.orElse(null);
if (user == null || !user.isActive()) {
return ResponseEntity
.status(HttpStatus.UNAUTHORIZED)
.body("Invalid credentials");
}
if (!passwordEncoder.matches(request.getPassword(), user.getPasswordHash())) {
return ResponseEntity
.status(HttpStatus.UNAUTHORIZED)
.body("Invalid credentials");
}
user.setLastLoginAt(OffsetDateTime.now());
user.incrementLoginCount();
user.setUpdatedAt(OffsetDateTime.now());
users.save(user);
String token = jwtService.generateToken(user);
return ResponseEntity.ok(
new AuthResponse(
token,
user.getEmail(),
user.getDisplayName(),
user.getRole()
)
);
}
// ---------------------------------------------------------------------
// Beta Flow
// ---------------------------------------------------------------------
@PostMapping("/beta/signup")
public ResponseEntity<Map<String, Object>> betaSignup(@RequestBody BetaSignupRequest request) {
// Always return OK to prevent email enumeration
try {
betaAuthService.signup(request.getEmail(), request.getUseCase());
} catch (Exception ignored) {
// Intentionally swallow errors here to avoid leaking state
}
return ResponseEntity.ok(Map.of("ok", true));
}
@PostMapping("/beta/confirm")
public ResponseEntity<?> betaConfirm(@RequestBody TokenRequest request) {
try {
return ResponseEntity.ok(betaAuthService.confirmAndExchange(request.getToken()));
} catch (IllegalArgumentException e) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body("Confirm link is invalid or expired. Please request a new one.");
}
}
@PostMapping("/magic/exchange")
public ResponseEntity<?> magicExchange(@RequestBody TokenRequest request) {
try {
AuthResponse auth = betaAuthService.exchangeMagicToken(request.getToken());
return ResponseEntity.ok(auth);
} catch (IllegalArgumentException e) {
// token invalid/expired/consumed
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body("Magic link is invalid or expired. Please request a new one.");
}
}
@PostMapping("/password/forgot")
public ResponseEntity<?> forgotPassword(@RequestBody Map<String, String> body) {
String email = body.getOrDefault("email", "").trim();
try {
betaAuthService.sendPasswordReset(email); // name well add below
} catch (Exception ignored) {
// swallow to avoid enumeration
}
return ResponseEntity.ok().body("{\"ok\":true}");
}
@PostMapping("/password/reset")
public ResponseEntity<?> resetPassword(@RequestBody Map<String, String> body) {
String token = body.getOrDefault("token", "").trim();
String password = body.getOrDefault("password", "").trim();
betaAuthService.resetPassword(token, password);
return ResponseEntity.ok().body("{\"ok\":true}");
}
@PostMapping("/magic")
public ResponseEntity<?> requestMagic(@RequestBody Map<String, String> body) {
String email = body.getOrDefault("email", "").trim();
try {
betaAuthService.sendMagicLoginLink(email);
} catch (Exception ignored) {
// swallow to avoid enumeration
}
return ResponseEntity.ok(Map.of("ok", true));
}
package group.goforward.battlbuilder.controller;
import group.goforward.battlbuilder.model.User;
import group.goforward.battlbuilder.repo.UserRepository;
import group.goforward.battlbuilder.security.JwtService;
import group.goforward.battlbuilder.service.auth.BetaAuthService;
import group.goforward.battlbuilder.web.dto.auth.AuthResponse;
import group.goforward.battlbuilder.web.dto.auth.BetaSignupRequest;
import group.goforward.battlbuilder.web.dto.auth.LoginRequest;
import group.goforward.battlbuilder.web.dto.auth.RegisterRequest;
import group.goforward.battlbuilder.web.dto.auth.TokenRequest;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.*;
import jakarta.servlet.http.HttpServletRequest;
import java.time.OffsetDateTime;
import java.util.Map;
import java.util.UUID;
@RestController
@RequestMapping({"/api/auth", "/api/v1/auth"})
@CrossOrigin
public class AuthController {
private final UserRepository users;
private final PasswordEncoder passwordEncoder;
private final JwtService jwtService;
private final BetaAuthService betaAuthService;
public AuthController(
UserRepository users,
PasswordEncoder passwordEncoder,
JwtService jwtService,
BetaAuthService betaAuthService
) {
this.users = users;
this.passwordEncoder = passwordEncoder;
this.jwtService = jwtService;
this.betaAuthService = betaAuthService;
}
// ---------------------------------------------------------------------
// Standard Auth
// ---------------------------------------------------------------------
@PostMapping("/register")
public ResponseEntity<?> register(
@RequestBody RegisterRequest request,
HttpServletRequest httpRequest
) {
String email = request.getEmail().trim().toLowerCase();
// ✅ Enforce acceptance
if (!Boolean.TRUE.equals(request.getAcceptedTos())) {
return ResponseEntity
.status(HttpStatus.BAD_REQUEST)
.body("Terms of Service acceptance is required");
}
if (users.existsByEmailIgnoreCaseAndDeletedAtIsNull(email)) {
return ResponseEntity
.status(HttpStatus.CONFLICT)
.body("Email is already registered");
}
User user = new User();
user.setUuid(UUID.randomUUID());
user.setEmail(email);
user.setPasswordHash(passwordEncoder.encode(request.getPassword()));
user.setPasswordSetAt(OffsetDateTime.now());
user.setDisplayName(request.getDisplayName());
user.setRole("USER");
user.setActive(true);
user.setCreatedAt(OffsetDateTime.now());
user.setUpdatedAt(OffsetDateTime.now());
// ✅ Record ToS acceptance evidence
String tosVersion = StringUtils.hasText(request.getTosVersion())
? request.getTosVersion().trim()
: "2025-12-27"; // keep in sync with your ToS page
user.setTosAcceptedAt(OffsetDateTime.now());
user.setTosVersion(tosVersion);
user.setTosIp(extractClientIp(httpRequest));
user.setTosUserAgent(httpRequest.getHeader("User-Agent"));
users.save(user);
String token = jwtService.generateToken(user);
return ResponseEntity
.status(HttpStatus.CREATED)
.body(new AuthResponse(
token,
user.getEmail(),
user.getDisplayName(),
user.getRole()
));
}
private String extractClientIp(HttpServletRequest request) {
String xff = request.getHeader("X-Forwarded-For");
if (StringUtils.hasText(xff)) {
// first IP in the list
return xff.split(",")[0].trim();
}
String realIp = request.getHeader("X-Real-IP");
if (StringUtils.hasText(realIp)) return realIp.trim();
return request.getRemoteAddr();
}
@PostMapping("/login")
public ResponseEntity<?> login(@RequestBody LoginRequest request) {
String email = request.getEmail().trim().toLowerCase();
User user = users.findByEmailIgnoreCaseAndDeletedAtIsNull(email)
.orElse(null);
if (user == null || !user.isActive()) {
return ResponseEntity
.status(HttpStatus.UNAUTHORIZED)
.body("Invalid credentials");
}
if (!passwordEncoder.matches(request.getPassword(), user.getPasswordHash())) {
return ResponseEntity
.status(HttpStatus.UNAUTHORIZED)
.body("Invalid credentials");
}
user.setLastLoginAt(OffsetDateTime.now());
user.incrementLoginCount();
user.setUpdatedAt(OffsetDateTime.now());
users.save(user);
String token = jwtService.generateToken(user);
return ResponseEntity.ok(
new AuthResponse(
token,
user.getEmail(),
user.getDisplayName(),
user.getRole()
)
);
}
// ---------------------------------------------------------------------
// Beta Flow
// ---------------------------------------------------------------------
@PostMapping("/beta/signup")
public ResponseEntity<Map<String, Object>> betaSignup(@RequestBody BetaSignupRequest request) {
// Always return OK to prevent email enumeration
try {
betaAuthService.signup(request.getEmail(), request.getUseCase());
} catch (Exception ignored) {
// Intentionally swallow errors here to avoid leaking state
}
return ResponseEntity.ok(Map.of("ok", true));
}
@PostMapping("/beta/confirm")
public ResponseEntity<?> betaConfirm(@RequestBody TokenRequest request) {
try {
return ResponseEntity.ok(betaAuthService.confirmAndExchange(request.getToken()));
} catch (IllegalArgumentException e) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body("Confirm link is invalid or expired. Please request a new one.");
}
}
@PostMapping("/magic/exchange")
public ResponseEntity<?> magicExchange(@RequestBody TokenRequest request) {
try {
AuthResponse auth = betaAuthService.exchangeMagicToken(request.getToken());
return ResponseEntity.ok(auth);
} catch (IllegalArgumentException e) {
// token invalid/expired/consumed
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body("Magic link is invalid or expired. Please request a new one.");
}
}
@PostMapping("/password/forgot")
public ResponseEntity<?> forgotPassword(@RequestBody Map<String, String> body) {
String email = body.getOrDefault("email", "").trim();
try {
betaAuthService.sendPasswordReset(email); // name well add below
} catch (Exception ignored) {
// swallow to avoid enumeration
}
return ResponseEntity.ok().body("{\"ok\":true}");
}
@PostMapping("/password/reset")
public ResponseEntity<?> resetPassword(@RequestBody Map<String, String> body) {
String token = body.getOrDefault("token", "").trim();
String password = body.getOrDefault("password", "").trim();
betaAuthService.resetPassword(token, password);
return ResponseEntity.ok().body("{\"ok\":true}");
}
@PostMapping("/magic")
public ResponseEntity<?> requestMagic(@RequestBody Map<String, String> body) {
String email = body.getOrDefault("email", "").trim();
try {
betaAuthService.sendMagicLoginLink(email);
} catch (Exception ignored) {
// swallow to avoid enumeration
}
return ResponseEntity.ok(Map.of("ok", true));
}
}

View File

@@ -1,102 +1,106 @@
package group.goforward.battlbuilder.controller;
import group.goforward.battlbuilder.model.PartRoleMapping;
import group.goforward.battlbuilder.repo.PartCategoryRepository;
import group.goforward.battlbuilder.repo.PartRoleMappingRepository;
import group.goforward.battlbuilder.web.dto.admin.PartCategoryDto;
import org.springframework.web.bind.annotation.*;
import java.util.*;
@RestController
@RequestMapping({"/api/builder", "/api/v1/builder"})
@CrossOrigin
public class BuilderBootstrapController {
private final PartCategoryRepository partCategoryRepository;
private final PartRoleMappingRepository mappingRepository;
public BuilderBootstrapController(
PartCategoryRepository partCategoryRepository,
PartRoleMappingRepository mappingRepository
) {
this.partCategoryRepository = partCategoryRepository;
this.mappingRepository = mappingRepository;
}
/**
* Builder bootstrap payload.
* <p>
* Returns:
* - categories: ordered list for UI navigation
* - partRoleMap: normalized partRole -> categorySlug (platform-scoped)
* - categoryRoles: categorySlug -> normalized partRoles (derived)
*/
@GetMapping("/bootstrap")
public BuilderBootstrapDto bootstrap(
@RequestParam(defaultValue = "AR-15") String platform
) {
final String platformNorm = normalizePlatform(platform);
// 1) Categories in display order
List<PartCategoryDto> categories = partCategoryRepository
.findAllByOrderByGroupNameAscSortOrderAscNameAsc()
.stream()
.map(pc -> new PartCategoryDto(
pc.getId(),
pc.getSlug(),
pc.getName(),
pc.getDescription(),
pc.getGroupName(),
pc.getSortOrder()
))
.toList();
// 2) Role -> CategorySlug mapping (platform-scoped)
// Normalize keys to kebab-case so the UI can treat roles consistently.
Map<String, String> roleToCategorySlug = new LinkedHashMap<>();
List<PartRoleMapping> mappings = mappingRepository
.findAllByPlatformIgnoreCaseAndDeletedAtIsNullOrderByPartRoleAsc(platformNorm);
for (PartRoleMapping m : mappings) {
String roleKey = normalizePartRole(m.getPartRole());
if (roleKey == null || roleKey.isBlank()) continue;
if (m.getPartCategory() == null || m.getPartCategory().getSlug() == null) continue;
// If duplicates exist, keep first and ignore the rest so bootstrap never 500s.
roleToCategorySlug.putIfAbsent(roleKey, m.getPartCategory().getSlug());
}
// 3) CategorySlug -> Roles (derived)
Map<String, List<String>> categoryToRoles = new LinkedHashMap<>();
for (Map.Entry<String, String> e : roleToCategorySlug.entrySet()) {
categoryToRoles.computeIfAbsent(e.getValue(), k -> new ArrayList<>()).add(e.getKey());
}
return new BuilderBootstrapDto(platformNorm, categories, roleToCategorySlug, categoryToRoles);
}
private String normalizePartRole(String role) {
if (role == null) return null;
String r = role.trim();
if (r.isEmpty()) return null;
return r.toLowerCase(Locale.ROOT).replace('_', '-');
}
private String normalizePlatform(String platform) {
if (platform == null) return "AR-15";
String p = platform.trim();
if (p.isEmpty()) return "AR-15";
// normalize to AR-15 / AR-10 style
return p.toUpperCase(Locale.ROOT).replace('_', '-');
}
public record BuilderBootstrapDto(
String platform,
List<PartCategoryDto> categories,
Map<String, String> partRoleMap,
Map<String, List<String>> categoryRoles
) {}
package group.goforward.battlbuilder.controller;
import group.goforward.battlbuilder.model.PartRoleMapping;
import group.goforward.battlbuilder.repo.PartCategoryRepository;
import group.goforward.battlbuilder.repo.PartRoleMappingRepository;
import group.goforward.battlbuilder.web.dto.admin.PartCategoryDto;
import org.springframework.web.bind.annotation.*;
import java.util.*;
/**
* REST controller responsible for providing bootstrap data for the builder platform.
* This controller aggregates and normalizes data required to initialize builder-related UI components.
*/
@RestController
@RequestMapping({"/api/builder", "/api/v1/builder"})
@CrossOrigin
public class BuilderBootstrapController {
private final PartCategoryRepository partCategoryRepository;
private final PartRoleMappingRepository mappingRepository;
public BuilderBootstrapController(
PartCategoryRepository partCategoryRepository,
PartRoleMappingRepository mappingRepository
) {
this.partCategoryRepository = partCategoryRepository;
this.mappingRepository = mappingRepository;
}
/**
* Builder bootstrap payload.
* <p>
* Returns:
* - categories: ordered list for UI navigation
* - partRoleMap: normalized partRole -> categorySlug (platform-scoped)
* - categoryRoles: categorySlug -> normalized partRoles (derived)
*/
@GetMapping("/bootstrap")
public BuilderBootstrapDto bootstrap(
@RequestParam(defaultValue = "AR-15") String platform
) {
final String platformNorm = normalizePlatform(platform);
// 1) Categories in display order
List<PartCategoryDto> categories = partCategoryRepository
.findAllByOrderByGroupNameAscSortOrderAscNameAsc()
.stream()
.map(pc -> new PartCategoryDto(
pc.getId(),
pc.getSlug(),
pc.getName(),
pc.getDescription(),
pc.getGroupName(),
pc.getSortOrder()
))
.toList();
// 2) Role -> CategorySlug mapping (platform-scoped)
// Normalize keys to kebab-case so the UI can treat roles consistently.
Map<String, String> roleToCategorySlug = new LinkedHashMap<>();
List<PartRoleMapping> mappings = mappingRepository
.findAllByPlatformIgnoreCaseAndDeletedAtIsNullOrderByPartRoleAsc(platformNorm);
for (PartRoleMapping m : mappings) {
String roleKey = normalizePartRole(m.getPartRole());
if (roleKey == null || roleKey.isBlank()) continue;
if (m.getPartCategory() == null || m.getPartCategory().getSlug() == null) continue;
// If duplicates exist, keep first and ignore the rest so bootstrap never 500s.
roleToCategorySlug.putIfAbsent(roleKey, m.getPartCategory().getSlug());
}
// 3) CategorySlug -> Roles (derived)
Map<String, List<String>> categoryToRoles = new LinkedHashMap<>();
for (Map.Entry<String, String> e : roleToCategorySlug.entrySet()) {
categoryToRoles.computeIfAbsent(e.getValue(), k -> new ArrayList<>()).add(e.getKey());
}
return new BuilderBootstrapDto(platformNorm, categories, roleToCategorySlug, categoryToRoles);
}
private String normalizePartRole(String role) {
if (role == null) return null;
String r = role.trim();
if (r.isEmpty()) return null;
return r.toLowerCase(Locale.ROOT).replace('_', '-');
}
private String normalizePlatform(String platform) {
if (platform == null) return "AR-15";
String p = platform.trim();
if (p.isEmpty()) return "AR-15";
// normalize to AR-15 / AR-10 style
return p.toUpperCase(Locale.ROOT).replace('_', '-');
}
public record BuilderBootstrapDto(
String platform,
List<PartCategoryDto> categories,
Map<String, String> partRoleMap,
Map<String, List<String>> categoryRoles
) {}
}

View File

@@ -1,34 +1,55 @@
package group.goforward.battlbuilder.controller;
import group.goforward.battlbuilder.repo.PartCategoryRepository;
import group.goforward.battlbuilder.web.dto.admin.PartCategoryDto;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping({"/api/categories", "/api/v1/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();
}
package group.goforward.battlbuilder.controller;
import group.goforward.battlbuilder.repo.PartCategoryRepository;
import group.goforward.battlbuilder.web.dto.admin.PartCategoryDto;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* REST controller for managing part categories.
*
* This controller provides endpoints for retrieving and interacting with
* part category data through its associated repository. Part categories are
* sorted based on their group name, sort order, and name in ascending order.
*
* Annotations:
* - {@code @RestController}: Indicates that this class is a REST controller.
* - {@code @RequestMapping}: Maps HTTP requests to specific endpoints. Supported
* paths are "/api/categories" and "/api/v1/categories".
* - {@code @CrossOrigin}: Enables cross-origin requests.
*
* Constructor:
* - {@code CategoryController(PartCategoryRepository partCategories)}: Initializes
* the controller with the specified repository for accessing part category data.
*
* Methods:
* - {@code List<PartCategoryDto> list()}: Retrieves a list of part categories from
* the repository, sorts them, and maps them to DTO objects for output.
*/
@RestController
@RequestMapping({"/api/categories", "/api/v1/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,48 +1,60 @@
package group.goforward.battlbuilder.controller;
import group.goforward.battlbuilder.repo.EmailRequestRepository;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.net.URI;
import java.time.LocalDateTime;
@RestController
@RequestMapping("/api/email")
public class EmailTrackingController {
// 1x1 transparent GIF
private static final byte[] PIXEL = new byte[] {
71,73,70,56,57,97,1,0,1,0,-128,0,0,0,0,0,-1,-1,-1,33,-7,4,1,0,0,0,0,44,0,0,0,0,1,0,1,0,0,2,2,68,1,0,59
};
private final EmailRequestRepository repo;
public EmailTrackingController(EmailRequestRepository repo) {
this.repo = repo;
}
@GetMapping(value = "/open/{id}", produces = "image/gif")
public ResponseEntity<byte[]> open(@PathVariable Long id) {
repo.findById(id).ifPresent(r -> {
if (r.getOpenedAt() == null) r.setOpenedAt(LocalDateTime.now());
r.setOpenCount((r.getOpenCount() == null ? 0 : r.getOpenCount()) + 1);
repo.save(r);
});
return ResponseEntity.ok()
.header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0")
.body(PIXEL);
}
@GetMapping("/click/{id}")
public ResponseEntity<Void> click(@PathVariable Long id, @RequestParam String url) {
repo.findById(id).ifPresent(r -> {
if (r.getClickedAt() == null) r.setClickedAt(LocalDateTime.now());
r.setClickCount((r.getClickCount() == null ? 0 : r.getClickCount()) + 1);
repo.save(r);
});
return ResponseEntity.status(302).location(URI.create(url)).build();
}
package group.goforward.battlbuilder.controller;
import group.goforward.battlbuilder.repo.EmailRequestRepository;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.net.URI;
import java.time.LocalDateTime;
/**
* The EmailTrackingController handles tracking of email-related events such as
* email opens and link clicks. This controller provides endpoints to record
* these events and return appropriate responses.
*
* The tracking of email opens is achieved through a transparent 1x1 GIF image,
* and link clicks are redirected to the intended URL while capturing relevant metadata.
*
* Request mappings:
* 1. "/api/email/open/{id}" - Tracks email open events.
* 2. "/api/email/click/{id}" - Tracks email link click events.
*/
@RestController
@RequestMapping("/api/email")
public class EmailTrackingController {
// 1x1 transparent GIF
private static final byte[] PIXEL = new byte[] {
71,73,70,56,57,97,1,0,1,0,-128,0,0,0,0,0,-1,-1,-1,33,-7,4,1,0,0,0,0,44,0,0,0,0,1,0,1,0,0,2,2,68,1,0,59
};
private final EmailRequestRepository repo;
public EmailTrackingController(EmailRequestRepository repo) {
this.repo = repo;
}
@GetMapping(value = "/open/{id}", produces = "image/gif")
public ResponseEntity<byte[]> open(@PathVariable Long id) {
repo.findById(id).ifPresent(r -> {
if (r.getOpenedAt() == null) r.setOpenedAt(LocalDateTime.now());
r.setOpenCount((r.getOpenCount() == null ? 0 : r.getOpenCount()) + 1);
repo.save(r);
});
return ResponseEntity.ok()
.header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0")
.body(PIXEL);
}
@GetMapping("/click/{id}")
public ResponseEntity<Void> click(@PathVariable Long id, @RequestParam String url) {
repo.findById(id).ifPresent(r -> {
if (r.getClickedAt() == null) r.setClickedAt(LocalDateTime.now());
r.setClickCount((r.getClickCount() == null ? 0 : r.getClickCount()) + 1);
repo.save(r);
});
return ResponseEntity.status(302).location(URI.create(url)).build();
}
}

View File

@@ -4,6 +4,15 @@ import group.goforward.battlbuilder.service.MerchantFeedImportService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
/**
* REST controller responsible for handling import operations for merchants.
* Supports full product and offer imports as well as offers-only synchronization.
*
* Mapped to the following base endpoints:
* /api/admin/imports and /api/v1/admin/imports
*
* Cross-origin requests are permitted from http://localhost:3000.
*/
@RestController
@RequestMapping({"/api/admin/imports", "/api/v1/admin/imports"})

View File

@@ -8,6 +8,12 @@ import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/**
* REST controller for debugging merchant-related functionalities in the admin API.
* Provides endpoints for retrieving merchant data for administrative purposes.
*
* Mapped to both "/api/admin" and "/api/v1/admin" base paths.
*/
@RestController
@RequestMapping({"/api/admin", "/api/v1/admin"})
public class MerchantDebugController {

View File

@@ -1,31 +1,31 @@
//package group.goforward.battlbuilder.controller;
//
//import group.goforward.battlbuilder.service.PartRoleMappingService;
//import group.goforward.battlbuilder.web.dto.admin.PartRoleMappingDto;
//import group.goforward.battlbuilder.web.dto.PartRoleToCategoryDto;
//import org.springframework.web.bind.annotation.*;
//
//import java.util.List;
//
//@RestController
//@RequestMapping({"/api/part-role-mappings", "/api/v1/part-role-mappings"})
//public class PartRoleMappingController {
//
// private final PartRoleMappingService service;
//
// public PartRoleMappingController(PartRoleMappingService service) {
// this.service = service;
// }
//
// // Full view for admin UI
// @GetMapping("/{platform}")
// public List<PartRoleMappingDto> getMappings(@PathVariable String platform) {
// return service.getMappingsForPlatform(platform);
// }
//
// // Thin mapping for the builder
// @GetMapping("/{platform}/map")
// public List<PartRoleToCategoryDto> getRoleMap(@PathVariable String platform) {
// return service.getRoleToCategoryMap(platform);
// }
//package group.goforward.battlbuilder.controller;
//
//import group.goforward.battlbuilder.service.PartRoleMappingService;
//import group.goforward.battlbuilder.web.dto.admin.PartRoleMappingDto;
//import group.goforward.battlbuilder.web.dto.PartRoleToCategoryDto;
//import org.springframework.web.bind.annotation.*;
//
//import java.util.List;
//
//@RestController
//@RequestMapping({"/api/part-role-mappings", "/api/v1/part-role-mappings"})
//public class PartRoleMappingController {
//
// private final PartRoleMappingService service;
//
// public PartRoleMappingController(PartRoleMappingService service) {
// this.service = service;
// }
//
// // Full view for admin UI
// @GetMapping("/{platform}")
// public List<PartRoleMappingDto> getMappings(@PathVariable String platform) {
// return service.getMappingsForPlatform(platform);
// }
//
// // Thin mapping for the builder
// @GetMapping("/{platform}/map")
// public List<PartRoleToCategoryDto> getRoleMap(@PathVariable String platform) {
// return service.getRoleToCategoryMap(platform);
// }
//}

View File

@@ -1,51 +1,51 @@
package group.goforward.battlbuilder.controller.admin;
import group.goforward.battlbuilder.service.auth.impl.BetaInviteService;
import group.goforward.battlbuilder.web.dto.admin.AdminBetaRequestDto;
import group.goforward.battlbuilder.web.dto.admin.AdminInviteResponse;
import org.springframework.data.domain.Page;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/v1/admin/beta")
public class AdminBetaInviteController {
private final BetaInviteService betaInviteService;
public AdminBetaInviteController(BetaInviteService betaInviteService) {
this.betaInviteService = betaInviteService;
}
/**
* //api/v1/admin/beta/invites/send?limit=25&dryRun=true&tokenMinutes=30
* @param limit
* @param dryRun
* @param tokenMinutes
* @return
*/
@PostMapping("/invites/send")
public InviteBatchResponse sendInvites(
@RequestParam(defaultValue = "0") int limit,
@RequestParam(defaultValue = "true") boolean dryRun,
@RequestParam(defaultValue = "30") int tokenMinutes
) {
int processed = betaInviteService.inviteAllBetaUsers(tokenMinutes, limit, dryRun);
return new InviteBatchResponse(processed, dryRun, tokenMinutes, limit);
}
@GetMapping("/requests")
public Page<AdminBetaRequestDto> listBetaRequests(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "25") int size
) {
return betaInviteService.listPendingBetaUsers(page, size);
}
@PostMapping("/requests/{userId}/invite")
public AdminInviteResponse inviteSingle(@PathVariable Integer userId) {
return betaInviteService.inviteSingleBetaUser(userId);
}
public record InviteBatchResponse(int processed, boolean dryRun, int tokenMinutes, int limit) {}
package group.goforward.battlbuilder.controller.admin;
import group.goforward.battlbuilder.service.auth.impl.BetaInviteService;
import group.goforward.battlbuilder.web.dto.admin.AdminBetaRequestDto;
import group.goforward.battlbuilder.web.dto.admin.AdminInviteResponse;
import org.springframework.data.domain.Page;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/v1/admin/beta")
public class AdminBetaInviteController {
private final BetaInviteService betaInviteService;
public AdminBetaInviteController(BetaInviteService betaInviteService) {
this.betaInviteService = betaInviteService;
}
/**
* //api/v1/admin/beta/invites/send?limit=25&dryRun=true&tokenMinutes=30
* @param limit
* @param dryRun
* @param tokenMinutes
* @return
*/
@PostMapping("/invites/send")
public InviteBatchResponse sendInvites(
@RequestParam(defaultValue = "0") int limit,
@RequestParam(defaultValue = "true") boolean dryRun,
@RequestParam(defaultValue = "30") int tokenMinutes
) {
int processed = betaInviteService.inviteAllBetaUsers(tokenMinutes, limit, dryRun);
return new InviteBatchResponse(processed, dryRun, tokenMinutes, limit);
}
@GetMapping("/requests")
public Page<AdminBetaRequestDto> listBetaRequests(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "25") int size
) {
return betaInviteService.listPendingBetaUsers(page, size);
}
@PostMapping("/requests/{userId}/invite")
public AdminInviteResponse inviteSingle(@PathVariable Integer userId) {
return betaInviteService.inviteSingleBetaUser(userId);
}
public record InviteBatchResponse(int processed, boolean dryRun, int tokenMinutes, int limit) {}
}

View File

@@ -1,40 +1,40 @@
package group.goforward.battlbuilder.controller.admin;
import group.goforward.battlbuilder.model.PartCategory;
import group.goforward.battlbuilder.repo.PartCategoryRepository;
import group.goforward.battlbuilder.web.dto.admin.PartCategoryDto;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/admin/categories")
@CrossOrigin
public class AdminCategoryController {
private final PartCategoryRepository partCategories;
public AdminCategoryController(PartCategoryRepository partCategories) {
this.partCategories = partCategories;
}
@GetMapping
public List<PartCategoryDto> listCategories() {
return partCategories
.findAllByOrderByGroupNameAscSortOrderAscNameAsc()
.stream()
.map(this::toDto)
.toList();
}
private PartCategoryDto toDto(PartCategory entity) {
return new PartCategoryDto(
entity.getId(),
entity.getSlug(),
entity.getName(),
entity.getDescription(),
entity.getGroupName(),
entity.getSortOrder()
);
}
package group.goforward.battlbuilder.controller.admin;
import group.goforward.battlbuilder.model.PartCategory;
import group.goforward.battlbuilder.repo.PartCategoryRepository;
import group.goforward.battlbuilder.web.dto.admin.PartCategoryDto;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/admin/categories")
@CrossOrigin
public class AdminCategoryController {
private final PartCategoryRepository partCategories;
public AdminCategoryController(PartCategoryRepository partCategories) {
this.partCategories = partCategories;
}
@GetMapping
public List<PartCategoryDto> listCategories() {
return partCategories
.findAllByOrderByGroupNameAscSortOrderAscNameAsc()
.stream()
.map(this::toDto)
.toList();
}
private PartCategoryDto toDto(PartCategory entity) {
return new PartCategoryDto(
entity.getId(),
entity.getSlug(),
entity.getName(),
entity.getDescription(),
entity.getGroupName(),
entity.getSortOrder()
);
}
}

View File

@@ -1,25 +1,25 @@
package group.goforward.battlbuilder.controller.admin;
import group.goforward.battlbuilder.service.admin.impl.AdminDashboardService;
import group.goforward.battlbuilder.web.dto.admin.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);
}
package group.goforward.battlbuilder.controller.admin;
import group.goforward.battlbuilder.service.admin.impl.AdminDashboardService;
import group.goforward.battlbuilder.web.dto.admin.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,34 +1,34 @@
package group.goforward.battlbuilder.controller.admin;
import group.goforward.battlbuilder.repo.PartCategoryRepository;
import group.goforward.battlbuilder.web.dto.admin.PartCategoryDto;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/admin/part-categories")
@CrossOrigin // keep it loose for now, you can tighten origins later
public class AdminPartCategoryController {
private final PartCategoryRepository partCategories;
public AdminPartCategoryController(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();
}
package group.goforward.battlbuilder.controller.admin;
import group.goforward.battlbuilder.repo.PartCategoryRepository;
import group.goforward.battlbuilder.web.dto.admin.PartCategoryDto;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/admin/part-categories")
@CrossOrigin // keep it loose for now, you can tighten origins later
public class AdminPartCategoryController {
private final PartCategoryRepository partCategories;
public AdminPartCategoryController(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,124 +1,124 @@
package group.goforward.battlbuilder.controller.admin;
import group.goforward.battlbuilder.model.PartCategory;
import group.goforward.battlbuilder.model.PartRoleMapping;
import group.goforward.battlbuilder.repo.PartCategoryRepository;
import group.goforward.battlbuilder.repo.PartRoleMappingRepository;
import group.goforward.battlbuilder.web.dto.admin.AdminPartRoleMappingDto;
import group.goforward.battlbuilder.web.dto.admin.CreatePartRoleMappingRequest;
import group.goforward.battlbuilder.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
.findByPlatformAndDeletedAtIsNullOrderByPartRoleAsc(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()
);
}
package group.goforward.battlbuilder.controller.admin;
import group.goforward.battlbuilder.model.PartCategory;
import group.goforward.battlbuilder.model.PartRoleMapping;
import group.goforward.battlbuilder.repo.PartCategoryRepository;
import group.goforward.battlbuilder.repo.PartRoleMappingRepository;
import group.goforward.battlbuilder.web.dto.admin.AdminPartRoleMappingDto;
import group.goforward.battlbuilder.web.dto.admin.CreatePartRoleMappingRequest;
import group.goforward.battlbuilder.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
.findByPlatformAndDeletedAtIsNullOrderByPartRoleAsc(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

@@ -1,11 +1,11 @@
/**
* Admin controller package for the BattlBuilder application.
* <p>
* Contains REST controller for administrative operations including
* category management, platform configuration, and merchant administration.
*
* @author Forward Group, LLC
* @version 1.0
* @since 2025-12-10
*/
package group.goforward.battlbuilder.controller.admin;
/**
* Admin controller package for the BattlBuilder application.
* <p>
* Contains REST controller for administrative operations including
* category management, platform configuration, and merchant administration.
*
* @author Forward Group, LLC
* @version 1.0
* @since 2025-12-10
*/
package group.goforward.battlbuilder.controller.admin;

View File

@@ -11,6 +11,27 @@ import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* REST controller for managing brand entities.
* Provides endpoints for performing CRUD operations on brands.
*
* The controller exposes REST APIs to:
* - Retrieve a list of all brands.
* - Fetch a single brand by its ID.
* - Create a new brand.
* - Delete a brand by its ID.
*
* This controller interacts with the persistence layer through `BrandRepository` and with the
* business logic through `BrandService`.
*
* Mapped base endpoints:
* - `/api/v1/brands`
* - `/api/brands`
*
* Dependencies:
* - `BrandRepository` for direct database access.
* - `BrandService` for handling the business logic of brands.
*/
@RestController
@RequestMapping({"/api/v1/brands", "/api/brands"})
public class BrandController {

View File

@@ -1,96 +1,96 @@
package group.goforward.battlbuilder.controller.api.v1;
import group.goforward.battlbuilder.service.BuildService;
import group.goforward.battlbuilder.web.dto.BuildDto;
import group.goforward.battlbuilder.web.dto.BuildFeedCardDto;
import group.goforward.battlbuilder.web.dto.BuildSummaryDto;
import group.goforward.battlbuilder.web.dto.UpdateBuildRequest;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.UUID;
@CrossOrigin
@RestController
@RequestMapping("/api/v1/builds")
public class BuildV1Controller {
private final BuildService buildService;
public BuildV1Controller(BuildService buildService) {
this.buildService = buildService;
}
/**
* Public builds feed for /builds page.
* GET /api/v1/builds?limit=50
*/
@GetMapping
public ResponseEntity<List<BuildFeedCardDto>> listPublicBuilds(
@RequestParam(name = "limit", required = false, defaultValue = "50") Integer limit
) {
return ResponseEntity.ok(buildService.listPublicBuilds(limit == null ? 50 : limit));
}
/**
* Public build detail for /builds/{uuid}
* GET /api/v1/builds/{uuid}
*/
@GetMapping("/{uuid}")
public ResponseEntity<BuildDto> getPublicBuild(@PathVariable("uuid") UUID uuid) {
return ResponseEntity.ok(buildService.getPublicBuild(uuid));
}
/**
* Vault builds (authenticated user).
* GET /api/v1/builds/me?limit=100
*/
@GetMapping("/me")
public ResponseEntity<List<BuildSummaryDto>> listMyBuilds(
@RequestParam(name = "limit", required = false, defaultValue = "100") Integer limit
) {
return ResponseEntity.ok(buildService.listMyBuilds(limit == null ? 100 : limit));
}
/**
* Load a single build (Vault edit + Builder ?load=uuid).
* GET /api/v1/builds/me/{uuid}
*/
@GetMapping("/me/{uuid}")
public ResponseEntity<BuildDto> getMyBuild(@PathVariable("uuid") UUID uuid) {
return ResponseEntity.ok(buildService.getMyBuild(uuid));
}
/**
* Create a NEW build in Vault (Save As…).
* POST /api/v1/builds/me
*/
@PostMapping("/me")
public ResponseEntity<BuildDto> createMyBuild(@RequestBody UpdateBuildRequest req) {
return ResponseEntity.ok(buildService.createMyBuild(req));
}
/**
* Update build (authenticated user; must own build eventually).
* PUT /api/v1/builds/me/{uuid}
*/
@PutMapping("/me/{uuid}")
public ResponseEntity<BuildDto> updateMyBuild(
@PathVariable("uuid") UUID uuid,
@RequestBody UpdateBuildRequest req
) {
return ResponseEntity.ok(buildService.updateMyBuild(uuid, req));
}
/**
* Delete a build (authenticated user; must own build).
* DELETE /api/v1/builds/me/{uuid}
*/
@DeleteMapping("/me/{uuid}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void deleteMyBuild(@PathVariable("uuid") UUID uuid) {
buildService.deleteMyBuild(uuid);
}
package group.goforward.battlbuilder.controller.api.v1;
import group.goforward.battlbuilder.service.BuildService;
import group.goforward.battlbuilder.web.dto.BuildDto;
import group.goforward.battlbuilder.web.dto.BuildFeedCardDto;
import group.goforward.battlbuilder.web.dto.BuildSummaryDto;
import group.goforward.battlbuilder.web.dto.UpdateBuildRequest;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.UUID;
@CrossOrigin
@RestController
@RequestMapping("/api/v1/builds")
public class BuildV1Controller {
private final BuildService buildService;
public BuildV1Controller(BuildService buildService) {
this.buildService = buildService;
}
/**
* Public builds feed for /builds page.
* GET /api/v1/builds?limit=50
*/
@GetMapping
public ResponseEntity<List<BuildFeedCardDto>> listPublicBuilds(
@RequestParam(name = "limit", required = false, defaultValue = "50") Integer limit
) {
return ResponseEntity.ok(buildService.listPublicBuilds(limit == null ? 50 : limit));
}
/**
* Public build detail for /builds/{uuid}
* GET /api/v1/builds/{uuid}
*/
@GetMapping("/{uuid}")
public ResponseEntity<BuildDto> getPublicBuild(@PathVariable("uuid") UUID uuid) {
return ResponseEntity.ok(buildService.getPublicBuild(uuid));
}
/**
* Vault builds (authenticated user).
* GET /api/v1/builds/me?limit=100
*/
@GetMapping("/me")
public ResponseEntity<List<BuildSummaryDto>> listMyBuilds(
@RequestParam(name = "limit", required = false, defaultValue = "100") Integer limit
) {
return ResponseEntity.ok(buildService.listMyBuilds(limit == null ? 100 : limit));
}
/**
* Load a single build (Vault edit + Builder ?load=uuid).
* GET /api/v1/builds/me/{uuid}
*/
@GetMapping("/me/{uuid}")
public ResponseEntity<BuildDto> getMyBuild(@PathVariable("uuid") UUID uuid) {
return ResponseEntity.ok(buildService.getMyBuild(uuid));
}
/**
* Create a NEW build in Vault (Save As…).
* POST /api/v1/builds/me
*/
@PostMapping("/me")
public ResponseEntity<BuildDto> createMyBuild(@RequestBody UpdateBuildRequest req) {
return ResponseEntity.ok(buildService.createMyBuild(req));
}
/**
* Update build (authenticated user; must own build eventually).
* PUT /api/v1/builds/me/{uuid}
*/
@PutMapping("/me/{uuid}")
public ResponseEntity<BuildDto> updateMyBuild(
@PathVariable("uuid") UUID uuid,
@RequestBody UpdateBuildRequest req
) {
return ResponseEntity.ok(buildService.updateMyBuild(uuid, req));
}
/**
* Delete a build (authenticated user; must own build).
* DELETE /api/v1/builds/me/{uuid}
*/
@DeleteMapping("/me/{uuid}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void deleteMyBuild(@PathVariable("uuid") UUID uuid) {
buildService.deleteMyBuild(uuid);
}
}

View File

@@ -1,60 +1,60 @@
package group.goforward.battlbuilder.controller.api.v1;
import group.goforward.battlbuilder.service.CatalogQueryService;
import group.goforward.battlbuilder.web.dto.ProductSummaryDto;
import group.goforward.battlbuilder.web.dto.catalog.CatalogProductIdsRequest;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/v1/catalog")
@CrossOrigin // tighten later
public class CatalogController {
private final CatalogQueryService catalogQueryService;
public CatalogController(CatalogQueryService catalogQueryService) {
this.catalogQueryService = catalogQueryService;
}
@GetMapping("/options")
public Page<ProductSummaryDto> getOptions(
@RequestParam(required = false) String platform,
@RequestParam(required = false) String partRole,
@RequestParam(required = false) List<String> partRoles,
@RequestParam(required = false, name = "brand") List<String> brands,
@RequestParam(required = false) String q,
Pageable pageable
) {
Pageable safe = sanitizeCatalogPageable(pageable);
return catalogQueryService.getOptions(platform, partRole, partRoles, brands, q, safe);
}
private Pageable sanitizeCatalogPageable(Pageable pageable) {
int page = Math.max(0, pageable.getPageNumber());
// hard cap to keep UI snappy + protect DB
int requested = pageable.getPageSize();
int size = Math.min(Math.max(requested, 1), 48); // 48 max
// default sort if none provided
Sort sort = pageable.getSort().isSorted()
? pageable.getSort()
: Sort.by(Sort.Direction.DESC, "updatedAt");
return PageRequest.of(page, size, sort);
}
@PostMapping("/products/by-ids")
public List<ProductSummaryDto> getProductsByIds(@RequestBody CatalogProductIdsRequest request) {
return catalogQueryService.getProductsByIds(request);
}
package group.goforward.battlbuilder.controller.api.v1;
import group.goforward.battlbuilder.service.CatalogQueryService;
import group.goforward.battlbuilder.web.dto.ProductSummaryDto;
import group.goforward.battlbuilder.web.dto.catalog.CatalogProductIdsRequest;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/v1/catalog")
@CrossOrigin // tighten later
public class CatalogController {
private final CatalogQueryService catalogQueryService;
public CatalogController(CatalogQueryService catalogQueryService) {
this.catalogQueryService = catalogQueryService;
}
@GetMapping("/options")
public Page<ProductSummaryDto> getOptions(
@RequestParam(required = false) String platform,
@RequestParam(required = false) String partRole,
@RequestParam(required = false) List<String> partRoles,
@RequestParam(required = false, name = "brand") List<String> brands,
@RequestParam(required = false) String q,
Pageable pageable
) {
Pageable safe = sanitizeCatalogPageable(pageable);
return catalogQueryService.getOptions(platform, partRole, partRoles, brands, q, safe);
}
private Pageable sanitizeCatalogPageable(Pageable pageable) {
int page = Math.max(0, pageable.getPageNumber());
// hard cap to keep UI snappy + protect DB
int requested = pageable.getPageSize();
int size = Math.min(Math.max(requested, 1), 48); // 48 max
// default sort if none provided
Sort sort = pageable.getSort().isSorted()
? pageable.getSort()
: Sort.by(Sort.Direction.DESC, "updatedAt");
return PageRequest.of(page, size, sort);
}
@PostMapping("/products/by-ids")
public List<ProductSummaryDto> getProductsByIds(@RequestBody CatalogProductIdsRequest request) {
return catalogQueryService.getProductsByIds(request);
}
}

View File

@@ -1,152 +1,152 @@
package group.goforward.battlbuilder.controller.api.v1;
import group.goforward.battlbuilder.common.ApiResponse;
import group.goforward.battlbuilder.dto.EmailRequestDto;
import group.goforward.battlbuilder.model.EmailRequest;
import group.goforward.battlbuilder.model.EmailStatus;
import group.goforward.battlbuilder.repo.EmailRequestRepository;
import group.goforward.battlbuilder.service.utils.EmailService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import java.util.Arrays;
import java.util.List;
@RestController
@RequestMapping("/api/email")
public class EmailController {
private static final EmailStatus EMAIL_STATUS_SENT = EmailStatus.SENT;
private final EmailService emailService;
private final EmailRequestRepository emailRequestRepository;
@Autowired
public EmailController(EmailService emailService, EmailRequestRepository emailRequestRepository) {
this.emailService = emailService;
this.emailRequestRepository = emailRequestRepository;
}
@GetMapping("/statuses")
public ResponseEntity<ApiResponse<List<String>>> getEmailStatuses() {
List<String> statuses = Arrays.stream(EmailStatus.values())
.map(Enum::name)
.toList();
return ResponseEntity.ok(
ApiResponse.success(statuses, "Email statuses retrieved successfully")
);
}
@GetMapping
public ResponseEntity<ApiResponse<List<EmailRequest>>> getAllEmailRequests() {
try {
List<EmailRequest> emailRequests = emailRequestRepository.findAll();
return ResponseEntity.ok(
ApiResponse.success(emailRequests, "Email requests retrieved successfully")
);
} catch (Exception e) {
return ResponseEntity.status(500).body(
ApiResponse.error("Error retrieving email requests: " + e.getMessage(), null)
);
}
}
@GetMapping("/allSent")
public ResponseEntity<ApiResponse<List<EmailRequest>>> getNotSentEmailRequests() {
try {
List<EmailRequest> emailRequests = emailRequestRepository.findByStatus(EmailStatus.SENT);
return ResponseEntity.ok(
ApiResponse.success(emailRequests, "Not sent email requests retrieved successfully")
);
} catch (Exception e) {
return ResponseEntity.status(500).body(
ApiResponse.error("Error retrieving not sent email requests: " + e.getMessage(), null)
);
}
}
@GetMapping("/allFailed")
public ResponseEntity<ApiResponse<List<EmailRequest>>> getFailedEmailRequests() {
try {
List<EmailRequest> emailRequests = emailRequestRepository.findByStatus(EmailStatus.FAILED);
return ResponseEntity.ok(
ApiResponse.success(emailRequests, "Failed email requests retrieved successfully")
);
} catch (Exception e) {
return ResponseEntity.status(500).body(
ApiResponse.error("Error retrieving failed email requests: " + e.getMessage(), null)
);
}
}
@GetMapping("/allPending")
public ResponseEntity<ApiResponse<List<EmailRequest>>> getPendingEmailRequests() {
try {
List<EmailRequest> emailRequests = emailRequestRepository.findByStatus(EmailStatus.PENDING);
return ResponseEntity.ok(
ApiResponse.success(emailRequests, "Pending email requests retrieved successfully")
);
} catch (Exception e) {
return ResponseEntity.status(500).body(
ApiResponse.error("Error retrieving Pending email requests: " + e.getMessage(), null)
);
}
}
@PostMapping("/send")
public ResponseEntity<ApiResponse<EmailRequest>> sendEmail(@RequestBody EmailRequestDto emailDto) {
try {
EmailRequest emailRequest = emailService.sendEmail(
emailDto.getRecipient(),
emailDto.getSubject(),
emailDto.getBody()
);
return buildEmailResponse(emailRequest);
} catch (Exception e) {
return buildErrorResponse(e.getMessage());
}
}
@DeleteMapping("/delete/{id}")
public ResponseEntity<Void> deleteItem(@PathVariable Integer id) {
return emailRequestRepository.findById(Long.valueOf(id))
.map(item -> {
emailRequestRepository.deleteById(Long.valueOf(id));
return ResponseEntity.noContent().<Void>build();
})
.orElse(ResponseEntity.notFound().build());
}
// Replace /delete/{id} with a RESTful DELETE /{id}
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void deleteEmailRequest(@PathVariable Long id) {
if (!emailRequestRepository.existsById(id)) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Email request not found");
}
emailRequestRepository.deleteById(id);
}
private ResponseEntity<ApiResponse<EmailRequest>> buildEmailResponse(EmailRequest emailRequest) {
if (EMAIL_STATUS_SENT.equals(emailRequest.getStatus())) {
return ResponseEntity.ok(
ApiResponse.success(emailRequest, "Email sent successfully")
);
} else {
String errorMessage = "Failed to send email: " + emailRequest.getErrorMessage();
return ResponseEntity.status(500).body(
ApiResponse.error(errorMessage, emailRequest)
);
}
}
private ResponseEntity<ApiResponse<EmailRequest>> buildErrorResponse(String exceptionMessage) {
String errorMessage = "Error processing email request: " + exceptionMessage;
return ResponseEntity.status(500).body(
ApiResponse.error(errorMessage, null)
);
}
package group.goforward.battlbuilder.controller.api.v1;
import group.goforward.battlbuilder.common.ApiResponse;
import group.goforward.battlbuilder.dto.EmailRequestDto;
import group.goforward.battlbuilder.model.EmailRequest;
import group.goforward.battlbuilder.model.EmailStatus;
import group.goforward.battlbuilder.repo.EmailRequestRepository;
import group.goforward.battlbuilder.service.utils.EmailService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import java.util.Arrays;
import java.util.List;
@RestController
@RequestMapping("/api/email")
public class EmailController {
private static final EmailStatus EMAIL_STATUS_SENT = EmailStatus.SENT;
private final EmailService emailService;
private final EmailRequestRepository emailRequestRepository;
@Autowired
public EmailController(EmailService emailService, EmailRequestRepository emailRequestRepository) {
this.emailService = emailService;
this.emailRequestRepository = emailRequestRepository;
}
@GetMapping("/statuses")
public ResponseEntity<ApiResponse<List<String>>> getEmailStatuses() {
List<String> statuses = Arrays.stream(EmailStatus.values())
.map(Enum::name)
.toList();
return ResponseEntity.ok(
ApiResponse.success(statuses, "Email statuses retrieved successfully")
);
}
@GetMapping
public ResponseEntity<ApiResponse<List<EmailRequest>>> getAllEmailRequests() {
try {
List<EmailRequest> emailRequests = emailRequestRepository.findAll();
return ResponseEntity.ok(
ApiResponse.success(emailRequests, "Email requests retrieved successfully")
);
} catch (Exception e) {
return ResponseEntity.status(500).body(
ApiResponse.error("Error retrieving email requests: " + e.getMessage(), null)
);
}
}
@GetMapping("/allSent")
public ResponseEntity<ApiResponse<List<EmailRequest>>> getNotSentEmailRequests() {
try {
List<EmailRequest> emailRequests = emailRequestRepository.findByStatus(EmailStatus.SENT);
return ResponseEntity.ok(
ApiResponse.success(emailRequests, "Not sent email requests retrieved successfully")
);
} catch (Exception e) {
return ResponseEntity.status(500).body(
ApiResponse.error("Error retrieving not sent email requests: " + e.getMessage(), null)
);
}
}
@GetMapping("/allFailed")
public ResponseEntity<ApiResponse<List<EmailRequest>>> getFailedEmailRequests() {
try {
List<EmailRequest> emailRequests = emailRequestRepository.findByStatus(EmailStatus.FAILED);
return ResponseEntity.ok(
ApiResponse.success(emailRequests, "Failed email requests retrieved successfully")
);
} catch (Exception e) {
return ResponseEntity.status(500).body(
ApiResponse.error("Error retrieving failed email requests: " + e.getMessage(), null)
);
}
}
@GetMapping("/allPending")
public ResponseEntity<ApiResponse<List<EmailRequest>>> getPendingEmailRequests() {
try {
List<EmailRequest> emailRequests = emailRequestRepository.findByStatus(EmailStatus.PENDING);
return ResponseEntity.ok(
ApiResponse.success(emailRequests, "Pending email requests retrieved successfully")
);
} catch (Exception e) {
return ResponseEntity.status(500).body(
ApiResponse.error("Error retrieving Pending email requests: " + e.getMessage(), null)
);
}
}
@PostMapping("/send")
public ResponseEntity<ApiResponse<EmailRequest>> sendEmail(@RequestBody EmailRequestDto emailDto) {
try {
EmailRequest emailRequest = emailService.sendEmail(
emailDto.getRecipient(),
emailDto.getSubject(),
emailDto.getBody()
);
return buildEmailResponse(emailRequest);
} catch (Exception e) {
return buildErrorResponse(e.getMessage());
}
}
@DeleteMapping("/delete/{id}")
public ResponseEntity<Void> deleteItem(@PathVariable Integer id) {
return emailRequestRepository.findById(Long.valueOf(id))
.map(item -> {
emailRequestRepository.deleteById(Long.valueOf(id));
return ResponseEntity.noContent().<Void>build();
})
.orElse(ResponseEntity.notFound().build());
}
// Replace /delete/{id} with a RESTful DELETE /{id}
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void deleteEmailRequest(@PathVariable Long id) {
if (!emailRequestRepository.existsById(id)) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Email request not found");
}
emailRequestRepository.deleteById(id);
}
private ResponseEntity<ApiResponse<EmailRequest>> buildEmailResponse(EmailRequest emailRequest) {
if (EMAIL_STATUS_SENT.equals(emailRequest.getStatus())) {
return ResponseEntity.ok(
ApiResponse.success(emailRequest, "Email sent successfully")
);
} else {
String errorMessage = "Failed to send email: " + emailRequest.getErrorMessage();
return ResponseEntity.status(500).body(
ApiResponse.error(errorMessage, emailRequest)
);
}
}
private ResponseEntity<ApiResponse<EmailRequest>> buildErrorResponse(String exceptionMessage) {
String errorMessage = "Error processing email request: " + exceptionMessage;
return ResponseEntity.status(500).body(
ApiResponse.error(errorMessage, null)
);
}
}

View File

@@ -1,231 +1,231 @@
package group.goforward.battlbuilder.controller.api.v1;
import group.goforward.battlbuilder.model.User;
import group.goforward.battlbuilder.repo.UserRepository;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import java.time.OffsetDateTime;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import static org.springframework.http.HttpStatus.*;
@RestController
@RequestMapping({"/api/v1/users/me", "/api/users/me"})
@CrossOrigin
public class MeController {
private final UserRepository users;
private final PasswordEncoder passwordEncoder;
public MeController(UserRepository users, PasswordEncoder passwordEncoder) {
this.users = users;
this.passwordEncoder = passwordEncoder;
}
// -----------------------------
// Helpers
// -----------------------------
private Authentication requireAuth() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null || !auth.isAuthenticated()) {
throw new ResponseStatusException(UNAUTHORIZED, "Not authenticated");
}
// Spring may set "anonymousUser" as a principal when not logged in
Object principal = auth.getPrincipal();
if (principal == null || "anonymousUser".equals(principal)) {
throw new ResponseStatusException(UNAUTHORIZED, "Not authenticated");
}
return auth;
}
private Optional<UUID> tryParseUuid(String s) {
try {
return Optional.of(UUID.fromString(s));
} catch (Exception ignored) {
return Optional.empty();
}
}
private User requireUser() {
Authentication auth = requireAuth();
Object principal = auth.getPrincipal();
// Case 1: principal is a String (we commonly set this to UUID string)
if (principal instanceof String s) {
// Prefer UUID lookup
Optional<UUID> uuid = tryParseUuid(s);
if (uuid.isPresent()) {
return users.findByUuidAndDeletedAtIsNull(uuid.get())
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "User not found"));
}
// Fallback to email lookup
String email = s.trim().toLowerCase();
return users.findByEmailIgnoreCaseAndDeletedAtIsNull(email)
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "User not found"));
}
// Case 2: principal is a UserDetails (often username=email)
if (principal instanceof UserDetails ud) {
String username = ud.getUsername();
if (username == null) {
throw new ResponseStatusException(UNAUTHORIZED, "Not authenticated");
}
// Try UUID first, then email
Optional<UUID> uuid = tryParseUuid(username);
if (uuid.isPresent()) {
return users.findByUuidAndDeletedAtIsNull(uuid.get())
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "User not found"));
}
String email = username.trim().toLowerCase();
return users.findByEmailIgnoreCaseAndDeletedAtIsNull(email)
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "User not found"));
}
// Anything else: unsupported principal type
throw new ResponseStatusException(UNAUTHORIZED, "Not authenticated");
}
private Map<String, Object> toMeResponse(User user) {
Map<String, Object> out = new java.util.HashMap<>();
out.put("email", user.getEmail());
out.put("displayName", user.getDisplayName() == null ? "" : user.getDisplayName());
out.put("username", user.getUsername() == null ? "" : user.getUsername());
out.put("role", user.getRole() == null ? "USER" : user.getRole());
out.put("uuid", String.valueOf(user.getUuid()));
out.put("passwordSetAt", user.getPasswordSetAt() == null ? null : user.getPasswordSetAt().toString());
return out;
}
private String normalizeUsername(String raw) {
if (raw == null) return null;
String s = raw.trim().toLowerCase();
return s.isBlank() ? null : s;
}
private boolean isReservedUsername(String u) {
return switch (u) {
case "admin", "support", "battl", "battlbuilders", "builder",
"api", "login", "register", "account", "privacy", "tos" -> true;
default -> false;
};
}
// -----------------------------
// Routes
// -----------------------------
@GetMapping
public ResponseEntity<?> me() {
User user = requireUser();
return ResponseEntity.ok(toMeResponse(user));
}
@PatchMapping
public ResponseEntity<?> updateMe(@RequestBody Map<String, Object> body) {
User user = requireUser();
String displayName = null;
if (body != null && body.get("displayName") != null) {
displayName = String.valueOf(body.get("displayName")).trim();
}
String username = null;
if (body != null && body.get("username") != null) {
username = normalizeUsername(String.valueOf(body.get("username")));
}
if ((displayName == null || displayName.isBlank()) && (username == null)) {
throw new ResponseStatusException(BAD_REQUEST, "username or displayName is required");
}
// display name is flexible
if (displayName != null && !displayName.isBlank()) {
user.setDisplayName(displayName);
}
// username is strict + unique
if (username != null) {
if (username.length() < 3 || username.length() > 20) {
throw new ResponseStatusException(BAD_REQUEST, "Username must be 320 characters");
}
if (!username.matches("^[a-z0-9_]+$")) {
throw new ResponseStatusException(BAD_REQUEST, "Username can only contain a-z, 0-9, underscore");
}
if (isReservedUsername(username)) {
throw new ResponseStatusException(BAD_REQUEST, "That username is reserved");
}
users.findByUsernameIgnoreCaseAndDeletedAtIsNull(username).ifPresent(existing -> {
if (!existing.getId().equals(user.getId())) {
throw new ResponseStatusException(CONFLICT, "Username already taken");
}
});
user.setUsername(username);
}
user.setUpdatedAt(OffsetDateTime.now());
users.save(user);
return ResponseEntity.ok(toMeResponse(user));
}
@PostMapping("/password")
public ResponseEntity<?> setPassword(@RequestBody Map<String, Object> body) {
User user = requireUser();
String password = null;
if (body != null && body.get("password") != null) {
password = String.valueOf(body.get("password"));
}
if (password == null || password.length() < 8) {
throw new ResponseStatusException(BAD_REQUEST, "Password must be at least 8 characters");
}
user.setPasswordHash(passwordEncoder.encode(password));
user.setPasswordSetAt(OffsetDateTime.now()); // ✅ NEW
user.setUpdatedAt(OffsetDateTime.now());
users.save(user);
return ResponseEntity.ok(Map.of("ok", true, "passwordSetAt", user.getPasswordSetAt().toString()));
}
@GetMapping("/username-available")
public ResponseEntity<?> usernameAvailable(@RequestParam("username") String usernameRaw) {
String username = normalizeUsername(usernameRaw);
// Soft fail
if (username == null) return ResponseEntity.ok(Map.of("available", false));
if (username.length() < 3 || username.length() > 20) {
return ResponseEntity.ok(Map.of("available", false));
}
if (!username.matches("^[a-z0-9_]+$")) {
return ResponseEntity.ok(Map.of("available", false));
}
if (isReservedUsername(username)) {
return ResponseEntity.ok(Map.of("available", false));
}
User me = requireUser();
boolean available = users.findByUsernameIgnoreCaseAndDeletedAtIsNull(username)
.map(existing -> existing.getId().equals(me.getId())) // if it's mine, treat as available
.orElse(true);
return ResponseEntity.ok(Map.of("available", available));
}
package group.goforward.battlbuilder.controller.api.v1;
import group.goforward.battlbuilder.model.User;
import group.goforward.battlbuilder.repo.UserRepository;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import java.time.OffsetDateTime;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import static org.springframework.http.HttpStatus.*;
@RestController
@RequestMapping({"/api/v1/users/me", "/api/users/me"})
@CrossOrigin
public class MeController {
private final UserRepository users;
private final PasswordEncoder passwordEncoder;
public MeController(UserRepository users, PasswordEncoder passwordEncoder) {
this.users = users;
this.passwordEncoder = passwordEncoder;
}
// -----------------------------
// Helpers
// -----------------------------
private Authentication requireAuth() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null || !auth.isAuthenticated()) {
throw new ResponseStatusException(UNAUTHORIZED, "Not authenticated");
}
// Spring may set "anonymousUser" as a principal when not logged in
Object principal = auth.getPrincipal();
if (principal == null || "anonymousUser".equals(principal)) {
throw new ResponseStatusException(UNAUTHORIZED, "Not authenticated");
}
return auth;
}
private Optional<UUID> tryParseUuid(String s) {
try {
return Optional.of(UUID.fromString(s));
} catch (Exception ignored) {
return Optional.empty();
}
}
private User requireUser() {
Authentication auth = requireAuth();
Object principal = auth.getPrincipal();
// Case 1: principal is a String (we commonly set this to UUID string)
if (principal instanceof String s) {
// Prefer UUID lookup
Optional<UUID> uuid = tryParseUuid(s);
if (uuid.isPresent()) {
return users.findByUuidAndDeletedAtIsNull(uuid.get())
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "User not found"));
}
// Fallback to email lookup
String email = s.trim().toLowerCase();
return users.findByEmailIgnoreCaseAndDeletedAtIsNull(email)
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "User not found"));
}
// Case 2: principal is a UserDetails (often username=email)
if (principal instanceof UserDetails ud) {
String username = ud.getUsername();
if (username == null) {
throw new ResponseStatusException(UNAUTHORIZED, "Not authenticated");
}
// Try UUID first, then email
Optional<UUID> uuid = tryParseUuid(username);
if (uuid.isPresent()) {
return users.findByUuidAndDeletedAtIsNull(uuid.get())
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "User not found"));
}
String email = username.trim().toLowerCase();
return users.findByEmailIgnoreCaseAndDeletedAtIsNull(email)
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "User not found"));
}
// Anything else: unsupported principal type
throw new ResponseStatusException(UNAUTHORIZED, "Not authenticated");
}
private Map<String, Object> toMeResponse(User user) {
Map<String, Object> out = new java.util.HashMap<>();
out.put("email", user.getEmail());
out.put("displayName", user.getDisplayName() == null ? "" : user.getDisplayName());
out.put("username", user.getUsername() == null ? "" : user.getUsername());
out.put("role", user.getRole() == null ? "USER" : user.getRole());
out.put("uuid", String.valueOf(user.getUuid()));
out.put("passwordSetAt", user.getPasswordSetAt() == null ? null : user.getPasswordSetAt().toString());
return out;
}
private String normalizeUsername(String raw) {
if (raw == null) return null;
String s = raw.trim().toLowerCase();
return s.isBlank() ? null : s;
}
private boolean isReservedUsername(String u) {
return switch (u) {
case "admin", "support", "battl", "battlbuilders", "builder",
"api", "login", "register", "account", "privacy", "tos" -> true;
default -> false;
};
}
// -----------------------------
// Routes
// -----------------------------
@GetMapping
public ResponseEntity<?> me() {
User user = requireUser();
return ResponseEntity.ok(toMeResponse(user));
}
@PatchMapping
public ResponseEntity<?> updateMe(@RequestBody Map<String, Object> body) {
User user = requireUser();
String displayName = null;
if (body != null && body.get("displayName") != null) {
displayName = String.valueOf(body.get("displayName")).trim();
}
String username = null;
if (body != null && body.get("username") != null) {
username = normalizeUsername(String.valueOf(body.get("username")));
}
if ((displayName == null || displayName.isBlank()) && (username == null)) {
throw new ResponseStatusException(BAD_REQUEST, "username or displayName is required");
}
// display name is flexible
if (displayName != null && !displayName.isBlank()) {
user.setDisplayName(displayName);
}
// username is strict + unique
if (username != null) {
if (username.length() < 3 || username.length() > 20) {
throw new ResponseStatusException(BAD_REQUEST, "Username must be 320 characters");
}
if (!username.matches("^[a-z0-9_]+$")) {
throw new ResponseStatusException(BAD_REQUEST, "Username can only contain a-z, 0-9, underscore");
}
if (isReservedUsername(username)) {
throw new ResponseStatusException(BAD_REQUEST, "That username is reserved");
}
users.findByUsernameIgnoreCaseAndDeletedAtIsNull(username).ifPresent(existing -> {
if (!existing.getId().equals(user.getId())) {
throw new ResponseStatusException(CONFLICT, "Username already taken");
}
});
user.setUsername(username);
}
user.setUpdatedAt(OffsetDateTime.now());
users.save(user);
return ResponseEntity.ok(toMeResponse(user));
}
@PostMapping("/password")
public ResponseEntity<?> setPassword(@RequestBody Map<String, Object> body) {
User user = requireUser();
String password = null;
if (body != null && body.get("password") != null) {
password = String.valueOf(body.get("password"));
}
if (password == null || password.length() < 8) {
throw new ResponseStatusException(BAD_REQUEST, "Password must be at least 8 characters");
}
user.setPasswordHash(passwordEncoder.encode(password));
user.setPasswordSetAt(OffsetDateTime.now()); // ✅ NEW
user.setUpdatedAt(OffsetDateTime.now());
users.save(user);
return ResponseEntity.ok(Map.of("ok", true, "passwordSetAt", user.getPasswordSetAt().toString()));
}
@GetMapping("/username-available")
public ResponseEntity<?> usernameAvailable(@RequestParam("username") String usernameRaw) {
String username = normalizeUsername(usernameRaw);
// Soft fail
if (username == null) return ResponseEntity.ok(Map.of("available", false));
if (username.length() < 3 || username.length() > 20) {
return ResponseEntity.ok(Map.of("available", false));
}
if (!username.matches("^[a-z0-9_]+$")) {
return ResponseEntity.ok(Map.of("available", false));
}
if (isReservedUsername(username)) {
return ResponseEntity.ok(Map.of("available", false));
}
User me = requireUser();
boolean available = users.findByUsernameIgnoreCaseAndDeletedAtIsNull(username)
.map(existing -> existing.getId().equals(me.getId())) // if it's mine, treat as available
.orElse(true);
return ResponseEntity.ok(Map.of("available", available));
}
}

View File

@@ -1,49 +1,49 @@
package group.goforward.battlbuilder.controller.api.v1;
import group.goforward.battlbuilder.service.ProductQueryService;
import group.goforward.battlbuilder.web.dto.ProductOfferDto;
import group.goforward.battlbuilder.web.dto.ProductSummaryDto;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.web.PageableDefault;
import java.util.List;
@RestController
@RequestMapping("/api/v1/products")
@CrossOrigin
public class ProductV1Controller {
private final ProductQueryService productQueryService;
public ProductV1Controller(ProductQueryService productQueryService) {
this.productQueryService = productQueryService;
}
@GetMapping
@Cacheable(
value = "gunbuilderProductsV1",
key = "#platform + '::' + (#partRoles == null ? 'ALL' : #partRoles.toString()) + '::' + #pageable.pageNumber + '::' + #pageable.pageSize"
)
public Page<ProductSummaryDto> getProducts(
@RequestParam(defaultValue = "AR-15") String platform,
@RequestParam(required = false, name = "partRoles") List<String> partRoles,
@PageableDefault(size = 50) Pageable pageable
) {
return productQueryService.getProductsPage(platform, partRoles, pageable);
}
@GetMapping("/{id}/offers")
public List<ProductOfferDto> getOffersForProduct(@PathVariable("id") Integer productId) {
return productQueryService.getOffersForProduct(productId);
}
@GetMapping("/{id}")
public ResponseEntity<ProductSummaryDto> getProductById(@PathVariable("id") Integer productId) {
ProductSummaryDto dto = productQueryService.getProductById(productId);
return dto != null ? ResponseEntity.ok(dto) : ResponseEntity.notFound().build();
}
package group.goforward.battlbuilder.controller.api.v1;
import group.goforward.battlbuilder.service.ProductQueryService;
import group.goforward.battlbuilder.web.dto.ProductOfferDto;
import group.goforward.battlbuilder.web.dto.ProductSummaryDto;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.web.PageableDefault;
import java.util.List;
@RestController
@RequestMapping("/api/v1/products")
@CrossOrigin
public class ProductV1Controller {
private final ProductQueryService productQueryService;
public ProductV1Controller(ProductQueryService productQueryService) {
this.productQueryService = productQueryService;
}
@GetMapping
@Cacheable(
value = "gunbuilderProductsV1",
key = "#platform + '::' + (#partRoles == null ? 'ALL' : #partRoles.toString()) + '::' + #pageable.pageNumber + '::' + #pageable.pageSize"
)
public Page<ProductSummaryDto> getProducts(
@RequestParam(defaultValue = "AR-15") String platform,
@RequestParam(required = false, name = "partRoles") List<String> partRoles,
@PageableDefault(size = 50) Pageable pageable
) {
return productQueryService.getProductsPage(platform, partRoles, pageable);
}
@GetMapping("/{id}/offers")
public List<ProductOfferDto> getOffersForProduct(@PathVariable("id") Integer productId) {
return productQueryService.getOffersForProduct(productId);
}
@GetMapping("/{id}")
public ResponseEntity<ProductSummaryDto> getProductById(@PathVariable("id") Integer productId) {
ProductSummaryDto dto = productQueryService.getProductById(productId);
return dto != null ? ResponseEntity.ok(dto) : ResponseEntity.notFound().build();
}
}

View File

@@ -1,11 +1,11 @@
/**
* API controller package for the BattlBuilder application.
* <p>
* Contains REST API controller for public-facing endpoints including
* brand management, state information, and user operations.
*
* @author Forward Group, LLC
* @version 1.0
* @since 2025-12-10
*/
package group.goforward.battlbuilder.controller.api.v1;
/**
* API controller package for the BattlBuilder application.
* <p>
* Contains REST API controller for public-facing endpoints including
* brand management, state information, and user operations.
*
* @author Forward Group, LLC
* @version 1.0
* @since 2025-12-10
*/
package group.goforward.battlbuilder.controller.api.v1;

View File

@@ -1,12 +1,12 @@
/*
Web admin DTOs package for the BattlBuilder application.
<p>
Contains Data Transfer Objects specific to administrative
operations including user management, mappings, and platform configuration.
@author Forward Group, LLC
* @version 1.0
* @since 2025-12-10
*/
package group.goforward.battlbuilder.dto;
/*
Web admin DTOs package for the BattlBuilder application.
<p>
Contains Data Transfer Objects specific to administrative
operations including user management, mappings, and platform configuration.
@author Forward Group, LLC
* @version 1.0
* @since 2025-12-10
*/
package group.goforward.battlbuilder.dto;

View File

@@ -1,10 +1,10 @@
package group.goforward.battlbuilder.enrichment;
/**
* Enum representing the source of an enrichment.
*/
public enum EnrichmentSource {
AI,
RULES,
HUMAN
package group.goforward.battlbuilder.enrichment;
/**
* Enum representing the source of an enrichment.
*/
public enum EnrichmentSource {
AI,
RULES,
HUMAN
}

View File

@@ -1,19 +1,19 @@
package group.goforward.battlbuilder.enrichment;
/**
* Status of an enrichment in the system.
*
* <p>Possible values:
* <ul>
* <li>PENDING_REVIEW - awaiting review</li>
* <li>APPROVED - approved to apply</li>
* <li>REJECTED - rejected and will not be applied</li>
* <li>APPLIED - enrichment has been applied</li>
* </ul>
*/
public enum EnrichmentStatus {
PENDING_REVIEW,
APPROVED,
REJECTED,
APPLIED
package group.goforward.battlbuilder.enrichment;
/**
* Status of an enrichment in the system.
*
* <p>Possible values:
* <ul>
* <li>PENDING_REVIEW - awaiting review</li>
* <li>APPROVED - approved to apply</li>
* <li>REJECTED - rejected and will not be applied</li>
* <li>APPLIED - enrichment has been applied</li>
* </ul>
*/
public enum EnrichmentStatus {
PENDING_REVIEW,
APPROVED,
REJECTED,
APPLIED
}

View File

@@ -1,14 +1,14 @@
package group.goforward.battlbuilder.enrichment;
/**
* Enum representing different types of enrichment that can be applied to products.
*/
public enum EnrichmentType {
CALIBER,
CALIBER_GROUP,
BARREL_LENGTH,
GAS_SYSTEM,
HANDGUARD_LENGTH,
CONFIGURATION,
PART_ROLE
package group.goforward.battlbuilder.enrichment;
/**
* Enum representing different types of enrichment that can be applied to products.
*/
public enum EnrichmentType {
CALIBER,
CALIBER_GROUP,
BARREL_LENGTH,
GAS_SYSTEM,
HANDGUARD_LENGTH,
CONFIGURATION,
PART_ROLE
}

View File

@@ -1,103 +1,103 @@
package group.goforward.battlbuilder.enrichment.ai;
import group.goforward.battlbuilder.enrichment.*;
import group.goforward.battlbuilder.enrichment.ai.dto.CaliberExtractionResult;
import group.goforward.battlbuilder.enrichment.model.ProductEnrichment;
import group.goforward.battlbuilder.enrichment.repo.ProductEnrichmentRepository;
import group.goforward.battlbuilder.enrichment.taxonomies.CaliberTaxonomy;
import group.goforward.battlbuilder.model.Product;
import group.goforward.battlbuilder.repo.ProductRepository;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.util.List;
import java.util.Map;
@Service
public class AiEnrichmentOrchestrator {
private final EnrichmentModelClient modelClient;
private final ProductRepository productRepository;
private final ProductEnrichmentRepository enrichmentRepository;
@Value("${ai.minConfidence:0.75}")
private BigDecimal minConfidence;
public AiEnrichmentOrchestrator(
EnrichmentModelClient modelClient,
ProductRepository productRepository,
ProductEnrichmentRepository enrichmentRepository
) {
this.modelClient = modelClient;
this.productRepository = productRepository;
this.enrichmentRepository = enrichmentRepository;
}
public int runCaliber(int limit) {
// pick candidates: caliber missing
List<Product> candidates = productRepository.findProductsMissingCaliber(limit);
int created = 0;
for (Product p : candidates) {
CaliberExtractionResult r = modelClient.extractCaliber(p);
if (r == null || !r.isUsable(minConfidence)) {
continue;
}
// Optional: avoid duplicates for same product/type/status
boolean exists = enrichmentRepository.existsByProductIdAndEnrichmentTypeAndStatus(
p.getId(), EnrichmentType.CALIBER, EnrichmentStatus.PENDING_REVIEW
);
if (exists) continue;
ProductEnrichment pe = new ProductEnrichment();
pe.setProductId(p.getId());
pe.setEnrichmentType(EnrichmentType.CALIBER);
pe.setSource(EnrichmentSource.AI);
pe.setStatus(EnrichmentStatus.PENDING_REVIEW);
pe.setSchemaVersion(1);
pe.setAttributes(Map.of("caliber", r.caliber()));
pe.setConfidence(r.confidence());
pe.setRationale(r.reason());
pe.setMeta(Map.of("provider", modelClient.providerName()));
enrichmentRepository.save(pe);
created++;
}
return created;
}
public int runCaliberGroup(int limit) {
List<Product> candidates = productRepository.findProductsMissingCaliberGroup(limit);
int created = 0;
for (Product p : candidates) {
String group = CaliberTaxonomy.groupForCaliber(p.getCaliber());
if (group == null || group.isBlank()) continue;
boolean exists = enrichmentRepository.existsByProductIdAndEnrichmentTypeAndStatus(
p.getId(), EnrichmentType.CALIBER_GROUP, EnrichmentStatus.PENDING_REVIEW
);
if (exists) continue;
ProductEnrichment pe = new ProductEnrichment();
pe.setProductId(p.getId());
pe.setEnrichmentType(EnrichmentType.CALIBER_GROUP);
pe.setSource(EnrichmentSource.RULES); // derived rules
pe.setStatus(EnrichmentStatus.PENDING_REVIEW);
pe.setSchemaVersion(1);
pe.setAttributes(java.util.Map.of("caliberGroup", group));
pe.setConfidence(new java.math.BigDecimal("1.00"));
pe.setRationale("Derived caliberGroup from product.caliber via CaliberTaxonomy");
pe.setMeta(java.util.Map.of("provider", "TAXONOMY"));
enrichmentRepository.save(pe);
created++;
}
return created;
}
package group.goforward.battlbuilder.enrichment.ai;
import group.goforward.battlbuilder.enrichment.*;
import group.goforward.battlbuilder.enrichment.ai.dto.CaliberExtractionResult;
import group.goforward.battlbuilder.enrichment.model.ProductEnrichment;
import group.goforward.battlbuilder.enrichment.repo.ProductEnrichmentRepository;
import group.goforward.battlbuilder.enrichment.taxonomies.CaliberTaxonomy;
import group.goforward.battlbuilder.model.Product;
import group.goforward.battlbuilder.repo.ProductRepository;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.util.List;
import java.util.Map;
@Service
public class AiEnrichmentOrchestrator {
private final EnrichmentModelClient modelClient;
private final ProductRepository productRepository;
private final ProductEnrichmentRepository enrichmentRepository;
@Value("${ai.minConfidence:0.75}")
private BigDecimal minConfidence;
public AiEnrichmentOrchestrator(
EnrichmentModelClient modelClient,
ProductRepository productRepository,
ProductEnrichmentRepository enrichmentRepository
) {
this.modelClient = modelClient;
this.productRepository = productRepository;
this.enrichmentRepository = enrichmentRepository;
}
public int runCaliber(int limit) {
// pick candidates: caliber missing
List<Product> candidates = productRepository.findProductsMissingCaliber(limit);
int created = 0;
for (Product p : candidates) {
CaliberExtractionResult r = modelClient.extractCaliber(p);
if (r == null || !r.isUsable(minConfidence)) {
continue;
}
// Optional: avoid duplicates for same product/type/status
boolean exists = enrichmentRepository.existsByProductIdAndEnrichmentTypeAndStatus(
p.getId(), EnrichmentType.CALIBER, EnrichmentStatus.PENDING_REVIEW
);
if (exists) continue;
ProductEnrichment pe = new ProductEnrichment();
pe.setProductId(p.getId());
pe.setEnrichmentType(EnrichmentType.CALIBER);
pe.setSource(EnrichmentSource.AI);
pe.setStatus(EnrichmentStatus.PENDING_REVIEW);
pe.setSchemaVersion(1);
pe.setAttributes(Map.of("caliber", r.caliber()));
pe.setConfidence(r.confidence());
pe.setRationale(r.reason());
pe.setMeta(Map.of("provider", modelClient.providerName()));
enrichmentRepository.save(pe);
created++;
}
return created;
}
public int runCaliberGroup(int limit) {
List<Product> candidates = productRepository.findProductsMissingCaliberGroup(limit);
int created = 0;
for (Product p : candidates) {
String group = CaliberTaxonomy.groupForCaliber(p.getCaliber());
if (group == null || group.isBlank()) continue;
boolean exists = enrichmentRepository.existsByProductIdAndEnrichmentTypeAndStatus(
p.getId(), EnrichmentType.CALIBER_GROUP, EnrichmentStatus.PENDING_REVIEW
);
if (exists) continue;
ProductEnrichment pe = new ProductEnrichment();
pe.setProductId(p.getId());
pe.setEnrichmentType(EnrichmentType.CALIBER_GROUP);
pe.setSource(EnrichmentSource.RULES); // derived rules
pe.setStatus(EnrichmentStatus.PENDING_REVIEW);
pe.setSchemaVersion(1);
pe.setAttributes(java.util.Map.of("caliberGroup", group));
pe.setConfidence(new java.math.BigDecimal("1.00"));
pe.setRationale("Derived caliberGroup from product.caliber via CaliberTaxonomy");
pe.setMeta(java.util.Map.of("provider", "TAXONOMY"));
enrichmentRepository.save(pe);
created++;
}
return created;
}
}

View File

@@ -1,153 +1,153 @@
package group.goforward.battlbuilder.enrichment.controller;
import group.goforward.battlbuilder.enrichment.taxonomies.CaliberTaxonomy;
import group.goforward.battlbuilder.enrichment.EnrichmentStatus;
import group.goforward.battlbuilder.enrichment.EnrichmentType;
import group.goforward.battlbuilder.enrichment.model.ProductEnrichment;
import group.goforward.battlbuilder.enrichment.ai.AiEnrichmentOrchestrator;
import group.goforward.battlbuilder.enrichment.repo.ProductEnrichmentRepository;
import group.goforward.battlbuilder.enrichment.service.CaliberEnrichmentService;
import org.springframework.data.domain.PageRequest;
import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/admin/enrichment")
public class AdminEnrichmentController {
private final CaliberEnrichmentService caliberEnrichmentService;
private final ProductEnrichmentRepository enrichmentRepository;
private final AiEnrichmentOrchestrator aiEnrichmentOrchestrator;
public AdminEnrichmentController(
CaliberEnrichmentService caliberEnrichmentService,
ProductEnrichmentRepository enrichmentRepository,
AiEnrichmentOrchestrator aiEnrichmentOrchestrator
) {
this.caliberEnrichmentService = caliberEnrichmentService;
this.enrichmentRepository = enrichmentRepository;
this.aiEnrichmentOrchestrator = aiEnrichmentOrchestrator;
}
@PostMapping("/run")
public ResponseEntity<?> run(
@RequestParam EnrichmentType type,
@RequestParam(defaultValue = "200") int limit
) {
if (type != EnrichmentType.CALIBER) {
return ResponseEntity.badRequest().body("Only CALIBER is supported in v0.");
}
return ResponseEntity.ok(caliberEnrichmentService.runRules(limit));
}
// ✅ NEW: Run AI enrichment
@PostMapping("/ai/run")
public ResponseEntity<?> runAi(
@RequestParam EnrichmentType type,
@RequestParam(defaultValue = "200") int limit
) {
if (type != EnrichmentType.CALIBER) {
return ResponseEntity.badRequest().body("Only CALIBER is supported in v0.");
}
// This should create ProductEnrichment rows with source=AI and status=PENDING_REVIEW
return ResponseEntity.ok(aiEnrichmentOrchestrator.runCaliber(limit));
}
@GetMapping("/queue")
public ResponseEntity<List<ProductEnrichment>> queue(
@RequestParam(defaultValue = "CALIBER") EnrichmentType type,
@RequestParam(defaultValue = "PENDING_REVIEW") EnrichmentStatus status,
@RequestParam(defaultValue = "100") int limit
) {
var items = enrichmentRepository
.findByEnrichmentTypeAndStatusOrderByCreatedAtDesc(type, status, PageRequest.of(0, limit));
return ResponseEntity.ok(items);
}
@GetMapping("/queue2")
public ResponseEntity<?> queue2(
@RequestParam(defaultValue = "CALIBER") EnrichmentType type,
@RequestParam(defaultValue = "PENDING_REVIEW") EnrichmentStatus status,
@RequestParam(defaultValue = "100") int limit
) {
return ResponseEntity.ok(
enrichmentRepository.queueWithProduct(type, status, PageRequest.of(0, limit))
);
}
@PostMapping("/{id}/approve")
public ResponseEntity<?> approve(@PathVariable Long id) {
var e = enrichmentRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Not found: " + id));
e.setStatus(EnrichmentStatus.APPROVED);
enrichmentRepository.save(e);
return ResponseEntity.ok(e);
}
@PostMapping("/{id}/reject")
public ResponseEntity<?> reject(@PathVariable Long id) {
var e = enrichmentRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Not found: " + id));
e.setStatus(EnrichmentStatus.REJECTED);
enrichmentRepository.save(e);
return ResponseEntity.ok(e);
}
@PostMapping("/{id}/apply")
@Transactional
public ResponseEntity<?> apply(@PathVariable Long id) {
var e = enrichmentRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Not found: " + id));
if (e.getStatus() != EnrichmentStatus.APPROVED) {
return ResponseEntity.badRequest().body("Enrichment must be APPROVED before applying.");
}
if (e.getEnrichmentType() == EnrichmentType.CALIBER) {
Object caliberObj = e.getAttributes().get("caliber");
if (!(caliberObj instanceof String caliber) || caliber.trim().isEmpty()) {
return ResponseEntity.badRequest().body("Missing attributes.caliber");
}
String canonical = CaliberTaxonomy.normalizeCaliber(caliber.trim());
int updated = enrichmentRepository.applyCaliberIfBlank(e.getProductId(), canonical);
if (updated == 0) {
return ResponseEntity.badRequest().body("Product caliber already set (or product not found). Not applied.");
}
// Bonus safety: set group if blank
String group = CaliberTaxonomy.groupForCaliber(canonical);
if (group != null && !group.isBlank()) {
enrichmentRepository.applyCaliberGroupIfBlank(e.getProductId(), group);
}
} else if (e.getEnrichmentType() == EnrichmentType.CALIBER_GROUP) {
Object groupObj = e.getAttributes().get("caliberGroup");
if (!(groupObj instanceof String group) || group.trim().isEmpty()) {
return ResponseEntity.badRequest().body("Missing attributes.caliberGroup");
}
int updated = enrichmentRepository.applyCaliberGroupIfBlank(e.getProductId(), group.trim());
if (updated == 0) {
return ResponseEntity.badRequest().body("Product caliber_group already set (or product not found). Not applied.");
}
} else {
return ResponseEntity.badRequest().body("Unsupported enrichment type in v0.");
}
e.setStatus(EnrichmentStatus.APPLIED);
enrichmentRepository.save(e);
return ResponseEntity.ok(e);
}
@PostMapping("/groups/run")
public ResponseEntity<?> runGroups(@RequestParam(defaultValue = "200") int limit) {
return ResponseEntity.ok(aiEnrichmentOrchestrator.runCaliberGroup(limit));
}
package group.goforward.battlbuilder.enrichment.controller;
import group.goforward.battlbuilder.enrichment.taxonomies.CaliberTaxonomy;
import group.goforward.battlbuilder.enrichment.EnrichmentStatus;
import group.goforward.battlbuilder.enrichment.EnrichmentType;
import group.goforward.battlbuilder.enrichment.model.ProductEnrichment;
import group.goforward.battlbuilder.enrichment.ai.AiEnrichmentOrchestrator;
import group.goforward.battlbuilder.enrichment.repo.ProductEnrichmentRepository;
import group.goforward.battlbuilder.enrichment.service.CaliberEnrichmentService;
import org.springframework.data.domain.PageRequest;
import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/admin/enrichment")
public class AdminEnrichmentController {
private final CaliberEnrichmentService caliberEnrichmentService;
private final ProductEnrichmentRepository enrichmentRepository;
private final AiEnrichmentOrchestrator aiEnrichmentOrchestrator;
public AdminEnrichmentController(
CaliberEnrichmentService caliberEnrichmentService,
ProductEnrichmentRepository enrichmentRepository,
AiEnrichmentOrchestrator aiEnrichmentOrchestrator
) {
this.caliberEnrichmentService = caliberEnrichmentService;
this.enrichmentRepository = enrichmentRepository;
this.aiEnrichmentOrchestrator = aiEnrichmentOrchestrator;
}
@PostMapping("/run")
public ResponseEntity<?> run(
@RequestParam EnrichmentType type,
@RequestParam(defaultValue = "200") int limit
) {
if (type != EnrichmentType.CALIBER) {
return ResponseEntity.badRequest().body("Only CALIBER is supported in v0.");
}
return ResponseEntity.ok(caliberEnrichmentService.runRules(limit));
}
// ✅ NEW: Run AI enrichment
@PostMapping("/ai/run")
public ResponseEntity<?> runAi(
@RequestParam EnrichmentType type,
@RequestParam(defaultValue = "200") int limit
) {
if (type != EnrichmentType.CALIBER) {
return ResponseEntity.badRequest().body("Only CALIBER is supported in v0.");
}
// This should create ProductEnrichment rows with source=AI and status=PENDING_REVIEW
return ResponseEntity.ok(aiEnrichmentOrchestrator.runCaliber(limit));
}
@GetMapping("/queue")
public ResponseEntity<List<ProductEnrichment>> queue(
@RequestParam(defaultValue = "CALIBER") EnrichmentType type,
@RequestParam(defaultValue = "PENDING_REVIEW") EnrichmentStatus status,
@RequestParam(defaultValue = "100") int limit
) {
var items = enrichmentRepository
.findByEnrichmentTypeAndStatusOrderByCreatedAtDesc(type, status, PageRequest.of(0, limit));
return ResponseEntity.ok(items);
}
@GetMapping("/queue2")
public ResponseEntity<?> queue2(
@RequestParam(defaultValue = "CALIBER") EnrichmentType type,
@RequestParam(defaultValue = "PENDING_REVIEW") EnrichmentStatus status,
@RequestParam(defaultValue = "100") int limit
) {
return ResponseEntity.ok(
enrichmentRepository.queueWithProduct(type, status, PageRequest.of(0, limit))
);
}
@PostMapping("/{id}/approve")
public ResponseEntity<?> approve(@PathVariable Long id) {
var e = enrichmentRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Not found: " + id));
e.setStatus(EnrichmentStatus.APPROVED);
enrichmentRepository.save(e);
return ResponseEntity.ok(e);
}
@PostMapping("/{id}/reject")
public ResponseEntity<?> reject(@PathVariable Long id) {
var e = enrichmentRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Not found: " + id));
e.setStatus(EnrichmentStatus.REJECTED);
enrichmentRepository.save(e);
return ResponseEntity.ok(e);
}
@PostMapping("/{id}/apply")
@Transactional
public ResponseEntity<?> apply(@PathVariable Long id) {
var e = enrichmentRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Not found: " + id));
if (e.getStatus() != EnrichmentStatus.APPROVED) {
return ResponseEntity.badRequest().body("Enrichment must be APPROVED before applying.");
}
if (e.getEnrichmentType() == EnrichmentType.CALIBER) {
Object caliberObj = e.getAttributes().get("caliber");
if (!(caliberObj instanceof String caliber) || caliber.trim().isEmpty()) {
return ResponseEntity.badRequest().body("Missing attributes.caliber");
}
String canonical = CaliberTaxonomy.normalizeCaliber(caliber.trim());
int updated = enrichmentRepository.applyCaliberIfBlank(e.getProductId(), canonical);
if (updated == 0) {
return ResponseEntity.badRequest().body("Product caliber already set (or product not found). Not applied.");
}
// Bonus safety: set group if blank
String group = CaliberTaxonomy.groupForCaliber(canonical);
if (group != null && !group.isBlank()) {
enrichmentRepository.applyCaliberGroupIfBlank(e.getProductId(), group);
}
} else if (e.getEnrichmentType() == EnrichmentType.CALIBER_GROUP) {
Object groupObj = e.getAttributes().get("caliberGroup");
if (!(groupObj instanceof String group) || group.trim().isEmpty()) {
return ResponseEntity.badRequest().body("Missing attributes.caliberGroup");
}
int updated = enrichmentRepository.applyCaliberGroupIfBlank(e.getProductId(), group.trim());
if (updated == 0) {
return ResponseEntity.badRequest().body("Product caliber_group already set (or product not found). Not applied.");
}
} else {
return ResponseEntity.badRequest().body("Unsupported enrichment type in v0.");
}
e.setStatus(EnrichmentStatus.APPLIED);
enrichmentRepository.save(e);
return ResponseEntity.ok(e);
}
@PostMapping("/groups/run")
public ResponseEntity<?> runGroups(@RequestParam(defaultValue = "200") int limit) {
return ResponseEntity.ok(aiEnrichmentOrchestrator.runCaliberGroup(limit));
}
}

View File

@@ -1,95 +1,95 @@
package group.goforward.battlbuilder.enrichment.model;
import group.goforward.battlbuilder.enrichment.EnrichmentSource;
import group.goforward.battlbuilder.enrichment.EnrichmentStatus;
import group.goforward.battlbuilder.enrichment.EnrichmentType;
import jakarta.persistence.*;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;
import java.math.BigDecimal;
import java.time.OffsetDateTime;
import java.util.HashMap;
import java.util.Map;
@Entity
@Table(name = "product_enrichments")
public class ProductEnrichment {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "product_id", nullable = false)
private Integer productId;
@Enumerated(EnumType.STRING)
@Column(name = "enrichment_type", nullable = false)
private EnrichmentType enrichmentType;
@Enumerated(EnumType.STRING)
@Column(name = "source", nullable = false)
private EnrichmentSource source = EnrichmentSource.AI;
@Enumerated(EnumType.STRING)
@Column(name = "status", nullable = false)
private EnrichmentStatus status = EnrichmentStatus.PENDING_REVIEW;
@Column(name = "schema_version", nullable = false)
private Integer schemaVersion = 1;
@JdbcTypeCode(SqlTypes.JSON)
@Column(name = "attributes", nullable = false, columnDefinition = "jsonb")
private Map<String, Object> attributes = new HashMap<>();
@Column(name = "confidence", precision = 4, scale = 3)
private BigDecimal confidence;
@Column(name = "rationale")
private String rationale;
@JdbcTypeCode(SqlTypes.JSON)
@Column(name = "meta", nullable = false, columnDefinition = "jsonb")
private Map<String, Object> meta = new HashMap<>();
// DB sets these defaults; we keep them read-only to avoid fighting the trigger/defaults
@Column(name = "created_at", insertable = false, updatable = false)
private OffsetDateTime createdAt;
@Column(name = "updated_at", insertable = false, updatable = false)
private OffsetDateTime updatedAt;
// --- getters/setters (generate via IDE) ---
public Long getId() { return id; }
public Integer getProductId() { return productId; }
public void setProductId(Integer productId) { this.productId = productId; }
public EnrichmentType getEnrichmentType() { return enrichmentType; }
public void setEnrichmentType(EnrichmentType enrichmentType) { this.enrichmentType = enrichmentType; }
public EnrichmentSource getSource() { return source; }
public void setSource(EnrichmentSource source) { this.source = source; }
public EnrichmentStatus getStatus() { return status; }
public void setStatus(EnrichmentStatus status) { this.status = status; }
public Integer getSchemaVersion() { return schemaVersion; }
public void setSchemaVersion(Integer schemaVersion) { this.schemaVersion = schemaVersion; }
public Map<String, Object> getAttributes() { return attributes; }
public void setAttributes(Map<String, Object> attributes) { this.attributes = attributes; }
public BigDecimal getConfidence() { return confidence; }
public void setConfidence(BigDecimal confidence) { this.confidence = confidence; }
public String getRationale() { return rationale; }
public void setRationale(String rationale) { this.rationale = rationale; }
public Map<String, Object> getMeta() { return meta; }
public void setMeta(Map<String, Object> meta) { this.meta = meta; }
public OffsetDateTime getCreatedAt() { return createdAt; }
public OffsetDateTime getUpdatedAt() { return updatedAt; }
package group.goforward.battlbuilder.enrichment.model;
import group.goforward.battlbuilder.enrichment.EnrichmentSource;
import group.goforward.battlbuilder.enrichment.EnrichmentStatus;
import group.goforward.battlbuilder.enrichment.EnrichmentType;
import jakarta.persistence.*;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;
import java.math.BigDecimal;
import java.time.OffsetDateTime;
import java.util.HashMap;
import java.util.Map;
@Entity
@Table(name = "product_enrichments")
public class ProductEnrichment {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "product_id", nullable = false)
private Integer productId;
@Enumerated(EnumType.STRING)
@Column(name = "enrichment_type", nullable = false)
private EnrichmentType enrichmentType;
@Enumerated(EnumType.STRING)
@Column(name = "source", nullable = false)
private EnrichmentSource source = EnrichmentSource.AI;
@Enumerated(EnumType.STRING)
@Column(name = "status", nullable = false)
private EnrichmentStatus status = EnrichmentStatus.PENDING_REVIEW;
@Column(name = "schema_version", nullable = false)
private Integer schemaVersion = 1;
@JdbcTypeCode(SqlTypes.JSON)
@Column(name = "attributes", nullable = false, columnDefinition = "jsonb")
private Map<String, Object> attributes = new HashMap<>();
@Column(name = "confidence", precision = 4, scale = 3)
private BigDecimal confidence;
@Column(name = "rationale")
private String rationale;
@JdbcTypeCode(SqlTypes.JSON)
@Column(name = "meta", nullable = false, columnDefinition = "jsonb")
private Map<String, Object> meta = new HashMap<>();
// DB sets these defaults; we keep them read-only to avoid fighting the trigger/defaults
@Column(name = "created_at", insertable = false, updatable = false)
private OffsetDateTime createdAt;
@Column(name = "updated_at", insertable = false, updatable = false)
private OffsetDateTime updatedAt;
// --- getters/setters (generate via IDE) ---
public Long getId() { return id; }
public Integer getProductId() { return productId; }
public void setProductId(Integer productId) { this.productId = productId; }
public EnrichmentType getEnrichmentType() { return enrichmentType; }
public void setEnrichmentType(EnrichmentType enrichmentType) { this.enrichmentType = enrichmentType; }
public EnrichmentSource getSource() { return source; }
public void setSource(EnrichmentSource source) { this.source = source; }
public EnrichmentStatus getStatus() { return status; }
public void setStatus(EnrichmentStatus status) { this.status = status; }
public Integer getSchemaVersion() { return schemaVersion; }
public void setSchemaVersion(Integer schemaVersion) { this.schemaVersion = schemaVersion; }
public Map<String, Object> getAttributes() { return attributes; }
public void setAttributes(Map<String, Object> attributes) { this.attributes = attributes; }
public BigDecimal getConfidence() { return confidence; }
public void setConfidence(BigDecimal confidence) { this.confidence = confidence; }
public String getRationale() { return rationale; }
public void setRationale(String rationale) { this.rationale = rationale; }
public Map<String, Object> getMeta() { return meta; }
public void setMeta(Map<String, Object> meta) { this.meta = meta; }
public OffsetDateTime getCreatedAt() { return createdAt; }
public OffsetDateTime getUpdatedAt() { return updatedAt; }
}

View File

@@ -1,94 +1,94 @@
package group.goforward.battlbuilder.enrichment.repo;
import group.goforward.battlbuilder.enrichment.EnrichmentStatus;
import group.goforward.battlbuilder.enrichment.EnrichmentType;
import group.goforward.battlbuilder.enrichment.model.ProductEnrichment;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.jpa.repository.Modifying;
import group.goforward.battlbuilder.enrichment.dto.EnrichmentQueueItem;
import org.springframework.data.repository.query.Param;
import java.util.List;
import java.util.Optional;
public interface ProductEnrichmentRepository extends JpaRepository<ProductEnrichment, Long> {
boolean existsByProductIdAndEnrichmentTypeAndStatus(
Integer productId,
EnrichmentType enrichmentType,
EnrichmentStatus status
);
@Query("""
select e from ProductEnrichment e
where e.productId = :productId
and e.enrichmentType = :type
and e.status in ('PENDING_REVIEW','APPROVED')
""")
Optional<ProductEnrichment> findActive(Integer productId, EnrichmentType type);
List<ProductEnrichment> findByEnrichmentTypeAndStatusOrderByCreatedAtDesc(
EnrichmentType type,
EnrichmentStatus status,
Pageable pageable
);
@Query("""
select new group.goforward.battlbuilder.enrichment.dto.EnrichmentQueueItem(
e.id,
e.productId,
p.name,
p.slug,
p.mainImageUrl,
b.name,
e.enrichmentType,
e.source,
e.status,
e.schemaVersion,
e.attributes,
e.confidence,
e.rationale,
e.createdAt,
p.caliber,
p.caliberGroup
)
from ProductEnrichment e
join Product p on p.id = e.productId
join p.brand b
where e.enrichmentType = :type
and e.status = :status
order by e.createdAt desc
""")
List<EnrichmentQueueItem> queueWithProduct(
EnrichmentType type,
EnrichmentStatus status,
Pageable pageable
);
@Modifying
@Query("""
update Product p
set p.caliber = :caliber
where p.id = :productId
and (p.caliber is null or trim(p.caliber) = '')
""")
int applyCaliberIfBlank(
@Param("productId") Integer productId,
@Param("caliber") String caliber
);
@Modifying
@Query("""
update Product p
set p.caliberGroup = :caliberGroup
where p.id = :productId
and (p.caliberGroup is null or trim(p.caliberGroup) = '')
""")
int applyCaliberGroupIfBlank(
@Param("productId") Integer productId,
@Param("caliberGroup") String caliberGroup
);
package group.goforward.battlbuilder.enrichment.repo;
import group.goforward.battlbuilder.enrichment.EnrichmentStatus;
import group.goforward.battlbuilder.enrichment.EnrichmentType;
import group.goforward.battlbuilder.enrichment.model.ProductEnrichment;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.jpa.repository.Modifying;
import group.goforward.battlbuilder.enrichment.dto.EnrichmentQueueItem;
import org.springframework.data.repository.query.Param;
import java.util.List;
import java.util.Optional;
public interface ProductEnrichmentRepository extends JpaRepository<ProductEnrichment, Long> {
boolean existsByProductIdAndEnrichmentTypeAndStatus(
Integer productId,
EnrichmentType enrichmentType,
EnrichmentStatus status
);
@Query("""
select e from ProductEnrichment e
where e.productId = :productId
and e.enrichmentType = :type
and e.status in ('PENDING_REVIEW','APPROVED')
""")
Optional<ProductEnrichment> findActive(Integer productId, EnrichmentType type);
List<ProductEnrichment> findByEnrichmentTypeAndStatusOrderByCreatedAtDesc(
EnrichmentType type,
EnrichmentStatus status,
Pageable pageable
);
@Query("""
select new group.goforward.battlbuilder.enrichment.dto.EnrichmentQueueItem(
e.id,
e.productId,
p.name,
p.slug,
p.mainImageUrl,
b.name,
e.enrichmentType,
e.source,
e.status,
e.schemaVersion,
e.attributes,
e.confidence,
e.rationale,
e.createdAt,
p.caliber,
p.caliberGroup
)
from ProductEnrichment e
join Product p on p.id = e.productId
join p.brand b
where e.enrichmentType = :type
and e.status = :status
order by e.createdAt desc
""")
List<EnrichmentQueueItem> queueWithProduct(
EnrichmentType type,
EnrichmentStatus status,
Pageable pageable
);
@Modifying
@Query("""
update Product p
set p.caliber = :caliber
where p.id = :productId
and (p.caliber is null or trim(p.caliber) = '')
""")
int applyCaliberIfBlank(
@Param("productId") Integer productId,
@Param("caliber") String caliber
);
@Modifying
@Query("""
update Product p
set p.caliberGroup = :caliberGroup
where p.id = :productId
and (p.caliberGroup is null or trim(p.caliberGroup) = '')
""")
int applyCaliberGroupIfBlank(
@Param("productId") Integer productId,
@Param("caliberGroup") String caliberGroup
);
}

View File

@@ -1,86 +1,86 @@
package group.goforward.battlbuilder.enrichment.service;
import group.goforward.battlbuilder.enrichment.*;
import group.goforward.battlbuilder.enrichment.model.ProductEnrichment;
import group.goforward.battlbuilder.enrichment.repo.ProductEnrichmentRepository;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.util.HashMap;
import java.util.List;
import java.util.Optional;
@Service
public class CaliberEnrichmentService {
private final ProductEnrichmentRepository enrichmentRepository;
@PersistenceContext
private EntityManager em;
private final CaliberRuleExtractor extractor = new CaliberRuleExtractor();
public CaliberEnrichmentService(ProductEnrichmentRepository enrichmentRepository) {
this.enrichmentRepository = enrichmentRepository;
}
public record RunResult(int scanned, int created) {}
/**
* Creates PENDING_REVIEW caliber enrichments for products that don't already have an active one.
*/
@Transactional
public RunResult runRules(int limit) {
// Adjust Product entity package if needed:
// IMPORTANT: Product must be a mapped @Entity named "Product"
List<Object[]> rows = em.createQuery("""
select p.id, p.name, p.description
from Product p
where p.deletedAt is null
and not exists (
select 1 from ProductEnrichment e
where e.productId = p.id
and e.enrichmentType = group.goforward.battlbuilder.enrichment.EnrichmentType.CALIBER
and e.status in ('PENDING_REVIEW','APPROVED')
)
order by p.id desc
""", Object[].class)
.setMaxResults(limit)
.getResultList();
int created = 0;
for (Object[] r : rows) {
Integer productId = (Integer) r[0];
String name = (String) r[1];
String description = (String) r[2];
Optional<CaliberRuleExtractor.Result> res = extractor.extract(name, description);
if (res.isEmpty()) continue;
var result = res.get();
ProductEnrichment e = new ProductEnrichment();
e.setProductId(productId);
e.setEnrichmentType(EnrichmentType.CALIBER);
e.setSource(EnrichmentSource.RULES);
e.setStatus(EnrichmentStatus.PENDING_REVIEW);
e.setSchemaVersion(1);
var attrs = new HashMap<String, Object>();
attrs.put("caliber", result.caliber());
e.setAttributes(attrs);
e.setConfidence(BigDecimal.valueOf(result.confidence()));
e.setRationale(result.rationale());
enrichmentRepository.save(e);
created++;
}
return new RunResult(rows.size(), created);
}
package group.goforward.battlbuilder.enrichment.service;
import group.goforward.battlbuilder.enrichment.*;
import group.goforward.battlbuilder.enrichment.model.ProductEnrichment;
import group.goforward.battlbuilder.enrichment.repo.ProductEnrichmentRepository;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.util.HashMap;
import java.util.List;
import java.util.Optional;
@Service
public class CaliberEnrichmentService {
private final ProductEnrichmentRepository enrichmentRepository;
@PersistenceContext
private EntityManager em;
private final CaliberRuleExtractor extractor = new CaliberRuleExtractor();
public CaliberEnrichmentService(ProductEnrichmentRepository enrichmentRepository) {
this.enrichmentRepository = enrichmentRepository;
}
public record RunResult(int scanned, int created) {}
/**
* Creates PENDING_REVIEW caliber enrichments for products that don't already have an active one.
*/
@Transactional
public RunResult runRules(int limit) {
// Adjust Product entity package if needed:
// IMPORTANT: Product must be a mapped @Entity named "Product"
List<Object[]> rows = em.createQuery("""
select p.id, p.name, p.description
from Product p
where p.deletedAt is null
and not exists (
select 1 from ProductEnrichment e
where e.productId = p.id
and e.enrichmentType = group.goforward.battlbuilder.enrichment.EnrichmentType.CALIBER
and e.status in ('PENDING_REVIEW','APPROVED')
)
order by p.id desc
""", Object[].class)
.setMaxResults(limit)
.getResultList();
int created = 0;
for (Object[] r : rows) {
Integer productId = (Integer) r[0];
String name = (String) r[1];
String description = (String) r[2];
Optional<CaliberRuleExtractor.Result> res = extractor.extract(name, description);
if (res.isEmpty()) continue;
var result = res.get();
ProductEnrichment e = new ProductEnrichment();
e.setProductId(productId);
e.setEnrichmentType(EnrichmentType.CALIBER);
e.setSource(EnrichmentSource.RULES);
e.setStatus(EnrichmentStatus.PENDING_REVIEW);
e.setSchemaVersion(1);
var attrs = new HashMap<String, Object>();
attrs.put("caliber", result.caliber());
e.setAttributes(attrs);
e.setConfidence(BigDecimal.valueOf(result.confidence()));
e.setRationale(result.rationale());
enrichmentRepository.save(e);
created++;
}
return new RunResult(rows.size(), created);
}
}

View File

@@ -1,35 +1,35 @@
package group.goforward.battlbuilder.enrichment.taxonomies;
import java.util.Locale;
public final class CaliberTaxonomy {
private CaliberTaxonomy() {}
public static String normalizeCaliber(String raw) {
if (raw == null) return null;
String s = raw.trim();
// Canonicalize common variants
String l = s.toLowerCase(Locale.ROOT);
if (l.contains("223 wylde") || l.contains(".223 wylde")) return ".223 Wylde";
if (l.contains("5.56") || l.contains("5,56") || l.contains("5.56x45") || l.contains("5.56x45mm")) return "5.56 NATO";
if (l.contains("223") || l.contains(".223") || l.contains("223 rem") || l.contains("223 remington")) return ".223 Remington";
if (l.contains("300 blackout") || l.contains("300 blk") || l.contains("300 aac")) return "300 BLK";
// fallback: return trimmed original (you can tighten later)
return s;
}
public static String groupForCaliber(String caliberCanonical) {
if (caliberCanonical == null) return null;
String l = caliberCanonical.toLowerCase(Locale.ROOT);
if (l.contains("223") || l.contains("5.56") || l.contains("wylde")) return "223/5.56";
if (l.contains("300 blk") || l.contains("300 blackout") || l.contains("300 aac")) return "300 BLK";
// TODO add more buckets: 308/7.62, 6.5 CM, 9mm, etc.
return null;
}
package group.goforward.battlbuilder.enrichment.taxonomies;
import java.util.Locale;
public final class CaliberTaxonomy {
private CaliberTaxonomy() {}
public static String normalizeCaliber(String raw) {
if (raw == null) return null;
String s = raw.trim();
// Canonicalize common variants
String l = s.toLowerCase(Locale.ROOT);
if (l.contains("223 wylde") || l.contains(".223 wylde")) return ".223 Wylde";
if (l.contains("5.56") || l.contains("5,56") || l.contains("5.56x45") || l.contains("5.56x45mm")) return "5.56 NATO";
if (l.contains("223") || l.contains(".223") || l.contains("223 rem") || l.contains("223 remington")) return ".223 Remington";
if (l.contains("300 blackout") || l.contains("300 blk") || l.contains("300 aac")) return "300 BLK";
// fallback: return trimmed original (you can tighten later)
return s;
}
public static String groupForCaliber(String caliberCanonical) {
if (caliberCanonical == null) return null;
String l = caliberCanonical.toLowerCase(Locale.ROOT);
if (l.contains("223") || l.contains("5.56") || l.contains("wylde")) return "223/5.56";
if (l.contains("300 blk") || l.contains("300 blackout") || l.contains("300 aac")) return "300 BLK";
// TODO add more buckets: 308/7.62, 6.5 CM, 9mm, etc.
return null;
}
}

View File

@@ -1,174 +1,174 @@
package group.goforward.battlbuilder.model;
import jakarta.persistence.*;
import java.time.OffsetDateTime;
/**
* Entity representing an authentication token.
* Tokens are used for beta verification, magic login links, and password resets.
* Tokens are hashed before storage and can be consumed and expired.
*
* @see jakarta.persistence.Entity
*/
@Entity
@Table(
name = "auth_tokens",
indexes = {
@Index(name = "idx_auth_tokens_email", columnList = "email"),
@Index(name = "idx_auth_tokens_type_hash", columnList = "type, token_hash")
}
)
public class AuthToken {
/**
* Enumeration of token types.
*/
public enum TokenType {
/** Token for beta access verification. */
BETA_VERIFY,
/** Token for magic link login. */
MAGIC_LOGIN,
/** Token for password reset. */
PASSWORD_RESET
}
/** The primary key identifier for the token. */
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
/** The email address associated with this token. */
@Column(nullable = false)
private String email;
/** The type of token. */
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 32)
private TokenType type;
/** The hashed token value. */
@Column(name = "token_hash", nullable = false, length = 64)
private String tokenHash;
/** The timestamp when this token expires. */
@Column(name = "expires_at", nullable = false)
private OffsetDateTime expiresAt;
/** The timestamp when this token was consumed/used. */
@Column(name = "consumed_at")
private OffsetDateTime consumedAt;
/** The timestamp when this token was created. */
@Column(name = "created_at", nullable = false)
private OffsetDateTime createdAt;
// getters/setters
/**
* Gets the primary key identifier for the token.
*
* @return the token ID
*/
public Long getId() { return id; }
/**
* Gets the email address associated with this token.
*
* @return the email address
*/
public String getEmail() { return email; }
/**
* Sets the email address associated with this token.
*
* @param email the email address to set
*/
public void setEmail(String email) { this.email = email; }
/**
* Gets the type of token.
*
* @return the token type
*/
public TokenType getType() { return type; }
/**
* Sets the type of token.
*
* @param type the token type to set
*/
public void setType(TokenType type) { this.type = type; }
/**
* Gets the hashed token value.
*
* @return the token hash
*/
public String getTokenHash() { return tokenHash; }
/**
* Sets the hashed token value.
*
* @param tokenHash the token hash to set
*/
public void setTokenHash(String tokenHash) { this.tokenHash = tokenHash; }
/**
* Gets the timestamp when this token expires.
*
* @return the expiration timestamp
*/
public OffsetDateTime getExpiresAt() { return expiresAt; }
/**
* Sets the timestamp when this token expires.
*
* @param expiresAt the expiration timestamp to set
*/
public void setExpiresAt(OffsetDateTime expiresAt) { this.expiresAt = expiresAt; }
/**
* Gets the timestamp when this token was consumed/used.
*
* @return the consumed timestamp, or null if not yet consumed
*/
public OffsetDateTime getConsumedAt() { return consumedAt; }
/**
* Sets the timestamp when this token was consumed/used.
*
* @param consumedAt the consumed timestamp to set
*/
public void setConsumedAt(OffsetDateTime consumedAt) { this.consumedAt = consumedAt; }
/**
* Gets the timestamp when this token was created.
*
* @return the creation timestamp
*/
public OffsetDateTime getCreatedAt() { return createdAt; }
/**
* Sets the timestamp when this token was created.
*
* @param createdAt the creation timestamp to set
*/
public void setCreatedAt(OffsetDateTime createdAt) { this.createdAt = createdAt; }
/**
* Checks if this token has been consumed/used.
*
* @return true if the token has been consumed, false otherwise
*/
@Transient
public boolean isConsumed() { return consumedAt != null; }
/**
* Checks if this token has expired at the given time.
*
* @param now the current time to check against
* @return true if the token has expired, false otherwise
*/
@Transient
public boolean isExpired(OffsetDateTime now) { return expiresAt.isBefore(now); }
package group.goforward.battlbuilder.model;
import jakarta.persistence.*;
import java.time.OffsetDateTime;
/**
* Entity representing an authentication token.
* Tokens are used for beta verification, magic login links, and password resets.
* Tokens are hashed before storage and can be consumed and expired.
*
* @see jakarta.persistence.Entity
*/
@Entity
@Table(
name = "auth_tokens",
indexes = {
@Index(name = "idx_auth_tokens_email", columnList = "email"),
@Index(name = "idx_auth_tokens_type_hash", columnList = "type, token_hash")
}
)
public class AuthToken {
/**
* Enumeration of token types.
*/
public enum TokenType {
/** Token for beta access verification. */
BETA_VERIFY,
/** Token for magic link login. */
MAGIC_LOGIN,
/** Token for password reset. */
PASSWORD_RESET
}
/** The primary key identifier for the token. */
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
/** The email address associated with this token. */
@Column(nullable = false)
private String email;
/** The type of token. */
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 32)
private TokenType type;
/** The hashed token value. */
@Column(name = "token_hash", nullable = false, length = 64)
private String tokenHash;
/** The timestamp when this token expires. */
@Column(name = "expires_at", nullable = false)
private OffsetDateTime expiresAt;
/** The timestamp when this token was consumed/used. */
@Column(name = "consumed_at")
private OffsetDateTime consumedAt;
/** The timestamp when this token was created. */
@Column(name = "created_at", nullable = false)
private OffsetDateTime createdAt;
// getters/setters
/**
* Gets the primary key identifier for the token.
*
* @return the token ID
*/
public Long getId() { return id; }
/**
* Gets the email address associated with this token.
*
* @return the email address
*/
public String getEmail() { return email; }
/**
* Sets the email address associated with this token.
*
* @param email the email address to set
*/
public void setEmail(String email) { this.email = email; }
/**
* Gets the type of token.
*
* @return the token type
*/
public TokenType getType() { return type; }
/**
* Sets the type of token.
*
* @param type the token type to set
*/
public void setType(TokenType type) { this.type = type; }
/**
* Gets the hashed token value.
*
* @return the token hash
*/
public String getTokenHash() { return tokenHash; }
/**
* Sets the hashed token value.
*
* @param tokenHash the token hash to set
*/
public void setTokenHash(String tokenHash) { this.tokenHash = tokenHash; }
/**
* Gets the timestamp when this token expires.
*
* @return the expiration timestamp
*/
public OffsetDateTime getExpiresAt() { return expiresAt; }
/**
* Sets the timestamp when this token expires.
*
* @param expiresAt the expiration timestamp to set
*/
public void setExpiresAt(OffsetDateTime expiresAt) { this.expiresAt = expiresAt; }
/**
* Gets the timestamp when this token was consumed/used.
*
* @return the consumed timestamp, or null if not yet consumed
*/
public OffsetDateTime getConsumedAt() { return consumedAt; }
/**
* Sets the timestamp when this token was consumed/used.
*
* @param consumedAt the consumed timestamp to set
*/
public void setConsumedAt(OffsetDateTime consumedAt) { this.consumedAt = consumedAt; }
/**
* Gets the timestamp when this token was created.
*
* @return the creation timestamp
*/
public OffsetDateTime getCreatedAt() { return createdAt; }
/**
* Sets the timestamp when this token was created.
*
* @param createdAt the creation timestamp to set
*/
public void setCreatedAt(OffsetDateTime createdAt) { this.createdAt = createdAt; }
/**
* Checks if this token has been consumed/used.
*
* @return true if the token has been consumed, false otherwise
*/
@Transient
public boolean isConsumed() { return consumedAt != null; }
/**
* Checks if this token has expired at the given time.
*
* @param now the current time to check against
* @return true if the token has expired, false otherwise
*/
@Transient
public boolean isExpired(OffsetDateTime now) { return expiresAt.isBefore(now); }
}

View File

@@ -1,122 +1,122 @@
package group.goforward.battlbuilder.model;
import jakarta.persistence.*;
import org.hibernate.annotations.ColumnDefault;
import java.time.OffsetDateTime;
/**
* build_profiles
* 1:1 with builds (build_id is both PK and FK)
* <p>
* Dev notes:
* - This is the "feed/meta" table for Option B (caliber, class, cover image, tags, etc.)
* - Keep it lightweight. Anything social (votes/comments/media) lives elsewhere.
*/
@Entity
@Table(name = "build_profiles")
public class BuildProfile {
// -----------------------------------------------------
// Primary Key = FK to builds.id
// -----------------------------------------------------
@Id
@Column(name = "build_id", nullable = false)
private Integer buildId;
@OneToOne(fetch = FetchType.LAZY, optional = false)
@MapsId
@JoinColumn(name = "build_id", nullable = false)
private Build build;
// -----------------------------------------------------
// Feed metadata fields (MVP)
// -----------------------------------------------------
/**
* Examples: "AR-15", "AR-10", "AR-9"
* (String for now; we can enum later once stable.)
*/
@Column(name = "platform")
private String platform;
/**
* Examples: "5.56", "9mm", ".300 BLK"
*/
@Column(name = "caliber")
private String caliber;
/**
* Expected values for UI: "Rifle" | "Pistol" | "NFA"
* (String for now; UI will default if missing.)
*/
@Column(name = "build_class")
private String buildClass;
/**
* Optional hero image used by /builds cards.
*/
@Column(name = "cover_image_url")
private String coverImageUrl;
/**
* MVP tags storage:
* - store as comma-separated string: "Duty,NV-Ready,LPVO"
* - later: switch to jsonb or join table when needed
*/
@Column(name = "tags_csv")
private String tagsCsv;
// -----------------------------------------------------
// Timestamps (optional but nice for auditing)
// -----------------------------------------------------
@ColumnDefault("now()")
@Column(name = "created_at", nullable = false)
private OffsetDateTime createdAt;
@ColumnDefault("now()")
@Column(name = "updated_at", nullable = false)
private OffsetDateTime updatedAt;
@PrePersist
public void prePersist() {
OffsetDateTime now = OffsetDateTime.now();
if (createdAt == null) createdAt = now;
if (updatedAt == null) updatedAt = now;
}
@PreUpdate
public void preUpdate() {
updatedAt = OffsetDateTime.now();
}
// -----------------------------------------------------
// Getters / Setters
// -----------------------------------------------------
public Integer getBuildId() { return buildId; }
public void setBuildId(Integer buildId) { this.buildId = buildId; }
public Build getBuild() { return build; }
public void setBuild(Build build) { this.build = build; }
public String getPlatform() { return platform; }
public void setPlatform(String platform) { this.platform = platform; }
public String getCaliber() { return caliber; }
public void setCaliber(String caliber) { this.caliber = caliber; }
public String getBuildClass() { return buildClass; }
public void setBuildClass(String buildClass) { this.buildClass = buildClass; }
public String getCoverImageUrl() { return coverImageUrl; }
public void setCoverImageUrl(String coverImageUrl) { this.coverImageUrl = coverImageUrl; }
public String getTagsCsv() { return tagsCsv; }
public void setTagsCsv(String tagsCsv) { this.tagsCsv = tagsCsv; }
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; }
package group.goforward.battlbuilder.model;
import jakarta.persistence.*;
import org.hibernate.annotations.ColumnDefault;
import java.time.OffsetDateTime;
/**
* build_profiles
* 1:1 with builds (build_id is both PK and FK)
* <p>
* Dev notes:
* - This is the "feed/meta" table for Option B (caliber, class, cover image, tags, etc.)
* - Keep it lightweight. Anything social (votes/comments/media) lives elsewhere.
*/
@Entity
@Table(name = "build_profiles")
public class BuildProfile {
// -----------------------------------------------------
// Primary Key = FK to builds.id
// -----------------------------------------------------
@Id
@Column(name = "build_id", nullable = false)
private Integer buildId;
@OneToOne(fetch = FetchType.LAZY, optional = false)
@MapsId
@JoinColumn(name = "build_id", nullable = false)
private Build build;
// -----------------------------------------------------
// Feed metadata fields (MVP)
// -----------------------------------------------------
/**
* Examples: "AR-15", "AR-10", "AR-9"
* (String for now; we can enum later once stable.)
*/
@Column(name = "platform")
private String platform;
/**
* Examples: "5.56", "9mm", ".300 BLK"
*/
@Column(name = "caliber")
private String caliber;
/**
* Expected values for UI: "Rifle" | "Pistol" | "NFA"
* (String for now; UI will default if missing.)
*/
@Column(name = "build_class")
private String buildClass;
/**
* Optional hero image used by /builds cards.
*/
@Column(name = "cover_image_url")
private String coverImageUrl;
/**
* MVP tags storage:
* - store as comma-separated string: "Duty,NV-Ready,LPVO"
* - later: switch to jsonb or join table when needed
*/
@Column(name = "tags_csv")
private String tagsCsv;
// -----------------------------------------------------
// Timestamps (optional but nice for auditing)
// -----------------------------------------------------
@ColumnDefault("now()")
@Column(name = "created_at", nullable = false)
private OffsetDateTime createdAt;
@ColumnDefault("now()")
@Column(name = "updated_at", nullable = false)
private OffsetDateTime updatedAt;
@PrePersist
public void prePersist() {
OffsetDateTime now = OffsetDateTime.now();
if (createdAt == null) createdAt = now;
if (updatedAt == null) updatedAt = now;
}
@PreUpdate
public void preUpdate() {
updatedAt = OffsetDateTime.now();
}
// -----------------------------------------------------
// Getters / Setters
// -----------------------------------------------------
public Integer getBuildId() { return buildId; }
public void setBuildId(Integer buildId) { this.buildId = buildId; }
public Build getBuild() { return build; }
public void setBuild(Build build) { this.build = build; }
public String getPlatform() { return platform; }
public void setPlatform(String platform) { this.platform = platform; }
public String getCaliber() { return caliber; }
public void setCaliber(String caliber) { this.caliber = caliber; }
public String getBuildClass() { return buildClass; }
public void setBuildClass(String buildClass) { this.buildClass = buildClass; }
public String getCoverImageUrl() { return coverImageUrl; }
public void setCoverImageUrl(String coverImageUrl) { this.coverImageUrl = coverImageUrl; }
public String getTagsCsv() { return tagsCsv; }
public void setTagsCsv(String tagsCsv) { this.tagsCsv = tagsCsv; }
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

@@ -1,310 +1,310 @@
package group.goforward.battlbuilder.model;
import jakarta.persistence.*;
import java.time.LocalDateTime;
/**
* Entity representing an email request/queue entry.
* Tracks email sending status, delivery, and engagement metrics (opens, clicks).
*
* @see jakarta.persistence.Entity
*/
@Entity
@Table(name = "email_requests")
@NamedQueries({
@NamedQuery(
name = "EmailRequest.findSent",
query = "SELECT e FROM EmailRequest e WHERE e.status = group.goforward.battlbuilder.model.EmailStatus.SENT"
),
@NamedQuery(
name = "EmailRequest.findFailed",
query = "SELECT e FROM EmailRequest e WHERE e.status = group.goforward.battlbuilder.model.EmailStatus.FAILED"
),
@NamedQuery(
name = "EmailRequest.findPending",
query = "SELECT e FROM EmailRequest e WHERE e.status = group.goforward.battlbuilder.model.EmailStatus.PENDING"
)
})
public class EmailRequest {
/** The primary key identifier for the email request. */
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
/** The email address of the recipient. */
@Column(nullable = false)
private String recipient;
/** The email subject line. */
@Column(nullable = false)
private String subject;
/** The email body content. */
@Column(columnDefinition = "TEXT")
private String body;
/** The template key used to generate this email (if applicable). */
@Column(name = "template_key", length = 100)
private String templateKey;
/** The status of the email (PENDING, SENT, FAILED). */
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private EmailStatus status; // PENDING, SENT, FAILED
/** The timestamp when the email was sent. */
@Column(name = "sent_at")
private LocalDateTime sentAt;
/** The error message if the email failed to send. */
@Column(name = "error_message")
private String errorMessage;
/** The timestamp when the email was first opened. */
@Column(name = "opened_at")
private LocalDateTime openedAt;
/** The number of times the email has been opened. Defaults to 0. */
@Column(name = "open_count", nullable = false)
private Integer openCount = 0;
/** The timestamp when a link in the email was first clicked. */
@Column(name = "clicked_at")
private LocalDateTime clickedAt;
/** The number of times links in the email have been clicked. Defaults to 0. */
@Column(name = "click_count", nullable = false)
private Integer clickCount = 0;
/** The timestamp when this email request was created. */
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
/** The timestamp when this email request was last updated. */
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
/**
* Lifecycle hook called before persisting a new entity.
* Initializes timestamps and default values.
*/
@PrePersist
protected void onCreate() {
LocalDateTime now = LocalDateTime.now();
createdAt = now;
updatedAt = now;
if (status == null) status = EmailStatus.PENDING;
if (openCount == null) openCount = 0;
if (clickCount == null) clickCount = 0;
}
/**
* Lifecycle hook called before updating an existing entity.
* Updates the updatedAt timestamp.
*/
@PreUpdate
protected void onUpdate() {
updatedAt = LocalDateTime.now();
}
// ===== Getters / Setters =====
/**
* Gets the primary key identifier for the email request.
*
* @return the email request ID
*/
public Long getId() { return id; }
/**
* Sets the primary key identifier for the email request.
*
* @param id the email request ID to set
*/
public void setId(Long id) { this.id = id; }
/**
* Gets the email address of the recipient.
*
* @return the recipient email address
*/
public String getRecipient() { return recipient; }
/**
* Sets the email address of the recipient.
*
* @param recipient the recipient email address to set
*/
public void setRecipient(String recipient) { this.recipient = recipient; }
/**
* Gets the email subject line.
*
* @return the subject
*/
public String getSubject() { return subject; }
/**
* Sets the email subject line.
*
* @param subject the subject to set
*/
public void setSubject(String subject) { this.subject = subject; }
/**
* Gets the email body content.
*
* @return the body, or null if not set
*/
public String getBody() { return body; }
/**
* Sets the email body content.
*
* @param body the body to set
*/
public void setBody(String body) { this.body = body; }
/**
* Gets the template key used to generate this email.
*
* @return the template key, or null if not set
*/
public String getTemplateKey() { return templateKey; }
/**
* Sets the template key used to generate this email.
*
* @param templateKey the template key to set
*/
public void setTemplateKey(String templateKey) { this.templateKey = templateKey; }
/**
* Gets the status of the email.
*
* @return the email status (PENDING, SENT, FAILED)
*/
public EmailStatus getStatus() { return status; }
/**
* Sets the status of the email.
*
* @param status the email status to set
*/
public void setStatus(EmailStatus status) { this.status = status; }
/**
* Gets the timestamp when the email was sent.
*
* @return the sent timestamp, or null if not yet sent
*/
public LocalDateTime getSentAt() { return sentAt; }
/**
* Sets the timestamp when the email was sent.
*
* @param sentAt the sent timestamp to set
*/
public void setSentAt(LocalDateTime sentAt) { this.sentAt = sentAt; }
/**
* Gets the error message if the email failed to send.
*
* @return the error message, or null if no error
*/
public String getErrorMessage() { return errorMessage; }
/**
* Sets the error message if the email failed to send.
*
* @param errorMessage the error message to set
*/
public void setErrorMessage(String errorMessage) { this.errorMessage = errorMessage; }
/**
* Gets the timestamp when the email was first opened.
*
* @return the opened timestamp, or null if never opened
*/
public LocalDateTime getOpenedAt() { return openedAt; }
/**
* Sets the timestamp when the email was first opened.
*
* @param openedAt the opened timestamp to set
*/
public void setOpenedAt(LocalDateTime openedAt) { this.openedAt = openedAt; }
/**
* Gets the number of times the email has been opened.
*
* @return the open count
*/
public Integer getOpenCount() { return openCount; }
/**
* Sets the number of times the email has been opened.
*
* @param openCount the open count to set
*/
public void setOpenCount(Integer openCount) { this.openCount = openCount; }
/**
* Gets the timestamp when a link in the email was first clicked.
*
* @return the clicked timestamp, or null if never clicked
*/
public LocalDateTime getClickedAt() { return clickedAt; }
/**
* Sets the timestamp when a link in the email was first clicked.
*
* @param clickedAt the clicked timestamp to set
*/
public void setClickedAt(LocalDateTime clickedAt) { this.clickedAt = clickedAt; }
/**
* Gets the number of times links in the email have been clicked.
*
* @return the click count
*/
public Integer getClickCount() { return clickCount; }
/**
* Sets the number of times links in the email have been clicked.
*
* @param clickCount the click count to set
*/
public void setClickCount(Integer clickCount) { this.clickCount = clickCount; }
/**
* Gets the timestamp when this email request was created.
*
* @return the creation timestamp
*/
public LocalDateTime getCreatedAt() { return createdAt; }
/**
* Sets the timestamp when this email request was created.
*
* @param createdAt the creation timestamp to set
*/
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
/**
* Gets the timestamp when this email request was last updated.
*
* @return the last update timestamp
*/
public LocalDateTime getUpdatedAt() { return updatedAt; }
/**
* Sets the timestamp when this email request was last updated.
*
* @param updatedAt the last update timestamp to set
*/
public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; }
package group.goforward.battlbuilder.model;
import jakarta.persistence.*;
import java.time.LocalDateTime;
/**
* Entity representing an email request/queue entry.
* Tracks email sending status, delivery, and engagement metrics (opens, clicks).
*
* @see jakarta.persistence.Entity
*/
@Entity
@Table(name = "email_requests")
@NamedQueries({
@NamedQuery(
name = "EmailRequest.findSent",
query = "SELECT e FROM EmailRequest e WHERE e.status = group.goforward.battlbuilder.model.EmailStatus.SENT"
),
@NamedQuery(
name = "EmailRequest.findFailed",
query = "SELECT e FROM EmailRequest e WHERE e.status = group.goforward.battlbuilder.model.EmailStatus.FAILED"
),
@NamedQuery(
name = "EmailRequest.findPending",
query = "SELECT e FROM EmailRequest e WHERE e.status = group.goforward.battlbuilder.model.EmailStatus.PENDING"
)
})
public class EmailRequest {
/** The primary key identifier for the email request. */
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
/** The email address of the recipient. */
@Column(nullable = false)
private String recipient;
/** The email subject line. */
@Column(nullable = false)
private String subject;
/** The email body content. */
@Column(columnDefinition = "TEXT")
private String body;
/** The template key used to generate this email (if applicable). */
@Column(name = "template_key", length = 100)
private String templateKey;
/** The status of the email (PENDING, SENT, FAILED). */
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private EmailStatus status; // PENDING, SENT, FAILED
/** The timestamp when the email was sent. */
@Column(name = "sent_at")
private LocalDateTime sentAt;
/** The error message if the email failed to send. */
@Column(name = "error_message")
private String errorMessage;
/** The timestamp when the email was first opened. */
@Column(name = "opened_at")
private LocalDateTime openedAt;
/** The number of times the email has been opened. Defaults to 0. */
@Column(name = "open_count", nullable = false)
private Integer openCount = 0;
/** The timestamp when a link in the email was first clicked. */
@Column(name = "clicked_at")
private LocalDateTime clickedAt;
/** The number of times links in the email have been clicked. Defaults to 0. */
@Column(name = "click_count", nullable = false)
private Integer clickCount = 0;
/** The timestamp when this email request was created. */
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
/** The timestamp when this email request was last updated. */
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
/**
* Lifecycle hook called before persisting a new entity.
* Initializes timestamps and default values.
*/
@PrePersist
protected void onCreate() {
LocalDateTime now = LocalDateTime.now();
createdAt = now;
updatedAt = now;
if (status == null) status = EmailStatus.PENDING;
if (openCount == null) openCount = 0;
if (clickCount == null) clickCount = 0;
}
/**
* Lifecycle hook called before updating an existing entity.
* Updates the updatedAt timestamp.
*/
@PreUpdate
protected void onUpdate() {
updatedAt = LocalDateTime.now();
}
// ===== Getters / Setters =====
/**
* Gets the primary key identifier for the email request.
*
* @return the email request ID
*/
public Long getId() { return id; }
/**
* Sets the primary key identifier for the email request.
*
* @param id the email request ID to set
*/
public void setId(Long id) { this.id = id; }
/**
* Gets the email address of the recipient.
*
* @return the recipient email address
*/
public String getRecipient() { return recipient; }
/**
* Sets the email address of the recipient.
*
* @param recipient the recipient email address to set
*/
public void setRecipient(String recipient) { this.recipient = recipient; }
/**
* Gets the email subject line.
*
* @return the subject
*/
public String getSubject() { return subject; }
/**
* Sets the email subject line.
*
* @param subject the subject to set
*/
public void setSubject(String subject) { this.subject = subject; }
/**
* Gets the email body content.
*
* @return the body, or null if not set
*/
public String getBody() { return body; }
/**
* Sets the email body content.
*
* @param body the body to set
*/
public void setBody(String body) { this.body = body; }
/**
* Gets the template key used to generate this email.
*
* @return the template key, or null if not set
*/
public String getTemplateKey() { return templateKey; }
/**
* Sets the template key used to generate this email.
*
* @param templateKey the template key to set
*/
public void setTemplateKey(String templateKey) { this.templateKey = templateKey; }
/**
* Gets the status of the email.
*
* @return the email status (PENDING, SENT, FAILED)
*/
public EmailStatus getStatus() { return status; }
/**
* Sets the status of the email.
*
* @param status the email status to set
*/
public void setStatus(EmailStatus status) { this.status = status; }
/**
* Gets the timestamp when the email was sent.
*
* @return the sent timestamp, or null if not yet sent
*/
public LocalDateTime getSentAt() { return sentAt; }
/**
* Sets the timestamp when the email was sent.
*
* @param sentAt the sent timestamp to set
*/
public void setSentAt(LocalDateTime sentAt) { this.sentAt = sentAt; }
/**
* Gets the error message if the email failed to send.
*
* @return the error message, or null if no error
*/
public String getErrorMessage() { return errorMessage; }
/**
* Sets the error message if the email failed to send.
*
* @param errorMessage the error message to set
*/
public void setErrorMessage(String errorMessage) { this.errorMessage = errorMessage; }
/**
* Gets the timestamp when the email was first opened.
*
* @return the opened timestamp, or null if never opened
*/
public LocalDateTime getOpenedAt() { return openedAt; }
/**
* Sets the timestamp when the email was first opened.
*
* @param openedAt the opened timestamp to set
*/
public void setOpenedAt(LocalDateTime openedAt) { this.openedAt = openedAt; }
/**
* Gets the number of times the email has been opened.
*
* @return the open count
*/
public Integer getOpenCount() { return openCount; }
/**
* Sets the number of times the email has been opened.
*
* @param openCount the open count to set
*/
public void setOpenCount(Integer openCount) { this.openCount = openCount; }
/**
* Gets the timestamp when a link in the email was first clicked.
*
* @return the clicked timestamp, or null if never clicked
*/
public LocalDateTime getClickedAt() { return clickedAt; }
/**
* Sets the timestamp when a link in the email was first clicked.
*
* @param clickedAt the clicked timestamp to set
*/
public void setClickedAt(LocalDateTime clickedAt) { this.clickedAt = clickedAt; }
/**
* Gets the number of times links in the email have been clicked.
*
* @return the click count
*/
public Integer getClickCount() { return clickCount; }
/**
* Sets the number of times links in the email have been clicked.
*
* @param clickCount the click count to set
*/
public void setClickCount(Integer clickCount) { this.clickCount = clickCount; }
/**
* Gets the timestamp when this email request was created.
*
* @return the creation timestamp
*/
public LocalDateTime getCreatedAt() { return createdAt; }
/**
* Sets the timestamp when this email request was created.
*
* @param createdAt the creation timestamp to set
*/
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
/**
* Gets the timestamp when this email request was last updated.
*
* @return the last update timestamp
*/
public LocalDateTime getUpdatedAt() { return updatedAt; }
/**
* Sets the timestamp when this email request was last updated.
*
* @param updatedAt the last update timestamp to set
*/
public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; }
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,28 +1,28 @@
package group.goforward.battlbuilder.repo;
import group.goforward.battlbuilder.model.AuthToken;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.time.OffsetDateTime;
import java.util.Optional;
public interface AuthTokenRepository extends JpaRepository<AuthToken, Long> {
Optional<AuthToken> findFirstByTypeAndTokenHash(AuthToken.TokenType type, String tokenHash);
// ✅ Used for "Invited" badge: active (not expired, not consumed) magic token exists
@Query("""
select (count(t) > 0) from AuthToken t
where lower(t.email) = lower(:email)
and t.type = :type
and t.expiresAt > :now
and t.consumedAt is null
""")
boolean hasActiveToken(
@Param("email") String email,
@Param("type") AuthToken.TokenType type,
@Param("now") OffsetDateTime now
);
package group.goforward.battlbuilder.repo;
import group.goforward.battlbuilder.model.AuthToken;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.time.OffsetDateTime;
import java.util.Optional;
public interface AuthTokenRepository extends JpaRepository<AuthToken, Long> {
Optional<AuthToken> findFirstByTypeAndTokenHash(AuthToken.TokenType type, String tokenHash);
// ✅ Used for "Invited" badge: active (not expired, not consumed) magic token exists
@Query("""
select (count(t) > 0) from AuthToken t
where lower(t.email) = lower(:email)
and t.type = :type
and t.expiresAt > :now
and t.consumedAt is null
""")
boolean hasActiveToken(
@Param("email") String email,
@Param("type") AuthToken.TokenType type,
@Param("now") OffsetDateTime now
);
}

View File

@@ -1,12 +1,12 @@
package group.goforward.battlbuilder.repo;
import group.goforward.battlbuilder.model.BuildProfile;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Collection;
import java.util.List;
public interface BuildProfileRepository extends JpaRepository<BuildProfile, Integer> {
List<BuildProfile> findByBuildIdIn(Collection<Integer> buildIds);
package group.goforward.battlbuilder.repo;
import group.goforward.battlbuilder.model.BuildProfile;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Collection;
import java.util.List;
public interface BuildProfileRepository extends JpaRepository<BuildProfile, Integer> {
List<BuildProfile> findByBuildIdIn(Collection<Integer> buildIds);
}

View File

@@ -1,18 +1,18 @@
package group.goforward.battlbuilder.repo;
import group.goforward.battlbuilder.model.CanonicalCategory;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import java.util.List;
public interface CanonicalCategoryRepository extends JpaRepository<CanonicalCategory, Integer> {
@Query("""
select c
from CanonicalCategory c
where c.deletedAt is null
order by c.name asc
""")
List<CanonicalCategory> findAllActive();
package group.goforward.battlbuilder.repo;
import group.goforward.battlbuilder.model.CanonicalCategory;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import java.util.List;
public interface CanonicalCategoryRepository extends JpaRepository<CanonicalCategory, Integer> {
@Query("""
select c
from CanonicalCategory c
where c.deletedAt is null
order by c.name asc
""")
List<CanonicalCategory> findAllActive();
}

View File

@@ -1,22 +1,22 @@
package group.goforward.battlbuilder.repo;
import group.goforward.battlbuilder.model.CategoryMapping;
import group.goforward.battlbuilder.model.Merchant;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import java.util.List;
public interface CategoryMappingRepository extends JpaRepository<CategoryMapping, Integer> {
// All mappings for a merchant, ordered nicely
List<CategoryMapping> findByMerchantIdOrderByRawCategoryPathAsc(Integer merchantId);
// Merchants that actually have mappings (for the dropdown)
@Query("""
select distinct cm.merchant
from CategoryMapping cm
order by cm.merchant.name asc
""")
List<Merchant> findDistinctMerchantsWithMappings();
package group.goforward.battlbuilder.repo;
import group.goforward.battlbuilder.model.CategoryMapping;
import group.goforward.battlbuilder.model.Merchant;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import java.util.List;
public interface CategoryMappingRepository extends JpaRepository<CategoryMapping, Integer> {
// All mappings for a merchant, ordered nicely
List<CategoryMapping> findByMerchantIdOrderByRawCategoryPathAsc(Integer merchantId);
// Merchants that actually have mappings (for the dropdown)
@Query("""
select distinct cm.merchant
from CategoryMapping cm
order by cm.merchant.name asc
""")
List<Merchant> findDistinctMerchantsWithMappings();
}

View File

@@ -1,16 +1,16 @@
package group.goforward.battlbuilder.repo;
import group.goforward.battlbuilder.model.EmailRequest;
import group.goforward.battlbuilder.model.EmailStatus;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface EmailRequestRepository extends JpaRepository<EmailRequest, Long> {
List<EmailRequest> findByStatus(EmailStatus status);
List<EmailRequest> findByStatusOrderByCreatedAtDesc(EmailStatus status);
}
package group.goforward.battlbuilder.repo;
import group.goforward.battlbuilder.model.EmailRequest;
import group.goforward.battlbuilder.model.EmailStatus;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface EmailRequestRepository extends JpaRepository<EmailRequest, Long> {
List<EmailRequest> findByStatus(EmailStatus status);
List<EmailRequest> findByStatusOrderByCreatedAtDesc(EmailStatus status);
}

View File

@@ -1,14 +1,14 @@
package group.goforward.battlbuilder.repo;
import group.goforward.battlbuilder.model.EmailTemplate;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.Optional;
@Repository
public interface EmailTemplateRepository extends JpaRepository<EmailTemplate, Long> {
Optional<EmailTemplate> findByTemplateKeyAndEnabledTrue(String templateKey);
package group.goforward.battlbuilder.repo;
import group.goforward.battlbuilder.model.EmailTemplate;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.Optional;
@Repository
public interface EmailTemplateRepository extends JpaRepository<EmailTemplate, Long> {
Optional<EmailTemplate> findByTemplateKeyAndEnabledTrue(String templateKey);
}

View File

@@ -1,63 +1,63 @@
package group.goforward.battlbuilder.repo;
import group.goforward.battlbuilder.model.MerchantCategoryMap;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
@Repository
public interface MerchantCategoryMapRepository extends JpaRepository<MerchantCategoryMap, Integer> {
// Pull candidates ordered by platform specificity: exact match first, then ANY/null.
@Query("""
select m
from MerchantCategoryMap m
where m.merchant.id = :merchantId
and lower(m.rawCategory) = lower(:rawCategory) and m.enabled = true
and m.deletedAt is null
and (m.platform is null or m.platform = 'ANY' or m.platform = :platform)
order by
case
when m.platform = :platform then 0
when m.platform = 'ANY' then 1
when m.platform is null then 2
else 3
end,
m.updatedAt desc
""")
List<MerchantCategoryMap> findCandidates(
@Param("merchantId") Integer merchantId,
@Param("rawCategory") String rawCategory,
@Param("platform") String platform
);
default Optional<MerchantCategoryMap> findBest(Integer merchantId, String rawCategory, String platform) {
List<MerchantCategoryMap> candidates = findCandidates(merchantId, rawCategory, platform);
return candidates.stream().findFirst();
}
// Optional helper if you want a quick "latest mapping regardless of platform"
Optional<MerchantCategoryMap> findFirstByMerchant_IdAndRawCategoryAndEnabledTrueAndDeletedAtIsNullOrderByUpdatedAtDesc(
Integer merchantId,
String rawCategory
);
// Optional: if you still want a role-only lookup list for debugging
@Query("""
select mcm.canonicalPartRole
from MerchantCategoryMap mcm
where mcm.merchant.id = :merchantId
and mcm.rawCategory = :rawCategory
and mcm.enabled = true
and mcm.deletedAt is null
order by mcm.updatedAt desc
""")
List<String> findCanonicalPartRoles(
@Param("merchantId") Integer merchantId,
@Param("rawCategory") String rawCategory
);
package group.goforward.battlbuilder.repo;
import group.goforward.battlbuilder.model.MerchantCategoryMap;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
@Repository
public interface MerchantCategoryMapRepository extends JpaRepository<MerchantCategoryMap, Integer> {
// Pull candidates ordered by platform specificity: exact match first, then ANY/null.
@Query("""
select m
from MerchantCategoryMap m
where m.merchant.id = :merchantId
and lower(m.rawCategory) = lower(:rawCategory) and m.enabled = true
and m.deletedAt is null
and (m.platform is null or m.platform = 'ANY' or m.platform = :platform)
order by
case
when m.platform = :platform then 0
when m.platform = 'ANY' then 1
when m.platform is null then 2
else 3
end,
m.updatedAt desc
""")
List<MerchantCategoryMap> findCandidates(
@Param("merchantId") Integer merchantId,
@Param("rawCategory") String rawCategory,
@Param("platform") String platform
);
default Optional<MerchantCategoryMap> findBest(Integer merchantId, String rawCategory, String platform) {
List<MerchantCategoryMap> candidates = findCandidates(merchantId, rawCategory, platform);
return candidates.stream().findFirst();
}
// Optional helper if you want a quick "latest mapping regardless of platform"
Optional<MerchantCategoryMap> findFirstByMerchant_IdAndRawCategoryAndEnabledTrueAndDeletedAtIsNullOrderByUpdatedAtDesc(
Integer merchantId,
String rawCategory
);
// Optional: if you still want a role-only lookup list for debugging
@Query("""
select mcm.canonicalPartRole
from MerchantCategoryMap mcm
where mcm.merchant.id = :merchantId
and mcm.rawCategory = :rawCategory
and mcm.enabled = true
and mcm.deletedAt is null
order by mcm.updatedAt desc
""")
List<String> findCanonicalPartRoles(
@Param("merchantId") Integer merchantId,
@Param("rawCategory") String rawCategory
);
}

View File

@@ -1,14 +1,14 @@
package group.goforward.battlbuilder.repo;
import group.goforward.battlbuilder.model.PartCategory;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional;
public interface PartCategoryRepository extends JpaRepository<PartCategory, Integer> {
Optional<PartCategory> findBySlug(String slug);
List<PartCategory> findAllByOrderByGroupNameAscSortOrderAscNameAsc();
package group.goforward.battlbuilder.repo;
import group.goforward.battlbuilder.model.PartCategory;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional;
public interface PartCategoryRepository extends JpaRepository<PartCategory, Integer> {
Optional<PartCategory> findBySlug(String slug);
List<PartCategory> findAllByOrderByGroupNameAscSortOrderAscNameAsc();
}

View File

@@ -1,22 +1,22 @@
package group.goforward.battlbuilder.repo;
import group.goforward.battlbuilder.model.PartRoleMapping;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional;
public interface PartRoleMappingRepository extends JpaRepository<PartRoleMapping, Integer> {
// Used by admin screens / lists (case-sensitive, no platform normalization)
List<PartRoleMapping> findByPlatformAndDeletedAtIsNullOrderByPartRoleAsc(String platform);
// Used by builder/bootstrap flows (case-insensitive)
List<PartRoleMapping> findAllByPlatformIgnoreCaseAndDeletedAtIsNullOrderByPartRoleAsc(String platform);
// Used by resolvers when mapping a single role (case-insensitive)
Optional<PartRoleMapping> findFirstByPlatformIgnoreCaseAndPartRoleIgnoreCaseAndDeletedAtIsNull(
String platform,
String partRole
);
package group.goforward.battlbuilder.repo;
import group.goforward.battlbuilder.model.PartRoleMapping;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional;
public interface PartRoleMappingRepository extends JpaRepository<PartRoleMapping, Integer> {
// Used by admin screens / lists (case-sensitive, no platform normalization)
List<PartRoleMapping> findByPlatformAndDeletedAtIsNullOrderByPartRoleAsc(String platform);
// Used by builder/bootstrap flows (case-insensitive)
List<PartRoleMapping> findAllByPlatformIgnoreCaseAndDeletedAtIsNullOrderByPartRoleAsc(String platform);
// Used by resolvers when mapping a single role (case-insensitive)
Optional<PartRoleMapping> findFirstByPlatformIgnoreCaseAndPartRoleIgnoreCaseAndDeletedAtIsNull(
String platform,
String partRole
);
}

View File

@@ -1,10 +1,10 @@
package group.goforward.battlbuilder.repo;
import group.goforward.battlbuilder.model.PartRoleRule;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface PartRoleRuleRepository extends JpaRepository<PartRoleRule, Long> {
List<PartRoleRule> findAllByActiveTrueOrderByPriorityDescIdAsc();
package group.goforward.battlbuilder.repo;
import group.goforward.battlbuilder.model.PartRoleRule;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface PartRoleRuleRepository extends JpaRepository<PartRoleRule, Long> {
List<PartRoleRule> findAllByActiveTrueOrderByPriorityDescIdAsc();
}

View File

@@ -1,14 +1,14 @@
package group.goforward.battlbuilder.repo;
import group.goforward.battlbuilder.model.PlatformRule;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface PlatformRuleRepository extends JpaRepository<PlatformRule, Long> {
// Active rules, highest priority first (tie-breaker: id asc for stability)
List<PlatformRule> findAllByActiveTrueOrderByPriorityDescIdAsc();
package group.goforward.battlbuilder.repo;
import group.goforward.battlbuilder.model.PlatformRule;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface PlatformRuleRepository extends JpaRepository<PlatformRule, Long> {
// Active rules, highest priority first (tie-breaker: id asc for stability)
List<PlatformRule> findAllByActiveTrueOrderByPriorityDescIdAsc();
}

View File

@@ -1,44 +1,44 @@
package group.goforward.battlbuilder.repo;
import group.goforward.battlbuilder.model.User;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import java.util.List;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.Optional;
import java.util.UUID;
public interface UserRepository extends JpaRepository<User, Integer> {
Optional<User> findByEmailIgnoreCaseAndDeletedAtIsNull(String email);
boolean existsByEmailIgnoreCaseAndDeletedAtIsNull(String email);
Optional<User> findByUuid(UUID uuid);
boolean existsByRole(String role);
Optional<User> findByUuidAndDeletedAtIsNull(UUID uuid);
List<User> findByRoleAndIsActiveFalseAndDeletedAtIsNull(String role);
// ✅ Pending beta requests (what you described)
Page<User> findByRoleAndIsActiveFalseAndDeletedAtIsNullOrderByCreatedAtDesc(
String role,
Pageable pageable
);
// ✅ Optional: find user by verification token for confirm flow (if you dont already have it)
Optional<User> findByVerificationTokenAndDeletedAtIsNull(String verificationToken);
// Set Username
Optional<User> findByUsernameIgnoreCaseAndDeletedAtIsNull(String username);
boolean existsByUsernameIgnoreCaseAndDeletedAtIsNull(String username);
@Query(value = "select * from users where role = :role and is_active = false and deleted_at is null order by created_at asc limit :limit", nativeQuery = true)
List<User> findTopNByRoleAndIsActiveFalseAndDeletedAtIsNull(@Param("role") String role, @Param("limit") int limit);
package group.goforward.battlbuilder.repo;
import group.goforward.battlbuilder.model.User;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import java.util.List;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.Optional;
import java.util.UUID;
public interface UserRepository extends JpaRepository<User, Integer> {
Optional<User> findByEmailIgnoreCaseAndDeletedAtIsNull(String email);
boolean existsByEmailIgnoreCaseAndDeletedAtIsNull(String email);
Optional<User> findByUuid(UUID uuid);
boolean existsByRole(String role);
Optional<User> findByUuidAndDeletedAtIsNull(UUID uuid);
List<User> findByRoleAndIsActiveFalseAndDeletedAtIsNull(String role);
// ✅ Pending beta requests (what you described)
Page<User> findByRoleAndIsActiveFalseAndDeletedAtIsNullOrderByCreatedAtDesc(
String role,
Pageable pageable
);
// ✅ Optional: find user by verification token for confirm flow (if you dont already have it)
Optional<User> findByVerificationTokenAndDeletedAtIsNull(String verificationToken);
// Set Username
Optional<User> findByUsernameIgnoreCaseAndDeletedAtIsNull(String username);
boolean existsByUsernameIgnoreCaseAndDeletedAtIsNull(String username);
@Query(value = "select * from users where role = :role and is_active = false and deleted_at is null order by created_at asc limit :limit", nativeQuery = true)
List<User> findTopNByRoleAndIsActiveFalseAndDeletedAtIsNull(@Param("role") String role, @Param("limit") int limit);
}

View File

@@ -1,57 +1,57 @@
package group.goforward.battlbuilder.repo.catalog.spec;
import group.goforward.battlbuilder.model.Product;
import group.goforward.battlbuilder.model.ProductStatus;
import group.goforward.battlbuilder.model.ProductVisibility;
import org.springframework.data.jpa.domain.Specification;
import jakarta.persistence.criteria.JoinType;
import java.util.List;
public class CatalogProductSpecifications {
private CatalogProductSpecifications() {}
// Default public catalog rules
public static Specification<Product> isCatalogVisible() {
return (root, query, cb) -> cb.and(
cb.isNull(root.get("deletedAt")),
cb.equal(root.get("status"), ProductStatus.ACTIVE),
cb.equal(root.get("visibility"), ProductVisibility.PUBLIC),
cb.isTrue(root.get("builderEligible"))
);
}
public static Specification<Product> platformEquals(String platform) {
return (root, query, cb) -> cb.equal(root.get("platform"), platform);
}
public static Specification<Product> partRoleIn(List<String> roles) {
return (root, query, cb) -> root.get("partRole").in(roles);
}
public static Specification<Product> brandNameIn(List<String> brandNames) {
return (root, query, cb) -> {
root.fetch("brand", JoinType.LEFT);
query.distinct(true);
return root.join("brand", JoinType.LEFT).get("name").in(brandNames);
};
}
public static Specification<Product> queryLike(String q) {
final String like = "%" + q.toLowerCase().trim() + "%";
return (root, query, cb) -> {
root.fetch("brand", JoinType.LEFT);
query.distinct(true);
var brandJoin = root.join("brand", JoinType.LEFT);
return cb.or(
cb.like(cb.lower(root.get("name")), like),
cb.like(cb.lower(brandJoin.get("name")), like),
cb.like(cb.lower(root.get("mpn")), like),
cb.like(cb.lower(root.get("upc")), like)
);
};
}
package group.goforward.battlbuilder.repo.catalog.spec;
import group.goforward.battlbuilder.model.Product;
import group.goforward.battlbuilder.model.ProductStatus;
import group.goforward.battlbuilder.model.ProductVisibility;
import org.springframework.data.jpa.domain.Specification;
import jakarta.persistence.criteria.JoinType;
import java.util.List;
public class CatalogProductSpecifications {
private CatalogProductSpecifications() {}
// Default public catalog rules
public static Specification<Product> isCatalogVisible() {
return (root, query, cb) -> cb.and(
cb.isNull(root.get("deletedAt")),
cb.equal(root.get("status"), ProductStatus.ACTIVE),
cb.equal(root.get("visibility"), ProductVisibility.PUBLIC),
cb.isTrue(root.get("builderEligible"))
);
}
public static Specification<Product> platformEquals(String platform) {
return (root, query, cb) -> cb.equal(root.get("platform"), platform);
}
public static Specification<Product> partRoleIn(List<String> roles) {
return (root, query, cb) -> root.get("partRole").in(roles);
}
public static Specification<Product> brandNameIn(List<String> brandNames) {
return (root, query, cb) -> {
root.fetch("brand", JoinType.LEFT);
query.distinct(true);
return root.join("brand", JoinType.LEFT).get("name").in(brandNames);
};
}
public static Specification<Product> queryLike(String q) {
final String like = "%" + q.toLowerCase().trim() + "%";
return (root, query, cb) -> {
root.fetch("brand", JoinType.LEFT);
query.distinct(true);
var brandJoin = root.join("brand", JoinType.LEFT);
return cb.or(
cb.like(cb.lower(root.get("name")), like),
cb.like(cb.lower(brandJoin.get("name")), like),
cb.like(cb.lower(root.get("mpn")), like),
cb.like(cb.lower(root.get("upc")), like)
);
};
}
}

View File

@@ -1,11 +1,11 @@
/**
* Repositories package for the BattlBuilder application.
* <p>
* Contains Spring Data JPA repository interfaces for database
* access and persistence operations.
*
* @author Forward Group, LLC
* @version 1.0
* @since 2025-12-10
*/
package group.goforward.battlbuilder.repo;
/**
* Repositories package for the BattlBuilder application.
* <p>
* Contains Spring Data JPA repository interfaces for database
* access and persistence operations.
*
* @author Forward Group, LLC
* @version 1.0
* @since 2025-12-10
*/
package group.goforward.battlbuilder.repo;

View File

@@ -1,14 +1,14 @@
package group.goforward.battlbuilder.repo.projections;
public interface CatalogRow {
Long getId();
String getName();
String getPlatform();
String getPartRole();
String getImageUrl(); // or mainImageUrl depending on your schema
String getBrand();
Double getPrice();
String getBuyUrl();
Boolean getInStock();
package group.goforward.battlbuilder.repo.projections;
public interface CatalogRow {
Long getId();
String getName();
String getPlatform();
String getPartRole();
String getImageUrl(); // or mainImageUrl depending on your schema
String getBrand();
Double getPrice();
String getBuyUrl();
Boolean getInStock();
}

View File

@@ -1,25 +1,25 @@
package group.goforward.battlbuilder.security;
import group.goforward.battlbuilder.model.User;
import group.goforward.battlbuilder.repo.UserRepository;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
@Service
public class CustomUserDetailsService implements UserDetailsService {
private final UserRepository users;
public CustomUserDetailsService(UserRepository users) {
this.users = users;
}
@Override
public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
User user = users.findByEmailIgnoreCaseAndDeletedAtIsNull(email)
.orElseThrow(() -> new UsernameNotFoundException("User not found"));
return new CustomUserDetails(user);
}
package group.goforward.battlbuilder.security;
import group.goforward.battlbuilder.model.User;
import group.goforward.battlbuilder.repo.UserRepository;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
@Service
public class CustomUserDetailsService implements UserDetailsService {
private final UserRepository users;
public CustomUserDetailsService(UserRepository users) {
this.users = users;
}
@Override
public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
User user = users.findByEmailIgnoreCaseAndDeletedAtIsNull(email)
.orElseThrow(() -> new UsernameNotFoundException("User not found"));
return new CustomUserDetails(user);
}
}

View File

@@ -1,88 +1,88 @@
package group.goforward.battlbuilder.security;
import group.goforward.battlbuilder.model.User;
import group.goforward.battlbuilder.repo.UserRepository;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.authentication.AnonymousAuthenticationToken;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.UUID;
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtService jwtService;
private final UserRepository userRepository;
public JwtAuthenticationFilter(JwtService jwtService, UserRepository userRepository) {
this.jwtService = jwtService;
this.userRepository = userRepository;
}
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain
) throws ServletException, IOException {
String authHeader = request.getHeader("Authorization");
if (!StringUtils.hasText(authHeader) || !authHeader.startsWith("Bearer ")) {
filterChain.doFilter(request, response);
return;
}
// ✅ If already authenticated with a REAL user, skip.
// ✅ If it's anonymous, we should continue and replace it.
var existing = SecurityContextHolder.getContext().getAuthentication();
if (existing != null
&& existing.isAuthenticated()
&& !(existing instanceof AnonymousAuthenticationToken)) {
filterChain.doFilter(request, response);
return;
}
String token = authHeader.substring(7);
if (!jwtService.isTokenValid(token)) {
filterChain.doFilter(request, response);
return;
}
UUID userUuid = jwtService.extractUserUuid(token);
if (userUuid == null) {
filterChain.doFilter(request, response);
return;
}
User user = userRepository.findByUuid(userUuid).orElse(null);
if (user == null || !Boolean.TRUE.equals(user.isActive())) {
filterChain.doFilter(request, response);
return;
}
CustomUserDetails userDetails = new CustomUserDetails(user);
UsernamePasswordAuthenticationToken authToken =
new UsernamePasswordAuthenticationToken(
user.getUuid().toString(), // principal = UUID string
null,
userDetails.getAuthorities()
);
authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authToken);
filterChain.doFilter(request, response);
}
package group.goforward.battlbuilder.security;
import group.goforward.battlbuilder.model.User;
import group.goforward.battlbuilder.repo.UserRepository;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.authentication.AnonymousAuthenticationToken;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.UUID;
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtService jwtService;
private final UserRepository userRepository;
public JwtAuthenticationFilter(JwtService jwtService, UserRepository userRepository) {
this.jwtService = jwtService;
this.userRepository = userRepository;
}
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain
) throws ServletException, IOException {
String authHeader = request.getHeader("Authorization");
if (!StringUtils.hasText(authHeader) || !authHeader.startsWith("Bearer ")) {
filterChain.doFilter(request, response);
return;
}
// ✅ If already authenticated with a REAL user, skip.
// ✅ If it's anonymous, we should continue and replace it.
var existing = SecurityContextHolder.getContext().getAuthentication();
if (existing != null
&& existing.isAuthenticated()
&& !(existing instanceof AnonymousAuthenticationToken)) {
filterChain.doFilter(request, response);
return;
}
String token = authHeader.substring(7);
if (!jwtService.isTokenValid(token)) {
filterChain.doFilter(request, response);
return;
}
UUID userUuid = jwtService.extractUserUuid(token);
if (userUuid == null) {
filterChain.doFilter(request, response);
return;
}
User user = userRepository.findByUuid(userUuid).orElse(null);
if (user == null || !Boolean.TRUE.equals(user.isActive())) {
filterChain.doFilter(request, response);
return;
}
CustomUserDetails userDetails = new CustomUserDetails(user);
UsernamePasswordAuthenticationToken authToken =
new UsernamePasswordAuthenticationToken(
user.getUuid().toString(), // principal = UUID string
null,
userDetails.getAuthorities()
);
authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authToken);
filterChain.doFilter(request, response);
}
}

View File

@@ -1,16 +1,16 @@
package group.goforward.battlbuilder.service;
import group.goforward.battlbuilder.model.Brand;
import java.util.List;
import java.util.Optional;
public interface BrandService {
List<Brand> findAll();
Optional<Brand> findById(Integer id);
Brand save(Brand item);
void deleteById(Integer id);
}
package group.goforward.battlbuilder.service;
import group.goforward.battlbuilder.model.Brand;
import java.util.List;
import java.util.Optional;
public interface BrandService {
List<Brand> findAll();
Optional<Brand> findById(Integer id);
Brand save(Brand item);
void deleteById(Integer id);
}

View File

@@ -1,26 +1,26 @@
package group.goforward.battlbuilder.service;
import group.goforward.battlbuilder.web.dto.BuildDto;
import group.goforward.battlbuilder.web.dto.BuildFeedCardDto;
import group.goforward.battlbuilder.web.dto.BuildSummaryDto;
import group.goforward.battlbuilder.web.dto.UpdateBuildRequest;
import java.util.List;
import java.util.UUID;
public interface BuildService {
List<BuildFeedCardDto> listPublicBuilds(int limit);
List<BuildSummaryDto> listMyBuilds(int limit);
BuildDto getMyBuild(UUID uuid);
BuildDto createMyBuild(UpdateBuildRequest req);
BuildDto updateMyBuild(UUID uuid, UpdateBuildRequest req);
BuildDto getPublicBuild(UUID uuid);
void deleteMyBuild(UUID uuid);
package group.goforward.battlbuilder.service;
import group.goforward.battlbuilder.web.dto.BuildDto;
import group.goforward.battlbuilder.web.dto.BuildFeedCardDto;
import group.goforward.battlbuilder.web.dto.BuildSummaryDto;
import group.goforward.battlbuilder.web.dto.UpdateBuildRequest;
import java.util.List;
import java.util.UUID;
public interface BuildService {
List<BuildFeedCardDto> listPublicBuilds(int limit);
List<BuildSummaryDto> listMyBuilds(int limit);
BuildDto getMyBuild(UUID uuid);
BuildDto createMyBuild(UpdateBuildRequest req);
BuildDto updateMyBuild(UUID uuid, UpdateBuildRequest req);
BuildDto getPublicBuild(UUID uuid);
void deleteMyBuild(UUID uuid);
}

View File

@@ -1,23 +1,23 @@
package group.goforward.battlbuilder.service;
import group.goforward.battlbuilder.web.dto.ProductSummaryDto;
import group.goforward.battlbuilder.web.dto.catalog.CatalogProductIdsRequest;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import java.util.List;
public interface CatalogQueryService {
Page<ProductSummaryDto> getOptions(
String platform,
String partRole,
List<String> partRoles,
List<String> brands,
String q,
Pageable pageable
);
List<ProductSummaryDto> getProductsByIds(CatalogProductIdsRequest request);
package group.goforward.battlbuilder.service;
import group.goforward.battlbuilder.web.dto.ProductSummaryDto;
import group.goforward.battlbuilder.web.dto.catalog.CatalogProductIdsRequest;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import java.util.List;
public interface CatalogQueryService {
Page<ProductSummaryDto> getOptions(
String platform,
String partRole,
List<String> partRoles,
List<String> brands,
String q,
Pageable pageable
);
List<ProductSummaryDto> getProductsByIds(CatalogProductIdsRequest request);
}

View File

@@ -1,27 +1,27 @@
package group.goforward.battlbuilder.service;
import group.goforward.battlbuilder.imports.MerchantFeedRow;
import group.goforward.battlbuilder.model.Merchant;
import group.goforward.battlbuilder.model.PartRoleSource;
public interface CategoryClassificationService {
record Result(
String platform,
String partRole,
String rawCategoryKey,
PartRoleSource source,
String reason
) {}
/**
* Legacy convenience: derives rawCategoryKey + platform from row.
*/
Result classify(Merchant merchant, MerchantFeedRow row);
/**
* Preferred for ETL: caller already computed platform + rawCategoryKey.
* This prevents platformResolver overrides from drifting vs mapping selection.
*/
Result classify(Merchant merchant, MerchantFeedRow row, String platformFinal, String rawCategoryKey);
package group.goforward.battlbuilder.service;
import group.goforward.battlbuilder.imports.MerchantFeedRow;
import group.goforward.battlbuilder.model.Merchant;
import group.goforward.battlbuilder.model.PartRoleSource;
public interface CategoryClassificationService {
record Result(
String platform,
String partRole,
String rawCategoryKey,
PartRoleSource source,
String reason
) {}
/**
* Legacy convenience: derives rawCategoryKey + platform from row.
*/
Result classify(Merchant merchant, MerchantFeedRow row);
/**
* Preferred for ETL: caller already computed platform + rawCategoryKey.
* This prevents platformResolver overrides from drifting vs mapping selection.
*/
Result classify(Merchant merchant, MerchantFeedRow row, String platformFinal, String rawCategoryKey);
}

View File

@@ -1,72 +1,72 @@
// src/main/java/group/goforward/ballistic/service/CategoryMappingRecommendationService.java
package group.goforward.battlbuilder.service;
import group.goforward.battlbuilder.model.Product;
import group.goforward.battlbuilder.repo.ProductRepository;
import group.goforward.battlbuilder.web.dto.CategoryMappingRecommendationDto;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class CategoryMappingRecommendationService {
private final ProductRepository productRepository;
public CategoryMappingRecommendationService(ProductRepository productRepository) {
this.productRepository = productRepository;
}
public List<CategoryMappingRecommendationDto> listRecommendations() {
var groups = productRepository.findUnmappedCategoryGroups();
return groups.stream()
.map(row -> {
String merchantName = (String) row.get("merchantName");
String rawCategoryKey = (String) row.get("rawCategoryKey");
long count = (long) row.get("productCount");
// Pull one sample product name
List<Product> examples = productRepository
.findExamplesForCategoryGroup(merchantName, rawCategoryKey);
String sampleName = examples.isEmpty()
? null
: examples.get(0).getName();
String recommendedRole = inferPartRoleFromRawKey(rawCategoryKey, sampleName);
return new CategoryMappingRecommendationDto(
merchantName,
rawCategoryKey,
count,
recommendedRole,
sampleName
);
})
.toList();
}
private String inferPartRoleFromRawKey(String rawKey, String sampleName) {
String blob = (rawKey + " " + (sampleName != null ? sampleName : "")).toLowerCase();
if (blob.contains("handguard") || blob.contains("rail")) return "handguard";
if (blob.contains("barrel")) return "barrel";
if (blob.contains("upper")) return "upper-receiver";
if (blob.contains("lower")) return "lower-receiver";
if (blob.contains("mag") || blob.contains("magazine")) return "magazine";
if (blob.contains("stock") || blob.contains("buttstock")) return "stock";
if (blob.contains("grip")) return "grip";
if (blob.contains("trigger")) return "trigger";
if (blob.contains("sight") || blob.contains("iron sights")) return "sights";
if (blob.contains("optic") || blob.contains("scope") || blob.contains("red dot")) return "optic";
if (blob.contains("buffer")) return "buffer";
if (blob.contains("gas block")) return "gas-block";
if (blob.contains("gas tube")) return "gas-tube";
if (blob.contains("muzzle")) return "muzzle-device";
if (blob.contains("sling")) return "sling";
if (blob.contains("bipod")) return "bipod";
if (blob.contains("tool")) return "tools";
return "UNKNOWN";
}
// src/main/java/group/goforward/ballistic/service/CategoryMappingRecommendationService.java
package group.goforward.battlbuilder.service;
import group.goforward.battlbuilder.model.Product;
import group.goforward.battlbuilder.repo.ProductRepository;
import group.goforward.battlbuilder.web.dto.CategoryMappingRecommendationDto;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class CategoryMappingRecommendationService {
private final ProductRepository productRepository;
public CategoryMappingRecommendationService(ProductRepository productRepository) {
this.productRepository = productRepository;
}
public List<CategoryMappingRecommendationDto> listRecommendations() {
var groups = productRepository.findUnmappedCategoryGroups();
return groups.stream()
.map(row -> {
String merchantName = (String) row.get("merchantName");
String rawCategoryKey = (String) row.get("rawCategoryKey");
long count = (long) row.get("productCount");
// Pull one sample product name
List<Product> examples = productRepository
.findExamplesForCategoryGroup(merchantName, rawCategoryKey);
String sampleName = examples.isEmpty()
? null
: examples.get(0).getName();
String recommendedRole = inferPartRoleFromRawKey(rawCategoryKey, sampleName);
return new CategoryMappingRecommendationDto(
merchantName,
rawCategoryKey,
count,
recommendedRole,
sampleName
);
})
.toList();
}
private String inferPartRoleFromRawKey(String rawKey, String sampleName) {
String blob = (rawKey + " " + (sampleName != null ? sampleName : "")).toLowerCase();
if (blob.contains("handguard") || blob.contains("rail")) return "handguard";
if (blob.contains("barrel")) return "barrel";
if (blob.contains("upper")) return "upper-receiver";
if (blob.contains("lower")) return "lower-receiver";
if (blob.contains("mag") || blob.contains("magazine")) return "magazine";
if (blob.contains("stock") || blob.contains("buttstock")) return "stock";
if (blob.contains("grip")) return "grip";
if (blob.contains("trigger")) return "trigger";
if (blob.contains("sight") || blob.contains("iron sights")) return "sights";
if (blob.contains("optic") || blob.contains("scope") || blob.contains("red dot")) return "optic";
if (blob.contains("buffer")) return "buffer";
if (blob.contains("gas block")) return "gas-block";
if (blob.contains("gas tube")) return "gas-tube";
if (blob.contains("muzzle")) return "muzzle-device";
if (blob.contains("sling")) return "sling";
if (blob.contains("bipod")) return "bipod";
if (blob.contains("tool")) return "tools";
return "UNKNOWN";
}
}

View File

@@ -1,52 +1,52 @@
package group.goforward.battlbuilder.service;
import group.goforward.battlbuilder.model.User;
import group.goforward.battlbuilder.repo.UserRepository;
import org.springframework.http.HttpStatus;
import org.springframework.security.authentication.AnonymousAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException;
import java.util.UUID;
@Service
public class CurrentUserService {
private final UserRepository userRepository;
public CurrentUserService(UserRepository userRepository) {
this.userRepository = userRepository;
}
/** Returns the authenticated User (401 if missing/invalid). */
public User requireUser() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
// No auth, or anonymous auth => 401
if (auth == null || !auth.isAuthenticated() || auth instanceof AnonymousAuthenticationToken) {
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Not authenticated");
}
// In your setup, JwtAuthenticationFilter sets auth name to UUID string
String principal = auth.getName();
if (principal == null || principal.isBlank() || "anonymousUser".equalsIgnoreCase(principal)) {
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Not authenticated");
}
final UUID userUuid;
try {
userUuid = UUID.fromString(principal);
} catch (IllegalArgumentException e) {
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid auth principal", e);
}
return userRepository.findByUuid(userUuid)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "User not found"));
}
public Integer requireUserId() {
return requireUser().getId();
}
package group.goforward.battlbuilder.service;
import group.goforward.battlbuilder.model.User;
import group.goforward.battlbuilder.repo.UserRepository;
import org.springframework.http.HttpStatus;
import org.springframework.security.authentication.AnonymousAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException;
import java.util.UUID;
@Service
public class CurrentUserService {
private final UserRepository userRepository;
public CurrentUserService(UserRepository userRepository) {
this.userRepository = userRepository;
}
/** Returns the authenticated User (401 if missing/invalid). */
public User requireUser() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
// No auth, or anonymous auth => 401
if (auth == null || !auth.isAuthenticated() || auth instanceof AnonymousAuthenticationToken) {
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Not authenticated");
}
// In your setup, JwtAuthenticationFilter sets auth name to UUID string
String principal = auth.getName();
if (principal == null || principal.isBlank() || "anonymousUser".equalsIgnoreCase(principal)) {
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Not authenticated");
}
final UUID userUuid;
try {
userUuid = UUID.fromString(principal);
} catch (IllegalArgumentException e) {
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid auth principal", e);
}
return userRepository.findByUuid(userUuid)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "User not found"));
}
public Integer requireUserId() {
return requireUser().getId();
}
}

View File

@@ -1,42 +1,42 @@
// src/main/java/group/goforward/ballistic/service/ImportStatusAdminService.java
package group.goforward.battlbuilder.service;
import group.goforward.battlbuilder.model.ImportStatus;
import group.goforward.battlbuilder.repo.ProductRepository;
import group.goforward.battlbuilder.web.dto.ImportStatusByMerchantDto;
import group.goforward.battlbuilder.web.dto.ImportStatusSummaryDto;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class ImportStatusAdminService {
private final ProductRepository productRepository;
public ImportStatusAdminService(ProductRepository productRepository) {
this.productRepository = productRepository;
}
public List<ImportStatusSummaryDto> summarizeByStatus() {
return productRepository.aggregateByImportStatus()
.stream()
.map(row -> new ImportStatusSummaryDto(
(ImportStatus) row.get("status"),
(long) row.get("count")
))
.toList();
}
public List<ImportStatusByMerchantDto> summarizeByMerchant() {
return productRepository.aggregateByMerchantAndStatus()
.stream()
.map(row -> new ImportStatusByMerchantDto(
(String) row.get("merchantName"),
(String) row.get("platform"),
(ImportStatus) row.get("status"),
(long) row.get("count")
))
.toList();
}
// src/main/java/group/goforward/ballistic/service/ImportStatusAdminService.java
package group.goforward.battlbuilder.service;
import group.goforward.battlbuilder.model.ImportStatus;
import group.goforward.battlbuilder.repo.ProductRepository;
import group.goforward.battlbuilder.web.dto.ImportStatusByMerchantDto;
import group.goforward.battlbuilder.web.dto.ImportStatusSummaryDto;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class ImportStatusAdminService {
private final ProductRepository productRepository;
public ImportStatusAdminService(ProductRepository productRepository) {
this.productRepository = productRepository;
}
public List<ImportStatusSummaryDto> summarizeByStatus() {
return productRepository.aggregateByImportStatus()
.stream()
.map(row -> new ImportStatusSummaryDto(
(ImportStatus) row.get("status"),
(long) row.get("count")
))
.toList();
}
public List<ImportStatusByMerchantDto> summarizeByMerchant() {
return productRepository.aggregateByMerchantAndStatus()
.stream()
.map(row -> new ImportStatusByMerchantDto(
(String) row.get("merchantName"),
(String) row.get("platform"),
(ImportStatus) row.get("status"),
(long) row.get("count")
))
.toList();
}
}

View File

@@ -1,228 +1,228 @@
package group.goforward.battlbuilder.service;
import group.goforward.battlbuilder.model.ImportStatus;
import group.goforward.battlbuilder.model.Merchant;
import group.goforward.battlbuilder.model.MerchantCategoryMap;
import group.goforward.battlbuilder.model.CanonicalCategory;
import group.goforward.battlbuilder.repo.CanonicalCategoryRepository;
import group.goforward.battlbuilder.repo.MerchantCategoryMapRepository;
import group.goforward.battlbuilder.repo.MerchantRepository;
import group.goforward.battlbuilder.repo.ProductRepository;
import group.goforward.battlbuilder.web.dto.MappingOptionsDto;
import group.goforward.battlbuilder.web.dto.PendingMappingBucketDto;
import group.goforward.battlbuilder.web.dto.RawCategoryMappingRowDto;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Locale;
import java.util.Optional;
@Service
public class MappingAdminService {
private final ProductRepository productRepository;
private final MerchantCategoryMapRepository merchantCategoryMapRepository;
private final MerchantRepository merchantRepository;
private final CanonicalCategoryRepository canonicalCategoryRepository;
private final ReclassificationService reclassificationService;
public MappingAdminService(
ProductRepository productRepository,
MerchantCategoryMapRepository merchantCategoryMapRepository,
MerchantRepository merchantRepository,
CanonicalCategoryRepository canonicalCategoryRepository,
ReclassificationService reclassificationService
) {
this.productRepository = productRepository;
this.merchantCategoryMapRepository = merchantCategoryMapRepository;
this.merchantRepository = merchantRepository;
this.canonicalCategoryRepository = canonicalCategoryRepository;
this.reclassificationService = reclassificationService;
}
// =========================
// 1) EXISTING: Role buckets
// =========================
@Transactional(readOnly = true)
public List<PendingMappingBucketDto> listPendingBuckets() {
List<Object[]> rows =
productRepository.findPendingMappingBuckets(ImportStatus.PENDING_MAPPING);
return rows.stream()
.map(row -> {
Integer merchantId = (Integer) row[0];
String merchantName = (String) row[1];
String rawCategoryKey = (String) row[2];
Long count = (Long) row[3];
return new PendingMappingBucketDto(
merchantId,
merchantName,
rawCategoryKey,
count != null ? count : 0L
);
})
.toList();
}
/**
* Part Role mapping:
* Writes merchant_category_map.canonical_part_role and applies to products.
*/
@Transactional
public int applyMapping(Integer merchantId, String rawCategoryKey, String mappedPartRole) {
if (merchantId == null || rawCategoryKey == null || rawCategoryKey.isBlank()
|| mappedPartRole == null || mappedPartRole.isBlank()) {
throw new IllegalArgumentException("merchantId, rawCategoryKey, and mappedPartRole are required");
}
Merchant merchant = merchantRepository.findById(merchantId)
.orElseThrow(() -> new IllegalArgumentException("Merchant not found: " + merchantId));
// NOTE: this creates a new row every time. If you want "upsert", use findBest() like we do below.
MerchantCategoryMap mapping = new MerchantCategoryMap();
mapping.setMerchant(merchant);
mapping.setRawCategory(rawCategoryKey.trim());
mapping.setEnabled(true);
// SOURCE OF TRUTH (builder slot mapping)
mapping.setCanonicalPartRole(mappedPartRole.trim());
merchantCategoryMapRepository.save(mapping);
return reclassificationService.applyMappingToProducts(merchantId, rawCategoryKey);
}
@Transactional
public int applyMappingToProducts(Integer merchantId, String rawCategoryKey) {
if (merchantId == null) throw new IllegalArgumentException("merchantId is required");
if (rawCategoryKey == null || rawCategoryKey.isBlank())
throw new IllegalArgumentException("rawCategoryKey is required");
return reclassificationService.applyMappingToProducts(merchantId, rawCategoryKey);
}
// ==========================================
// 2) NEW: Options endpoint for Catalog UI
// ==========================================
@Transactional(readOnly = true)
public MappingOptionsDto getOptions() {
var merchants = merchantRepository.findAll().stream()
.map(m -> new MappingOptionsDto.MerchantOptionDto(m.getId(), m.getName()))
.toList();
var categories = canonicalCategoryRepository.findAllActive().stream()
.map(c -> new MappingOptionsDto.CanonicalCategoryOptionDto(
c.getId(),
c.getName(),
c.getSlug()
))
.toList();
return new MappingOptionsDto(merchants, categories);
}
// =====================================================
// 3) NEW: Raw categories list for Catalog mapping table
// =====================================================
@Transactional(readOnly = true)
public List<RawCategoryMappingRowDto> listRawCategories(Integer merchantId, String platform, String q, Integer limit) {
if (merchantId == null) throw new IllegalArgumentException("merchantId is required");
String plat = normalizePlatform(platform);
String query = (q == null || q.isBlank()) ? null : q.trim();
int lim = (limit == null || limit <= 0) ? 500 : Math.min(limit, 2000);
List<Object[]> rows = productRepository.findRawCategoryMappingRows(merchantId, plat, query, lim);
return rows.stream().map(r -> new RawCategoryMappingRowDto(
(Integer) r[0], // merchantId
(String) r[1], // merchantName
(String) r[2], // platform
(String) r[3], // rawCategoryKey
((Number) r[4]).longValue(), // productCount
(r[5] == null ? null : ((Number) r[5]).longValue()), // mcmId
(Boolean) r[6], // enabled
(String) r[7], // canonicalPartRole
// IMPORTANT: canonicalCategoryId should be Integer, not Long.
(r[8] == null ? null : ((Number) r[8]).intValue()), // canonicalCategoryId (Integer)
(String) r[9] // canonicalCategoryName
)).toList();
}
// ==========================================================
// 4) NEW: Upsert catalog mapping
// ==========================================================
public record UpsertCatalogMappingResult(Integer merchantCategoryMapId, int updatedProducts) {}
@Transactional
public UpsertCatalogMappingResult upsertCatalogMapping(
Integer merchantId,
String platform,
String rawCategory,
Boolean enabled,
Integer canonicalCategoryId // <-- Integer (NOT Long)
) {
if (merchantId == null) throw new IllegalArgumentException("merchantId is required");
if (rawCategory == null || rawCategory.isBlank()) throw new IllegalArgumentException("rawCategory is required");
String plat = normalizePlatform(platform);
String raw = rawCategory.trim();
boolean en = (enabled == null) ? true : enabled;
Merchant merchant = merchantRepository.findById(merchantId)
.orElseThrow(() -> new IllegalArgumentException("Merchant not found: " + merchantId));
CanonicalCategory cat = null;
if (canonicalCategoryId != null) {
cat = canonicalCategoryRepository.findById(canonicalCategoryId)
.orElseThrow(() -> new IllegalArgumentException(
"CanonicalCategory not found: " + canonicalCategoryId
));
}
// Find mapping row (platform-specific first; then ANY/null via your findBest ordering)
Optional<MerchantCategoryMap> existing = merchantCategoryMapRepository.findBest(merchantId, raw, plat);
MerchantCategoryMap mcm = existing.orElseGet(MerchantCategoryMap::new);
// Always ensure required fields are set
mcm.setMerchant(merchant);
mcm.setRawCategory(raw);
mcm.setPlatform(plat);
mcm.setEnabled(en);
// Catalog mapping fields (FK + legacy mirror)
mcm.setCanonicalCategory(cat); // FK (preferred)
mcm.setCanonicalCategoryText(cat == null ? null : cat.getName()); // legacy mirror
// IMPORTANT: DO NOT clobber canonicalPartRole here
merchantCategoryMapRepository.save(mcm);
// Push category FK to products
int updated = reclassificationService.applyCatalogCategoryMappingToProducts(
merchantId,
raw,
canonicalCategoryId // can be null to clear
);
return new UpsertCatalogMappingResult(mcm.getId(), updated);
}
// -----------------
// Helpers
// -----------------
private String normalizePlatform(String p) {
if (p == null) return null;
String t = p.trim();
if (t.isEmpty()) return null;
return t.toUpperCase(Locale.ROOT);
}
package group.goforward.battlbuilder.service;
import group.goforward.battlbuilder.model.ImportStatus;
import group.goforward.battlbuilder.model.Merchant;
import group.goforward.battlbuilder.model.MerchantCategoryMap;
import group.goforward.battlbuilder.model.CanonicalCategory;
import group.goforward.battlbuilder.repo.CanonicalCategoryRepository;
import group.goforward.battlbuilder.repo.MerchantCategoryMapRepository;
import group.goforward.battlbuilder.repo.MerchantRepository;
import group.goforward.battlbuilder.repo.ProductRepository;
import group.goforward.battlbuilder.web.dto.MappingOptionsDto;
import group.goforward.battlbuilder.web.dto.PendingMappingBucketDto;
import group.goforward.battlbuilder.web.dto.RawCategoryMappingRowDto;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Locale;
import java.util.Optional;
@Service
public class MappingAdminService {
private final ProductRepository productRepository;
private final MerchantCategoryMapRepository merchantCategoryMapRepository;
private final MerchantRepository merchantRepository;
private final CanonicalCategoryRepository canonicalCategoryRepository;
private final ReclassificationService reclassificationService;
public MappingAdminService(
ProductRepository productRepository,
MerchantCategoryMapRepository merchantCategoryMapRepository,
MerchantRepository merchantRepository,
CanonicalCategoryRepository canonicalCategoryRepository,
ReclassificationService reclassificationService
) {
this.productRepository = productRepository;
this.merchantCategoryMapRepository = merchantCategoryMapRepository;
this.merchantRepository = merchantRepository;
this.canonicalCategoryRepository = canonicalCategoryRepository;
this.reclassificationService = reclassificationService;
}
// =========================
// 1) EXISTING: Role buckets
// =========================
@Transactional(readOnly = true)
public List<PendingMappingBucketDto> listPendingBuckets() {
List<Object[]> rows =
productRepository.findPendingMappingBuckets(ImportStatus.PENDING_MAPPING);
return rows.stream()
.map(row -> {
Integer merchantId = (Integer) row[0];
String merchantName = (String) row[1];
String rawCategoryKey = (String) row[2];
Long count = (Long) row[3];
return new PendingMappingBucketDto(
merchantId,
merchantName,
rawCategoryKey,
count != null ? count : 0L
);
})
.toList();
}
/**
* Part Role mapping:
* Writes merchant_category_map.canonical_part_role and applies to products.
*/
@Transactional
public int applyMapping(Integer merchantId, String rawCategoryKey, String mappedPartRole) {
if (merchantId == null || rawCategoryKey == null || rawCategoryKey.isBlank()
|| mappedPartRole == null || mappedPartRole.isBlank()) {
throw new IllegalArgumentException("merchantId, rawCategoryKey, and mappedPartRole are required");
}
Merchant merchant = merchantRepository.findById(merchantId)
.orElseThrow(() -> new IllegalArgumentException("Merchant not found: " + merchantId));
// NOTE: this creates a new row every time. If you want "upsert", use findBest() like we do below.
MerchantCategoryMap mapping = new MerchantCategoryMap();
mapping.setMerchant(merchant);
mapping.setRawCategory(rawCategoryKey.trim());
mapping.setEnabled(true);
// SOURCE OF TRUTH (builder slot mapping)
mapping.setCanonicalPartRole(mappedPartRole.trim());
merchantCategoryMapRepository.save(mapping);
return reclassificationService.applyMappingToProducts(merchantId, rawCategoryKey);
}
@Transactional
public int applyMappingToProducts(Integer merchantId, String rawCategoryKey) {
if (merchantId == null) throw new IllegalArgumentException("merchantId is required");
if (rawCategoryKey == null || rawCategoryKey.isBlank())
throw new IllegalArgumentException("rawCategoryKey is required");
return reclassificationService.applyMappingToProducts(merchantId, rawCategoryKey);
}
// ==========================================
// 2) NEW: Options endpoint for Catalog UI
// ==========================================
@Transactional(readOnly = true)
public MappingOptionsDto getOptions() {
var merchants = merchantRepository.findAll().stream()
.map(m -> new MappingOptionsDto.MerchantOptionDto(m.getId(), m.getName()))
.toList();
var categories = canonicalCategoryRepository.findAllActive().stream()
.map(c -> new MappingOptionsDto.CanonicalCategoryOptionDto(
c.getId(),
c.getName(),
c.getSlug()
))
.toList();
return new MappingOptionsDto(merchants, categories);
}
// =====================================================
// 3) NEW: Raw categories list for Catalog mapping table
// =====================================================
@Transactional(readOnly = true)
public List<RawCategoryMappingRowDto> listRawCategories(Integer merchantId, String platform, String q, Integer limit) {
if (merchantId == null) throw new IllegalArgumentException("merchantId is required");
String plat = normalizePlatform(platform);
String query = (q == null || q.isBlank()) ? null : q.trim();
int lim = (limit == null || limit <= 0) ? 500 : Math.min(limit, 2000);
List<Object[]> rows = productRepository.findRawCategoryMappingRows(merchantId, plat, query, lim);
return rows.stream().map(r -> new RawCategoryMappingRowDto(
(Integer) r[0], // merchantId
(String) r[1], // merchantName
(String) r[2], // platform
(String) r[3], // rawCategoryKey
((Number) r[4]).longValue(), // productCount
(r[5] == null ? null : ((Number) r[5]).longValue()), // mcmId
(Boolean) r[6], // enabled
(String) r[7], // canonicalPartRole
// IMPORTANT: canonicalCategoryId should be Integer, not Long.
(r[8] == null ? null : ((Number) r[8]).intValue()), // canonicalCategoryId (Integer)
(String) r[9] // canonicalCategoryName
)).toList();
}
// ==========================================================
// 4) NEW: Upsert catalog mapping
// ==========================================================
public record UpsertCatalogMappingResult(Integer merchantCategoryMapId, int updatedProducts) {}
@Transactional
public UpsertCatalogMappingResult upsertCatalogMapping(
Integer merchantId,
String platform,
String rawCategory,
Boolean enabled,
Integer canonicalCategoryId // <-- Integer (NOT Long)
) {
if (merchantId == null) throw new IllegalArgumentException("merchantId is required");
if (rawCategory == null || rawCategory.isBlank()) throw new IllegalArgumentException("rawCategory is required");
String plat = normalizePlatform(platform);
String raw = rawCategory.trim();
boolean en = (enabled == null) ? true : enabled;
Merchant merchant = merchantRepository.findById(merchantId)
.orElseThrow(() -> new IllegalArgumentException("Merchant not found: " + merchantId));
CanonicalCategory cat = null;
if (canonicalCategoryId != null) {
cat = canonicalCategoryRepository.findById(canonicalCategoryId)
.orElseThrow(() -> new IllegalArgumentException(
"CanonicalCategory not found: " + canonicalCategoryId
));
}
// Find mapping row (platform-specific first; then ANY/null via your findBest ordering)
Optional<MerchantCategoryMap> existing = merchantCategoryMapRepository.findBest(merchantId, raw, plat);
MerchantCategoryMap mcm = existing.orElseGet(MerchantCategoryMap::new);
// Always ensure required fields are set
mcm.setMerchant(merchant);
mcm.setRawCategory(raw);
mcm.setPlatform(plat);
mcm.setEnabled(en);
// Catalog mapping fields (FK + legacy mirror)
mcm.setCanonicalCategory(cat); // FK (preferred)
mcm.setCanonicalCategoryText(cat == null ? null : cat.getName()); // legacy mirror
// IMPORTANT: DO NOT clobber canonicalPartRole here
merchantCategoryMapRepository.save(mcm);
// Push category FK to products
int updated = reclassificationService.applyCatalogCategoryMappingToProducts(
merchantId,
raw,
canonicalCategoryId // can be null to clear
);
return new UpsertCatalogMappingResult(mcm.getId(), updated);
}
// -----------------
// Helpers
// -----------------
private String normalizePlatform(String p) {
if (p == null) return null;
String t = p.trim();
if (t.isEmpty()) return null;
return t.toUpperCase(Locale.ROOT);
}
}

View File

@@ -1,14 +1,14 @@
package group.goforward.battlbuilder.service;
public interface MerchantFeedImportService {
/**
* Full product + offer import for a given merchant.
*/
void importMerchantFeed(Integer merchantId);
/**
* Offers-only sync (price / stock) for a given merchant.
*/
void syncOffersOnly(Integer merchantId);
package group.goforward.battlbuilder.service;
public interface MerchantFeedImportService {
/**
* Full product + offer import for a given merchant.
*/
void importMerchantFeed(Integer merchantId);
/**
* Offers-only sync (price / stock) for a given merchant.
*/
void syncOffersOnly(Integer merchantId);
}

View File

@@ -1,35 +1,35 @@
package group.goforward.battlbuilder.service;
import group.goforward.battlbuilder.model.PartCategory;
import group.goforward.battlbuilder.model.PartRoleMapping;
import group.goforward.battlbuilder.repo.PartRoleMappingRepository;
import org.springframework.stereotype.Service;
import java.util.Optional;
@Service
public class PartCategoryResolverService {
private final PartRoleMappingRepository partRoleMappingRepository;
public PartCategoryResolverService(PartRoleMappingRepository partRoleMappingRepository) {
this.partRoleMappingRepository = partRoleMappingRepository;
}
/**
* Resolve a PartCategory for a given platform + partRole.
* Example: ("AR-15", "LOWER_RECEIVER_STRIPPED") -> category "lower"
*/
public Optional<PartCategory> resolveForPlatformAndPartRole(String platform, String partRole) {
if (platform == null || partRole == null) return Optional.empty();
String p = platform.trim();
String r = partRole.trim();
if (p.isEmpty() || r.isEmpty()) return Optional.empty();
return partRoleMappingRepository
.findFirstByPlatformIgnoreCaseAndPartRoleIgnoreCaseAndDeletedAtIsNull(p, r)
.map(PartRoleMapping::getPartCategory);
}
package group.goforward.battlbuilder.service;
import group.goforward.battlbuilder.model.PartCategory;
import group.goforward.battlbuilder.model.PartRoleMapping;
import group.goforward.battlbuilder.repo.PartRoleMappingRepository;
import org.springframework.stereotype.Service;
import java.util.Optional;
@Service
public class PartCategoryResolverService {
private final PartRoleMappingRepository partRoleMappingRepository;
public PartCategoryResolverService(PartRoleMappingRepository partRoleMappingRepository) {
this.partRoleMappingRepository = partRoleMappingRepository;
}
/**
* Resolve a PartCategory for a given platform + partRole.
* Example: ("AR-15", "LOWER_RECEIVER_STRIPPED") -> category "lower"
*/
public Optional<PartCategory> resolveForPlatformAndPartRole(String platform, String partRole) {
if (platform == null || partRole == null) return Optional.empty();
String p = platform.trim();
String r = partRole.trim();
if (p.isEmpty() || r.isEmpty()) return Optional.empty();
return partRoleMappingRepository
.findFirstByPlatformIgnoreCaseAndPartRoleIgnoreCaseAndDeletedAtIsNull(p, r)
.map(PartRoleMapping::getPartCategory);
}
}

View File

@@ -1,35 +1,35 @@
package group.goforward.battlbuilder.service;
import group.goforward.battlbuilder.repo.PartRoleMappingRepository;
import group.goforward.battlbuilder.web.dto.admin.PartRoleMappingDto;
import group.goforward.battlbuilder.web.dto.PartRoleToCategoryDto;
import group.goforward.battlbuilder.web.mapper.PartRoleMappingMapper;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class PartRoleMappingService {
private final PartRoleMappingRepository repository;
public PartRoleMappingService(PartRoleMappingRepository repository) {
this.repository = repository;
}
public List<PartRoleMappingDto> getMappingsForPlatform(String platform) {
return repository
.findByPlatformAndDeletedAtIsNullOrderByPartRoleAsc(platform)
.stream()
.map(PartRoleMappingMapper::toDto)
.toList();
}
public List<PartRoleToCategoryDto> getRoleToCategoryMap(String platform) {
return repository
.findByPlatformAndDeletedAtIsNullOrderByPartRoleAsc(platform)
.stream()
.map(PartRoleMappingMapper::toRoleMapDto)
.toList();
}
package group.goforward.battlbuilder.service;
import group.goforward.battlbuilder.repo.PartRoleMappingRepository;
import group.goforward.battlbuilder.web.dto.admin.PartRoleMappingDto;
import group.goforward.battlbuilder.web.dto.PartRoleToCategoryDto;
import group.goforward.battlbuilder.web.mapper.PartRoleMappingMapper;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class PartRoleMappingService {
private final PartRoleMappingRepository repository;
public PartRoleMappingService(PartRoleMappingRepository repository) {
this.repository = repository;
}
public List<PartRoleMappingDto> getMappingsForPlatform(String platform) {
return repository
.findByPlatformAndDeletedAtIsNullOrderByPartRoleAsc(platform)
.stream()
.map(PartRoleMappingMapper::toDto)
.toList();
}
public List<PartRoleToCategoryDto> getRoleToCategoryMap(String platform) {
return repository
.findByPlatformAndDeletedAtIsNullOrderByPartRoleAsc(platform)
.stream()
.map(PartRoleMappingMapper::toRoleMapDto)
.toList();
}
}

View File

@@ -1,20 +1,20 @@
package group.goforward.battlbuilder.service;
import group.goforward.battlbuilder.web.dto.ProductOfferDto;
import group.goforward.battlbuilder.web.dto.ProductSummaryDto;
import java.util.List;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
public interface ProductQueryService {
List<ProductSummaryDto> getProducts(String platform, List<String> partRoles);
List<ProductOfferDto> getOffersForProduct(Integer productId);
ProductSummaryDto getProductById(Integer productId);
Page<ProductSummaryDto> getProductsPage(String platform, List<String> partRoles, Pageable pageable);
package group.goforward.battlbuilder.service;
import group.goforward.battlbuilder.web.dto.ProductOfferDto;
import group.goforward.battlbuilder.web.dto.ProductSummaryDto;
import java.util.List;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
public interface ProductQueryService {
List<ProductSummaryDto> getProducts(String platform, List<String> partRoles);
List<ProductOfferDto> getOffersForProduct(Integer productId);
ProductSummaryDto getProductById(Integer productId);
Page<ProductSummaryDto> getProductsPage(String platform, List<String> partRoles, Pageable pageable);
}

View File

@@ -1,11 +1,11 @@
package group.goforward.battlbuilder.service;
public interface ReclassificationService {
int reclassifyPendingForMerchant(Integer merchantId);
// Existing: apply canonical_part_role mapping to products
int applyMappingToProducts(Integer merchantId, String rawCategoryKey);
// NEW: apply canonical_category_id mapping to products
int applyCatalogCategoryMappingToProducts(Integer merchantId, String rawCategoryKey, Integer canonicalCategoryId);
package group.goforward.battlbuilder.service;
public interface ReclassificationService {
int reclassifyPendingForMerchant(Integer merchantId);
// Existing: apply canonical_part_role mapping to products
int applyMappingToProducts(Integer merchantId, String rawCategoryKey);
// NEW: apply canonical_category_id mapping to products
int applyCatalogCategoryMappingToProducts(Integer merchantId, String rawCategoryKey, Integer canonicalCategoryId);
}

View File

@@ -1,18 +1,18 @@
package group.goforward.battlbuilder.service.admin;
import group.goforward.battlbuilder.web.dto.admin.AdminProductSearchRequest;
import group.goforward.battlbuilder.web.dto.admin.ProductAdminRowDto;
import group.goforward.battlbuilder.web.dto.admin.ProductBulkUpdateRequest;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
public interface AdminProductService {
Page<ProductAdminRowDto> search(
AdminProductSearchRequest request,
Pageable pageable
);
int bulkUpdate(ProductBulkUpdateRequest request);
package group.goforward.battlbuilder.service.admin;
import group.goforward.battlbuilder.web.dto.admin.AdminProductSearchRequest;
import group.goforward.battlbuilder.web.dto.admin.ProductAdminRowDto;
import group.goforward.battlbuilder.web.dto.admin.ProductBulkUpdateRequest;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
public interface AdminProductService {
Page<ProductAdminRowDto> search(
AdminProductSearchRequest request,
Pageable pageable
);
int bulkUpdate(ProductBulkUpdateRequest request);
}

View File

@@ -1,55 +1,55 @@
package group.goforward.battlbuilder.service.admin;
import group.goforward.battlbuilder.model.User;
import group.goforward.battlbuilder.repo.UserRepository;
import group.goforward.battlbuilder.web.dto.admin.AdminUserDto;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Set;
import java.util.UUID;
@Service
public class AdminUserService {
private static final Set<String> ALLOWED_ROLES = Set.of("USER", "ADMIN");
private final UserRepository userRepository;
public AdminUserService(UserRepository userRepository) {
this.userRepository = userRepository;
}
public List<AdminUserDto> getAllUsersForAdmin() {
return userRepository.findAll()
.stream()
.map(AdminUserDto::fromUser)
.toList();
}
@Transactional
public AdminUserDto updateUserRole(UUID userUuid, String newRole, Authentication auth) {
if (newRole == null || !ALLOWED_ROLES.contains(newRole.toUpperCase())) {
throw new IllegalArgumentException("Invalid role: " + newRole);
}
User user = userRepository.findByUuid(userUuid)
.orElseThrow(() -> new IllegalArgumentException("User not found"));
// Optional safety: do not allow demoting yourself (you can loosen this later)
String currentEmail = auth != null ? auth.getName() : null;
boolean isSelf = currentEmail != null
&& currentEmail.equalsIgnoreCase(user.getEmail());
if (isSelf && !"ADMIN".equalsIgnoreCase(newRole)) {
throw new IllegalStateException("You cannot change your own role to non-admin.");
}
user.setRole(newRole.toUpperCase());
// updatedAt will be handled by your entity / DB defaults
return AdminUserDto.fromUser(user);
}
package group.goforward.battlbuilder.service.admin;
import group.goforward.battlbuilder.model.User;
import group.goforward.battlbuilder.repo.UserRepository;
import group.goforward.battlbuilder.web.dto.admin.AdminUserDto;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Set;
import java.util.UUID;
@Service
public class AdminUserService {
private static final Set<String> ALLOWED_ROLES = Set.of("USER", "ADMIN");
private final UserRepository userRepository;
public AdminUserService(UserRepository userRepository) {
this.userRepository = userRepository;
}
public List<AdminUserDto> getAllUsersForAdmin() {
return userRepository.findAll()
.stream()
.map(AdminUserDto::fromUser)
.toList();
}
@Transactional
public AdminUserDto updateUserRole(UUID userUuid, String newRole, Authentication auth) {
if (newRole == null || !ALLOWED_ROLES.contains(newRole.toUpperCase())) {
throw new IllegalArgumentException("Invalid role: " + newRole);
}
User user = userRepository.findByUuid(userUuid)
.orElseThrow(() -> new IllegalArgumentException("User not found"));
// Optional safety: do not allow demoting yourself (you can loosen this later)
String currentEmail = auth != null ? auth.getName() : null;
boolean isSelf = currentEmail != null
&& currentEmail.equalsIgnoreCase(user.getEmail());
if (isSelf && !"ADMIN".equalsIgnoreCase(newRole)) {
throw new IllegalStateException("You cannot change your own role to non-admin.");
}
user.setRole(newRole.toUpperCase());
// updatedAt will be handled by your entity / DB defaults
return AdminUserDto.fromUser(user);
}
}

View File

@@ -1,16 +1,16 @@
package group.goforward.battlbuilder.service.admin;
import group.goforward.battlbuilder.model.State;
import java.util.List;
import java.util.Optional;
public interface StatesService {
List<State> findAll();
Optional<State> findById(Integer id);
State save(State item);
void deleteById(Integer id);
}
package group.goforward.battlbuilder.service.admin;
import group.goforward.battlbuilder.model.State;
import java.util.List;
import java.util.Optional;
public interface StatesService {
List<State> findAll();
Optional<State> findById(Integer id);
State save(State item);
void deleteById(Integer id);
}

View File

@@ -1,16 +1,16 @@
package group.goforward.battlbuilder.service.admin;
import group.goforward.battlbuilder.model.User;
import java.util.List;
import java.util.Optional;
public interface UsersService {
List<User> findAll();
Optional<User> findById(Integer id);
User save(User item);
void deleteById(Integer id);
}
package group.goforward.battlbuilder.service.admin;
import group.goforward.battlbuilder.model.User;
import java.util.List;
import java.util.Optional;
public interface UsersService {
List<User> findAll();
Optional<User> findById(Integer id);
User save(User item);
void deleteById(Integer id);
}

View File

@@ -1,11 +1,11 @@
/**
* Admin service package for the BattlBuilder application.
* <p>
* Contains service classes for administrative business logic
* and operations.
*
* @author Forward Group, LLC
* @version 1.0
* @since 2025-12-10
*/
package group.goforward.battlbuilder.service.admin;
/**
* Admin service package for the BattlBuilder application.
* <p>
* Contains service classes for administrative business logic
* and operations.
*
* @author Forward Group, LLC
* @version 1.0
* @since 2025-12-10
*/
package group.goforward.battlbuilder.service.admin;

View File

@@ -1,45 +1,45 @@
package group.goforward.battlbuilder.service.admin.impl;
import group.goforward.battlbuilder.model.ImportStatus;
import group.goforward.battlbuilder.repo.MerchantCategoryMapRepository;
import group.goforward.battlbuilder.repo.MerchantRepository;
import group.goforward.battlbuilder.repo.ProductRepository;
import group.goforward.battlbuilder.web.dto.admin.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 MerchantCategoryMapRepository merchantCategoryMapRepository;
public AdminDashboardService(
ProductRepository productRepository,
MerchantRepository merchantRepository,
MerchantCategoryMapRepository merchantCategoryMapRepository
) {
this.productRepository = productRepository;
this.merchantRepository = merchantRepository;
this.merchantCategoryMapRepository = merchantCategoryMapRepository;
}
@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 = merchantCategoryMapRepository.count();
return new AdminDashboardOverviewDto(
totalProducts,
mappedProducts,
unmappedProducts,
merchantCount,
categoryMappings
);
}
package group.goforward.battlbuilder.service.admin.impl;
import group.goforward.battlbuilder.model.ImportStatus;
import group.goforward.battlbuilder.repo.MerchantCategoryMapRepository;
import group.goforward.battlbuilder.repo.MerchantRepository;
import group.goforward.battlbuilder.repo.ProductRepository;
import group.goforward.battlbuilder.web.dto.admin.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 MerchantCategoryMapRepository merchantCategoryMapRepository;
public AdminDashboardService(
ProductRepository productRepository,
MerchantRepository merchantRepository,
MerchantCategoryMapRepository merchantCategoryMapRepository
) {
this.productRepository = productRepository;
this.merchantRepository = merchantRepository;
this.merchantCategoryMapRepository = merchantCategoryMapRepository;
}
@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 = merchantCategoryMapRepository.count();
return new AdminDashboardOverviewDto(
totalProducts,
mappedProducts,
unmappedProducts,
merchantCount,
categoryMappings
);
}
}

View File

@@ -1,65 +1,65 @@
package group.goforward.battlbuilder.service.admin.impl;
import group.goforward.battlbuilder.model.Product;
import group.goforward.battlbuilder.repo.ProductRepository;
import group.goforward.battlbuilder.service.admin.AdminProductService;
import group.goforward.battlbuilder.specs.ProductSpecifications;
import group.goforward.battlbuilder.web.dto.admin.AdminProductSearchRequest;
import group.goforward.battlbuilder.web.dto.admin.ProductAdminRowDto;
import group.goforward.battlbuilder.web.dto.admin.ProductBulkUpdateRequest;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@Transactional
public class AdminProductServiceImpl implements AdminProductService {
private final ProductRepository productRepository;
public AdminProductServiceImpl(ProductRepository productRepository) {
this.productRepository = productRepository;
}
@Override
public Page<ProductAdminRowDto> search(
AdminProductSearchRequest request,
Pageable pageable
) {
Specification<Product> spec =
ProductSpecifications.adminSearch(request);
return productRepository
.findAll(spec, pageable)
.map(ProductAdminRowDto::fromEntity);
}
@Override
public int bulkUpdate(ProductBulkUpdateRequest request) {
var products = productRepository.findAllById(request.getProductIds());
products.forEach(p -> {
if (request.getVisibility() != null) {
p.setVisibility(request.getVisibility());
}
if (request.getStatus() != null) {
p.setStatus(request.getStatus());
}
if (request.getBuilderEligible() != null) {
p.setBuilderEligible(request.getBuilderEligible());
}
if (request.getAdminLocked() != null) {
p.setAdminLocked(request.getAdminLocked());
}
if (request.getAdminNote() != null) {
p.setAdminNote(request.getAdminNote());
}
});
productRepository.saveAll(products);
return products.size();
}
package group.goforward.battlbuilder.service.admin.impl;
import group.goforward.battlbuilder.model.Product;
import group.goforward.battlbuilder.repo.ProductRepository;
import group.goforward.battlbuilder.service.admin.AdminProductService;
import group.goforward.battlbuilder.specs.ProductSpecifications;
import group.goforward.battlbuilder.web.dto.admin.AdminProductSearchRequest;
import group.goforward.battlbuilder.web.dto.admin.ProductAdminRowDto;
import group.goforward.battlbuilder.web.dto.admin.ProductBulkUpdateRequest;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@Transactional
public class AdminProductServiceImpl implements AdminProductService {
private final ProductRepository productRepository;
public AdminProductServiceImpl(ProductRepository productRepository) {
this.productRepository = productRepository;
}
@Override
public Page<ProductAdminRowDto> search(
AdminProductSearchRequest request,
Pageable pageable
) {
Specification<Product> spec =
ProductSpecifications.adminSearch(request);
return productRepository
.findAll(spec, pageable)
.map(ProductAdminRowDto::fromEntity);
}
@Override
public int bulkUpdate(ProductBulkUpdateRequest request) {
var products = productRepository.findAllById(request.getProductIds());
products.forEach(p -> {
if (request.getVisibility() != null) {
p.setVisibility(request.getVisibility());
}
if (request.getStatus() != null) {
p.setStatus(request.getStatus());
}
if (request.getBuilderEligible() != null) {
p.setBuilderEligible(request.getBuilderEligible());
}
if (request.getAdminLocked() != null) {
p.setAdminLocked(request.getAdminLocked());
}
if (request.getAdminNote() != null) {
p.setAdminNote(request.getAdminNote());
}
});
productRepository.saveAll(products);
return products.size();
}
}

View File

@@ -1,38 +1,38 @@
package group.goforward.battlbuilder.service.admin.impl;
import group.goforward.battlbuilder.model.State;
import group.goforward.battlbuilder.repo.StateRepository;
import group.goforward.battlbuilder.service.admin.StatesService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Optional;
@Service
public class StatesServiceImpl implements StatesService {
@Autowired
private StateRepository repo;
@Override
public List<State> findAll() {
return repo.findAll();
}
@Override
public Optional<State> findById(Integer id) {
return repo.findById(id);
}
@Override
public State save(State item) {
return null;
}
@Override
public void deleteById(Integer id) {
deleteById(id);
}
}
package group.goforward.battlbuilder.service.admin.impl;
import group.goforward.battlbuilder.model.State;
import group.goforward.battlbuilder.repo.StateRepository;
import group.goforward.battlbuilder.service.admin.StatesService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Optional;
@Service
public class StatesServiceImpl implements StatesService {
@Autowired
private StateRepository repo;
@Override
public List<State> findAll() {
return repo.findAll();
}
@Override
public Optional<State> findById(Integer id) {
return repo.findById(id);
}
@Override
public State save(State item) {
return null;
}
@Override
public void deleteById(Integer id) {
deleteById(id);
}
}

View File

@@ -1,37 +1,37 @@
package group.goforward.battlbuilder.service.admin.impl;
import group.goforward.battlbuilder.model.User;
import group.goforward.battlbuilder.repo.UserRepository;
import group.goforward.battlbuilder.service.admin.UsersService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Optional;
@Service
public class UsersServiceImpl implements UsersService {
@Autowired
private UserRepository repo;
@Override
public List<User> findAll() {
return repo.findAll();
}
@Override
public Optional<User> findById(Integer id) {
return repo.findById(id);
}
@Override
public User save(User item) {
return null;
}
@Override
public void deleteById(Integer id) {
deleteById(id);
}
}
package group.goforward.battlbuilder.service.admin.impl;
import group.goforward.battlbuilder.model.User;
import group.goforward.battlbuilder.repo.UserRepository;
import group.goforward.battlbuilder.service.admin.UsersService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Optional;
@Service
public class UsersServiceImpl implements UsersService {
@Autowired
private UserRepository repo;
@Override
public List<User> findAll() {
return repo.findAll();
}
@Override
public Optional<User> findById(Integer id) {
return repo.findById(id);
}
@Override
public User save(User item) {
return null;
}
@Override
public void deleteById(Integer id) {
deleteById(id);
}
}

View File

@@ -1,13 +1,13 @@
/**
* Provides the classes necessary for the Spring Services implementations for the Battl.Builder application.
* This package includes Services implementations for Spring-Boot application
*
*
* <p>The main entry point for managing the inventory is the
* {@link group.goforward.battlbuilder.BattlBuilderApplication} class.</p>
*
* @since 1.0
* @author Don Strawsburg
* @version 1.1
*/
/**
* Provides the classes necessary for the Spring Services implementations for the Battl.Builder application.
* This package includes Services implementations for Spring-Boot application
*
*
* <p>The main entry point for managing the inventory is the
* {@link group.goforward.battlbuilder.BattlBuilderApplication} class.</p>
*
* @since 1.0
* @author Don Strawsburg
* @version 1.1
*/
package group.goforward.battlbuilder.service.admin;

View File

@@ -1,29 +1,29 @@
package group.goforward.battlbuilder.service.auth;
import group.goforward.battlbuilder.web.dto.auth.AuthResponse;
public interface BetaAuthService {
/**
* Upsert a beta signup lead and send a confirmation email with a verify token/link.
* Should NOT throw to the caller for common cases (e.g. already exists).
*/
void signup(String email, String useCase);
/**
* Exchanges a "confirm" token for a real JWT session.
* This confirms the email (one-time) AND logs the user in immediately.
*/
AuthResponse confirmAndExchange(String token);
/**
* Exchanges a "magic link" token for a real JWT session.
* Used for returning users ("email me a sign-in link").
*/
AuthResponse exchangeMagicToken(String token);
void sendPasswordReset(String email);
void resetPassword(String token, String newPassword);
void sendMagicLoginLink(String email);
package group.goforward.battlbuilder.service.auth;
import group.goforward.battlbuilder.web.dto.auth.AuthResponse;
public interface BetaAuthService {
/**
* Upsert a beta signup lead and send a confirmation email with a verify token/link.
* Should NOT throw to the caller for common cases (e.g. already exists).
*/
void signup(String email, String useCase);
/**
* Exchanges a "confirm" token for a real JWT session.
* This confirms the email (one-time) AND logs the user in immediately.
*/
AuthResponse confirmAndExchange(String token);
/**
* Exchanges a "magic link" token for a real JWT session.
* Used for returning users ("email me a sign-in link").
*/
AuthResponse exchangeMagicToken(String token);
void sendPasswordReset(String email);
void resetPassword(String token, String newPassword);
void sendMagicLoginLink(String email);
}

View File

@@ -1,321 +1,321 @@
package group.goforward.battlbuilder.service.auth.impl;
import group.goforward.battlbuilder.model.AuthToken;
import group.goforward.battlbuilder.model.User;
import group.goforward.battlbuilder.repo.AuthTokenRepository;
import group.goforward.battlbuilder.repo.UserRepository;
import group.goforward.battlbuilder.security.JwtService;
import group.goforward.battlbuilder.service.auth.BetaAuthService;
import group.goforward.battlbuilder.service.utils.EmailService;
import group.goforward.battlbuilder.web.dto.auth.AuthResponse;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.time.OffsetDateTime;
import java.util.HexFormat;
import java.util.UUID;
@Service
public class BetaAuthServiceImpl implements BetaAuthService {
private final AuthTokenRepository tokens;
private final UserRepository users;
private final JwtService jwtService;
private final EmailService emailService;
private final PasswordEncoder passwordEncoder;
@Value("${app.publicBaseUrl:http://localhost:3000}")
private String publicBaseUrl;
@Value("${app.authTokenPepper:change-me}")
private String tokenPepper;
/**
* When true:
* - Signup captures users (role=BETA, inactive)
* - NO tokens are generated
* - NO emails are sent
*/
@Value("${app.beta.captureOnly:true}")
private boolean betaCaptureOnly;
private final SecureRandom secureRandom = new SecureRandom();
public BetaAuthServiceImpl(
AuthTokenRepository tokens,
UserRepository users,
JwtService jwtService,
EmailService emailService,
PasswordEncoder passwordEncoder
) {
this.tokens = tokens;
this.users = users;
this.jwtService = jwtService;
this.emailService = emailService;
this.passwordEncoder = passwordEncoder;
}
/**
* A: Beta signup (capture lead + optionally email confirm+login token).
* The Next page will call /api/auth/beta/confirm and receive AuthResponse.
*/
@Override
public void signup(String rawEmail, String useCase) {
String email = normalizeEmail(rawEmail);
// ✅ Create or update a "beta lead" user record
User user = users.findByEmailIgnoreCaseAndDeletedAtIsNull(email).orElse(null);
if (user == null) {
user = new User();
user.setUuid(UUID.randomUUID());
user.setEmail(email);
// Treat beta signups as users, but not active / not verified yet
user.setRole("BETA");
user.setActive(false);
user.setDisplayName(null);
user.setCreatedAt(OffsetDateTime.now());
}
// Optional: stash useCase somewhere if desired
// user.setPreferences(mergeUseCase(user.getPreferences(), useCase));
user.setUpdatedAt(OffsetDateTime.now());
users.save(user);
// 🚫 Capture-only mode: do not create tokens, do not send email
if (betaCaptureOnly) return;
// --- Invite mode (later) ---
// 24h confirm token
String verifyToken = generateToken();
saveToken(email, AuthToken.TokenType.BETA_VERIFY, verifyToken, OffsetDateTime.now().plusHours(24));
String confirmUrl = publicBaseUrl + "/beta/confirm?token=" + verifyToken;
String subject = "Your Battl Builders sign-in link";
String body = """
You're on the list.
Sign in (and confirm your email) here:
%s
If you didnt request this, you can ignore this email.
""".formatted(confirmUrl);
emailService.sendEmail(email, subject, body);
}
/**
* B: Existing users only — request a magic login link (no signup/confirm).
* Caller must always return OK to avoid email enumeration.
*/
@Override
public void sendMagicLoginLink(String rawEmail) {
String email = normalizeEmail(rawEmail);
// Only send if user exists (but do NOT reveal that)
User user = users.findByEmailIgnoreCaseAndDeletedAtIsNull(email).orElse(null);
if (user == null) return;
boolean isBeta = "BETA".equalsIgnoreCase(user.getRole());
// If capture-only mode is enabled, do not generate tokens or send email
if (betaCaptureOnly) return;
// Allow magic link requests for:
// - active USERs, OR
// - BETA users (even if inactive), since they may not be activated yet
if (!Boolean.TRUE.equals(user.isActive()) && !isBeta) return;
// Optional: restrict magic links to USER/BETA only (keeps admins/mods out if desired)
if (!("USER".equalsIgnoreCase(user.getRole()) || isBeta)) return;
// 30 minute magic token
String magicToken = generateToken();
saveToken(email, AuthToken.TokenType.MAGIC_LOGIN, magicToken, OffsetDateTime.now().plusMinutes(30));
String magicUrl = publicBaseUrl + "/beta/magic?token=" + magicToken;
String subject = "Your Battl Builders sign-in link";
String body = """
Heres your secure sign-in link (expires in 30 minutes):
%s
If you didnt request this, you can ignore this email.
""".formatted(magicUrl);
emailService.sendEmail(email, subject, body);
}
/**
* Consumes BETA_VERIFY token, activates user, promotes BETA->USER, and returns JWT immediately.
*/
@Override
public AuthResponse confirmAndExchange(String token) {
AuthToken authToken = consumeToken(AuthToken.TokenType.BETA_VERIFY, token);
String email = authToken.getEmail();
User user = users.findByEmailIgnoreCaseAndDeletedAtIsNull(email).orElse(null);
OffsetDateTime now = OffsetDateTime.now();
if (user == null) {
user = new User();
user.setUuid(UUID.randomUUID());
user.setEmail(email);
user.setDisplayName(null);
user.setRole("USER");
user.setActive(true);
user.setCreatedAt(now);
} else {
// Promote BETA -> USER on first successful confirm
if ("BETA".equalsIgnoreCase(user.getRole())) {
user.setRole("USER");
}
user.setActive(true);
}
user.setLastLoginAt(now);
user.incrementLoginCount();
user.setUpdatedAt(now);
users.save(user);
String jwt = jwtService.generateToken(user);
return new AuthResponse(jwt, user.getEmail(), user.getDisplayName(), user.getRole());
}
/**
* Consumes MAGIC_LOGIN token and returns JWT (returning users).
* Also promotes BETA->USER and activates the account on first successful login.
*/
@Override
public AuthResponse exchangeMagicToken(String token) {
AuthToken magic = consumeToken(AuthToken.TokenType.MAGIC_LOGIN, token);
User user = users.findByEmailIgnoreCaseAndDeletedAtIsNull(magic.getEmail())
.orElseThrow(() -> new IllegalStateException("User not found for magic token"));
OffsetDateTime now = OffsetDateTime.now();
// Promote/activate beta users on first successful magic login
if ("BETA".equalsIgnoreCase(user.getRole())) {
user.setRole("USER");
}
if (!Boolean.TRUE.equals(user.isActive())) {
user.setActive(true);
}
user.setLastLoginAt(now);
user.incrementLoginCount();
user.setUpdatedAt(now);
users.save(user);
String jwt = jwtService.generateToken(user);
return new AuthResponse(jwt, user.getEmail(), user.getDisplayName(), user.getRole());
}
// ---------------------------------------------------------------------
// Password Reset
// ---------------------------------------------------------------------
@Override
public void sendPasswordReset(String rawEmail) {
String email = normalizeEmail(rawEmail);
User user = users.findByEmailIgnoreCaseAndDeletedAtIsNull(email).orElse(null);
if (user == null) return;
// If capture-only mode is enabled, do not generate tokens or send email
if (betaCaptureOnly) return;
String resetToken = generateToken();
saveToken(email, AuthToken.TokenType.PASSWORD_RESET, resetToken, OffsetDateTime.now().plusMinutes(30));
String resetUrl = publicBaseUrl + "/reset-password?token=" + resetToken;
String subject = "Reset your Battl Builders password";
String body = """
Reset your password using this link (expires in 30 minutes):
%s
If you didnt request this, you can ignore this email.
""".formatted(resetUrl);
emailService.sendEmail(email, subject, body);
}
@Override
public void resetPassword(String token, String newPassword) {
if (newPassword == null || newPassword.trim().length() < 8) {
throw new IllegalArgumentException("Password must be at least 8 characters");
}
AuthToken t = consumeToken(AuthToken.TokenType.PASSWORD_RESET, token);
String email = t.getEmail();
User user = users.findByEmailIgnoreCaseAndDeletedAtIsNull(email)
.orElseThrow(() -> new IllegalArgumentException("User not found"));
user.setPasswordHash(passwordEncoder.encode(newPassword.trim()));
user.setPasswordSetAt(OffsetDateTime.now());
user.setUpdatedAt(OffsetDateTime.now());
users.save(user);
}
// ---------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------
private void saveToken(String email, AuthToken.TokenType type, String token, OffsetDateTime expiresAt) {
AuthToken t = new AuthToken();
t.setEmail(email);
t.setType(type);
t.setTokenHash(hashToken(token));
t.setExpiresAt(expiresAt);
t.setCreatedAt(OffsetDateTime.now());
tokens.save(t);
}
private AuthToken consumeToken(AuthToken.TokenType type, String token) {
String hash = hashToken(token);
AuthToken t = tokens.findFirstByTypeAndTokenHash(type, hash)
.orElseThrow(() -> new IllegalArgumentException("Invalid token"));
OffsetDateTime now = OffsetDateTime.now();
if (t.isConsumed()) throw new IllegalArgumentException("Token already used");
if (t.isExpired(now)) throw new IllegalArgumentException("Token expired");
t.setConsumedAt(now);
tokens.save(t);
return t;
}
private String normalizeEmail(String email) {
if (email == null) throw new IllegalArgumentException("Email required");
return email.trim().toLowerCase();
}
private String generateToken() {
byte[] bytes = new byte[32];
secureRandom.nextBytes(bytes);
return HexFormat.of().formatHex(bytes);
}
private String hashToken(String token) {
try {
MessageDigest md = MessageDigest.getInstance("SHA-256");
byte[] hashed = md.digest((tokenPepper + ":" + token).getBytes(StandardCharsets.UTF_8));
return HexFormat.of().formatHex(hashed);
} catch (Exception e) {
throw new RuntimeException("Failed to hash token", e);
}
}
package group.goforward.battlbuilder.service.auth.impl;
import group.goforward.battlbuilder.model.AuthToken;
import group.goforward.battlbuilder.model.User;
import group.goforward.battlbuilder.repo.AuthTokenRepository;
import group.goforward.battlbuilder.repo.UserRepository;
import group.goforward.battlbuilder.security.JwtService;
import group.goforward.battlbuilder.service.auth.BetaAuthService;
import group.goforward.battlbuilder.service.utils.EmailService;
import group.goforward.battlbuilder.web.dto.auth.AuthResponse;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.time.OffsetDateTime;
import java.util.HexFormat;
import java.util.UUID;
@Service
public class BetaAuthServiceImpl implements BetaAuthService {
private final AuthTokenRepository tokens;
private final UserRepository users;
private final JwtService jwtService;
private final EmailService emailService;
private final PasswordEncoder passwordEncoder;
@Value("${app.publicBaseUrl:http://localhost:3000}")
private String publicBaseUrl;
@Value("${app.authTokenPepper:change-me}")
private String tokenPepper;
/**
* When true:
* - Signup captures users (role=BETA, inactive)
* - NO tokens are generated
* - NO emails are sent
*/
@Value("${app.beta.captureOnly:true}")
private boolean betaCaptureOnly;
private final SecureRandom secureRandom = new SecureRandom();
public BetaAuthServiceImpl(
AuthTokenRepository tokens,
UserRepository users,
JwtService jwtService,
EmailService emailService,
PasswordEncoder passwordEncoder
) {
this.tokens = tokens;
this.users = users;
this.jwtService = jwtService;
this.emailService = emailService;
this.passwordEncoder = passwordEncoder;
}
/**
* A: Beta signup (capture lead + optionally email confirm+login token).
* The Next page will call /api/auth/beta/confirm and receive AuthResponse.
*/
@Override
public void signup(String rawEmail, String useCase) {
String email = normalizeEmail(rawEmail);
// ✅ Create or update a "beta lead" user record
User user = users.findByEmailIgnoreCaseAndDeletedAtIsNull(email).orElse(null);
if (user == null) {
user = new User();
user.setUuid(UUID.randomUUID());
user.setEmail(email);
// Treat beta signups as users, but not active / not verified yet
user.setRole("BETA");
user.setActive(false);
user.setDisplayName(null);
user.setCreatedAt(OffsetDateTime.now());
}
// Optional: stash useCase somewhere if desired
// user.setPreferences(mergeUseCase(user.getPreferences(), useCase));
user.setUpdatedAt(OffsetDateTime.now());
users.save(user);
// 🚫 Capture-only mode: do not create tokens, do not send email
if (betaCaptureOnly) return;
// --- Invite mode (later) ---
// 24h confirm token
String verifyToken = generateToken();
saveToken(email, AuthToken.TokenType.BETA_VERIFY, verifyToken, OffsetDateTime.now().plusHours(24));
String confirmUrl = publicBaseUrl + "/beta/confirm?token=" + verifyToken;
String subject = "Your Battl Builders sign-in link";
String body = """
You're on the list.
Sign in (and confirm your email) here:
%s
If you didnt request this, you can ignore this email.
""".formatted(confirmUrl);
emailService.sendEmail(email, subject, body);
}
/**
* B: Existing users only — request a magic login link (no signup/confirm).
* Caller must always return OK to avoid email enumeration.
*/
@Override
public void sendMagicLoginLink(String rawEmail) {
String email = normalizeEmail(rawEmail);
// Only send if user exists (but do NOT reveal that)
User user = users.findByEmailIgnoreCaseAndDeletedAtIsNull(email).orElse(null);
if (user == null) return;
boolean isBeta = "BETA".equalsIgnoreCase(user.getRole());
// If capture-only mode is enabled, do not generate tokens or send email
if (betaCaptureOnly) return;
// Allow magic link requests for:
// - active USERs, OR
// - BETA users (even if inactive), since they may not be activated yet
if (!Boolean.TRUE.equals(user.isActive()) && !isBeta) return;
// Optional: restrict magic links to USER/BETA only (keeps admins/mods out if desired)
if (!("USER".equalsIgnoreCase(user.getRole()) || isBeta)) return;
// 30 minute magic token
String magicToken = generateToken();
saveToken(email, AuthToken.TokenType.MAGIC_LOGIN, magicToken, OffsetDateTime.now().plusMinutes(30));
String magicUrl = publicBaseUrl + "/beta/magic?token=" + magicToken;
String subject = "Your Battl Builders sign-in link";
String body = """
Heres your secure sign-in link (expires in 30 minutes):
%s
If you didnt request this, you can ignore this email.
""".formatted(magicUrl);
emailService.sendEmail(email, subject, body);
}
/**
* Consumes BETA_VERIFY token, activates user, promotes BETA->USER, and returns JWT immediately.
*/
@Override
public AuthResponse confirmAndExchange(String token) {
AuthToken authToken = consumeToken(AuthToken.TokenType.BETA_VERIFY, token);
String email = authToken.getEmail();
User user = users.findByEmailIgnoreCaseAndDeletedAtIsNull(email).orElse(null);
OffsetDateTime now = OffsetDateTime.now();
if (user == null) {
user = new User();
user.setUuid(UUID.randomUUID());
user.setEmail(email);
user.setDisplayName(null);
user.setRole("USER");
user.setActive(true);
user.setCreatedAt(now);
} else {
// Promote BETA -> USER on first successful confirm
if ("BETA".equalsIgnoreCase(user.getRole())) {
user.setRole("USER");
}
user.setActive(true);
}
user.setLastLoginAt(now);
user.incrementLoginCount();
user.setUpdatedAt(now);
users.save(user);
String jwt = jwtService.generateToken(user);
return new AuthResponse(jwt, user.getEmail(), user.getDisplayName(), user.getRole());
}
/**
* Consumes MAGIC_LOGIN token and returns JWT (returning users).
* Also promotes BETA->USER and activates the account on first successful login.
*/
@Override
public AuthResponse exchangeMagicToken(String token) {
AuthToken magic = consumeToken(AuthToken.TokenType.MAGIC_LOGIN, token);
User user = users.findByEmailIgnoreCaseAndDeletedAtIsNull(magic.getEmail())
.orElseThrow(() -> new IllegalStateException("User not found for magic token"));
OffsetDateTime now = OffsetDateTime.now();
// Promote/activate beta users on first successful magic login
if ("BETA".equalsIgnoreCase(user.getRole())) {
user.setRole("USER");
}
if (!Boolean.TRUE.equals(user.isActive())) {
user.setActive(true);
}
user.setLastLoginAt(now);
user.incrementLoginCount();
user.setUpdatedAt(now);
users.save(user);
String jwt = jwtService.generateToken(user);
return new AuthResponse(jwt, user.getEmail(), user.getDisplayName(), user.getRole());
}
// ---------------------------------------------------------------------
// Password Reset
// ---------------------------------------------------------------------
@Override
public void sendPasswordReset(String rawEmail) {
String email = normalizeEmail(rawEmail);
User user = users.findByEmailIgnoreCaseAndDeletedAtIsNull(email).orElse(null);
if (user == null) return;
// If capture-only mode is enabled, do not generate tokens or send email
if (betaCaptureOnly) return;
String resetToken = generateToken();
saveToken(email, AuthToken.TokenType.PASSWORD_RESET, resetToken, OffsetDateTime.now().plusMinutes(30));
String resetUrl = publicBaseUrl + "/reset-password?token=" + resetToken;
String subject = "Reset your Battl Builders password";
String body = """
Reset your password using this link (expires in 30 minutes):
%s
If you didnt request this, you can ignore this email.
""".formatted(resetUrl);
emailService.sendEmail(email, subject, body);
}
@Override
public void resetPassword(String token, String newPassword) {
if (newPassword == null || newPassword.trim().length() < 8) {
throw new IllegalArgumentException("Password must be at least 8 characters");
}
AuthToken t = consumeToken(AuthToken.TokenType.PASSWORD_RESET, token);
String email = t.getEmail();
User user = users.findByEmailIgnoreCaseAndDeletedAtIsNull(email)
.orElseThrow(() -> new IllegalArgumentException("User not found"));
user.setPasswordHash(passwordEncoder.encode(newPassword.trim()));
user.setPasswordSetAt(OffsetDateTime.now());
user.setUpdatedAt(OffsetDateTime.now());
users.save(user);
}
// ---------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------
private void saveToken(String email, AuthToken.TokenType type, String token, OffsetDateTime expiresAt) {
AuthToken t = new AuthToken();
t.setEmail(email);
t.setType(type);
t.setTokenHash(hashToken(token));
t.setExpiresAt(expiresAt);
t.setCreatedAt(OffsetDateTime.now());
tokens.save(t);
}
private AuthToken consumeToken(AuthToken.TokenType type, String token) {
String hash = hashToken(token);
AuthToken t = tokens.findFirstByTypeAndTokenHash(type, hash)
.orElseThrow(() -> new IllegalArgumentException("Invalid token"));
OffsetDateTime now = OffsetDateTime.now();
if (t.isConsumed()) throw new IllegalArgumentException("Token already used");
if (t.isExpired(now)) throw new IllegalArgumentException("Token expired");
t.setConsumedAt(now);
tokens.save(t);
return t;
}
private String normalizeEmail(String email) {
if (email == null) throw new IllegalArgumentException("Email required");
return email.trim().toLowerCase();
}
private String generateToken() {
byte[] bytes = new byte[32];
secureRandom.nextBytes(bytes);
return HexFormat.of().formatHex(bytes);
}
private String hashToken(String token) {
try {
MessageDigest md = MessageDigest.getInstance("SHA-256");
byte[] hashed = md.digest((tokenPepper + ":" + token).getBytes(StandardCharsets.UTF_8));
return HexFormat.of().formatHex(hashed);
} catch (Exception e) {
throw new RuntimeException("Failed to hash token", e);
}
}
}

View File

@@ -1,185 +1,185 @@
package group.goforward.battlbuilder.service.auth.impl;
import group.goforward.battlbuilder.model.AuthToken;
import group.goforward.battlbuilder.model.User;
import group.goforward.battlbuilder.repo.AuthTokenRepository;
import group.goforward.battlbuilder.repo.UserRepository;
import group.goforward.battlbuilder.service.utils.TemplatedEmailService;
import group.goforward.battlbuilder.web.dto.admin.AdminBetaRequestDto;
import group.goforward.battlbuilder.web.dto.admin.AdminInviteResponse;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Service;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.time.OffsetDateTime;
import java.util.HexFormat;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
@Service
public class BetaInviteService {
private final UserRepository users;
private final AuthTokenRepository tokens;
private final TemplatedEmailService templatedEmailService;
@Value("${app.publicBaseUrl:http://localhost:3000}")
private String publicBaseUrl;
@Value("${app.authTokenPepper:change-me}")
private String tokenPepper;
private final SecureRandom secureRandom = new SecureRandom();
public BetaInviteService(
UserRepository users,
AuthTokenRepository tokens,
TemplatedEmailService templatedEmailService
) {
this.users = users;
this.tokens = tokens;
this.templatedEmailService = templatedEmailService;
}
/**
* Batch invite for all pending BETA users (role=BETA, is_active=false, deleted_at is null).
*/
public int inviteAllBetaUsers(int tokenMinutes, int limit, boolean dryRun) {
List<User> betaUsers = (limit > 0)
? users.findTopNByRoleAndIsActiveFalseAndDeletedAtIsNull("BETA", limit)
: users.findByRoleAndIsActiveFalseAndDeletedAtIsNull("BETA");
int sent = 0;
for (User user : betaUsers) {
inviteUser(user, tokenMinutes, dryRun);
sent++;
}
return sent;
}
/**
* Admin UI list: all pending beta requests (role=BETA, is_active=false).
* Controller expects Page<AdminBetaRequestDto>.
*/
public Page<AdminBetaRequestDto> listPendingBetaUsers(int page, int size) {
int safePage = Math.max(0, page);
int safeSize = Math.min(Math.max(1, size), 100);
List<User> pending = users.findByRoleAndIsActiveFalseAndDeletedAtIsNull("BETA");
int from = Math.min(safePage * safeSize, pending.size());
int to = Math.min(from + safeSize, pending.size());
OffsetDateTime now = OffsetDateTime.now();
List<AdminBetaRequestDto> dtos = pending.subList(from, to).stream()
.map(u -> {
AdminBetaRequestDto dto = AdminBetaRequestDto.from(u);
dto.invited = tokens.hasActiveToken(
u.getEmail(),
AuthToken.TokenType.MAGIC_LOGIN,
now
);
return dto;
})
.collect(Collectors.toList());
return new PageImpl<>(dtos, PageRequest.of(safePage, safeSize), pending.size());
}
/**
* Invite a single beta request by userId.
*/
public AdminInviteResponse inviteSingleBetaUser(Integer userId) {
if (userId == null) {
return new AdminInviteResponse(false, null, "userId is required");
}
User user = users.findById(userId).orElse(null);
if (user == null || user.getDeletedAt() != null) {
return new AdminInviteResponse(false, null, "User not found");
}
if (!"BETA".equalsIgnoreCase(user.getRole()) || user.isActive()) {
return new AdminInviteResponse(false, user.getEmail(), "User is not a pending beta request");
}
int tokenMinutes = 30; // default for single-invite; feel free to parametrize later
String magicUrl = inviteUser(user, tokenMinutes, false);
return new AdminInviteResponse(true, user.getEmail(), magicUrl);
}
/**
* Creates token, persists hash, and (optionally) sends email.
* Returns the magicUrl for logging / admin response.
*/
private String inviteUser(User user, int tokenMinutes, boolean dryRun) {
String email = user.getEmail();
String magicToken = generateToken();
saveToken(
email,
AuthToken.TokenType.MAGIC_LOGIN,
magicToken,
OffsetDateTime.now().plusMinutes(tokenMinutes)
);
String magicUrl = publicBaseUrl + "/beta/magic?token=" + magicToken;
if (!dryRun) {
templatedEmailService.send(
"beta_invite",
email,
Map.of(
"minutes", String.valueOf(tokenMinutes),
"magicUrl", magicUrl
)
);
}
return magicUrl;
}
private void saveToken(
String email,
AuthToken.TokenType type,
String token,
OffsetDateTime expiresAt
) {
AuthToken t = new AuthToken();
t.setEmail(email);
t.setType(type);
t.setTokenHash(hashToken(token));
t.setExpiresAt(expiresAt);
t.setCreatedAt(OffsetDateTime.now());
tokens.save(t);
}
private String generateToken() {
byte[] bytes = new byte[32];
secureRandom.nextBytes(bytes);
return HexFormat.of().formatHex(bytes);
}
private String hashToken(String token) {
try {
MessageDigest md = MessageDigest.getInstance("SHA-256");
byte[] hashed = md.digest(
(tokenPepper + ":" + token).getBytes(StandardCharsets.UTF_8)
);
return HexFormat.of().formatHex(hashed);
} catch (Exception e) {
throw new RuntimeException("Failed to hash token", e);
}
}
package group.goforward.battlbuilder.service.auth.impl;
import group.goforward.battlbuilder.model.AuthToken;
import group.goforward.battlbuilder.model.User;
import group.goforward.battlbuilder.repo.AuthTokenRepository;
import group.goforward.battlbuilder.repo.UserRepository;
import group.goforward.battlbuilder.service.utils.TemplatedEmailService;
import group.goforward.battlbuilder.web.dto.admin.AdminBetaRequestDto;
import group.goforward.battlbuilder.web.dto.admin.AdminInviteResponse;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Service;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.time.OffsetDateTime;
import java.util.HexFormat;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
@Service
public class BetaInviteService {
private final UserRepository users;
private final AuthTokenRepository tokens;
private final TemplatedEmailService templatedEmailService;
@Value("${app.publicBaseUrl:http://localhost:3000}")
private String publicBaseUrl;
@Value("${app.authTokenPepper:change-me}")
private String tokenPepper;
private final SecureRandom secureRandom = new SecureRandom();
public BetaInviteService(
UserRepository users,
AuthTokenRepository tokens,
TemplatedEmailService templatedEmailService
) {
this.users = users;
this.tokens = tokens;
this.templatedEmailService = templatedEmailService;
}
/**
* Batch invite for all pending BETA users (role=BETA, is_active=false, deleted_at is null).
*/
public int inviteAllBetaUsers(int tokenMinutes, int limit, boolean dryRun) {
List<User> betaUsers = (limit > 0)
? users.findTopNByRoleAndIsActiveFalseAndDeletedAtIsNull("BETA", limit)
: users.findByRoleAndIsActiveFalseAndDeletedAtIsNull("BETA");
int sent = 0;
for (User user : betaUsers) {
inviteUser(user, tokenMinutes, dryRun);
sent++;
}
return sent;
}
/**
* Admin UI list: all pending beta requests (role=BETA, is_active=false).
* Controller expects Page<AdminBetaRequestDto>.
*/
public Page<AdminBetaRequestDto> listPendingBetaUsers(int page, int size) {
int safePage = Math.max(0, page);
int safeSize = Math.min(Math.max(1, size), 100);
List<User> pending = users.findByRoleAndIsActiveFalseAndDeletedAtIsNull("BETA");
int from = Math.min(safePage * safeSize, pending.size());
int to = Math.min(from + safeSize, pending.size());
OffsetDateTime now = OffsetDateTime.now();
List<AdminBetaRequestDto> dtos = pending.subList(from, to).stream()
.map(u -> {
AdminBetaRequestDto dto = AdminBetaRequestDto.from(u);
dto.invited = tokens.hasActiveToken(
u.getEmail(),
AuthToken.TokenType.MAGIC_LOGIN,
now
);
return dto;
})
.collect(Collectors.toList());
return new PageImpl<>(dtos, PageRequest.of(safePage, safeSize), pending.size());
}
/**
* Invite a single beta request by userId.
*/
public AdminInviteResponse inviteSingleBetaUser(Integer userId) {
if (userId == null) {
return new AdminInviteResponse(false, null, "userId is required");
}
User user = users.findById(userId).orElse(null);
if (user == null || user.getDeletedAt() != null) {
return new AdminInviteResponse(false, null, "User not found");
}
if (!"BETA".equalsIgnoreCase(user.getRole()) || user.isActive()) {
return new AdminInviteResponse(false, user.getEmail(), "User is not a pending beta request");
}
int tokenMinutes = 30; // default for single-invite; feel free to parametrize later
String magicUrl = inviteUser(user, tokenMinutes, false);
return new AdminInviteResponse(true, user.getEmail(), magicUrl);
}
/**
* Creates token, persists hash, and (optionally) sends email.
* Returns the magicUrl for logging / admin response.
*/
private String inviteUser(User user, int tokenMinutes, boolean dryRun) {
String email = user.getEmail();
String magicToken = generateToken();
saveToken(
email,
AuthToken.TokenType.MAGIC_LOGIN,
magicToken,
OffsetDateTime.now().plusMinutes(tokenMinutes)
);
String magicUrl = publicBaseUrl + "/beta/magic?token=" + magicToken;
if (!dryRun) {
templatedEmailService.send(
"beta_invite",
email,
Map.of(
"minutes", String.valueOf(tokenMinutes),
"magicUrl", magicUrl
)
);
}
return magicUrl;
}
private void saveToken(
String email,
AuthToken.TokenType type,
String token,
OffsetDateTime expiresAt
) {
AuthToken t = new AuthToken();
t.setEmail(email);
t.setType(type);
t.setTokenHash(hashToken(token));
t.setExpiresAt(expiresAt);
t.setCreatedAt(OffsetDateTime.now());
tokens.save(t);
}
private String generateToken() {
byte[] bytes = new byte[32];
secureRandom.nextBytes(bytes);
return HexFormat.of().formatHex(bytes);
}
private String hashToken(String token) {
try {
MessageDigest md = MessageDigest.getInstance("SHA-256");
byte[] hashed = md.digest(
(tokenPepper + ":" + token).getBytes(StandardCharsets.UTF_8)
);
return HexFormat.of().formatHex(hashed);
} catch (Exception e) {
throw new RuntimeException("Failed to hash token", e);
}
}
}

View File

@@ -1,38 +1,38 @@
package group.goforward.battlbuilder.service.impl;
import group.goforward.battlbuilder.model.Brand;
import group.goforward.battlbuilder.repo.BrandRepository;
import group.goforward.battlbuilder.service.BrandService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Optional;
@Service
public class BrandServiceImpl implements BrandService {
@Autowired
private BrandRepository repo;
@Override
public List<Brand> findAll() {
return repo.findAll();
}
@Override
public Optional<Brand> findById(Integer id) {
return repo.findById(id);
}
@Override
public Brand save(Brand item) {
return repo.save(item);
}
@Override
public void deleteById(Integer id) {
deleteById(id);
}
}
package group.goforward.battlbuilder.service.impl;
import group.goforward.battlbuilder.model.Brand;
import group.goforward.battlbuilder.repo.BrandRepository;
import group.goforward.battlbuilder.service.BrandService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Optional;
@Service
public class BrandServiceImpl implements BrandService {
@Autowired
private BrandRepository repo;
@Override
public List<Brand> findAll() {
return repo.findAll();
}
@Override
public Optional<Brand> findById(Integer id) {
return repo.findById(id);
}
@Override
public Brand save(Brand item) {
return repo.save(item);
}
@Override
public void deleteById(Integer id) {
deleteById(id);
}
}

View File

@@ -1,209 +1,209 @@
package group.goforward.battlbuilder.service.impl;
import group.goforward.battlbuilder.model.Product;
import group.goforward.battlbuilder.model.ProductOffer;
import group.goforward.battlbuilder.repo.ProductOfferRepository;
import group.goforward.battlbuilder.repo.ProductRepository;
import group.goforward.battlbuilder.repo.catalog.spec.CatalogProductSpecifications;
import group.goforward.battlbuilder.service.CatalogQueryService;
import group.goforward.battlbuilder.web.dto.ProductSummaryDto;
import group.goforward.battlbuilder.web.dto.catalog.CatalogProductIdsRequest;
import group.goforward.battlbuilder.web.mapper.ProductMapper;
import org.springframework.data.domain.*;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.time.OffsetDateTime;
import java.util.*;
import java.util.stream.Collectors;
@Service
public class CatalogQueryServiceImpl implements CatalogQueryService {
private final ProductRepository productRepository;
private final ProductOfferRepository productOfferRepository;
public CatalogQueryServiceImpl(ProductRepository productRepository,
ProductOfferRepository productOfferRepository) {
this.productRepository = productRepository;
this.productOfferRepository = productOfferRepository;
}
@Override
public Page<ProductSummaryDto> getOptions(
String platform,
String partRole,
List<String> partRoles,
List<String> brands,
String q,
Pageable pageable
) {
pageable = sanitizeCatalogPageable(pageable);
// Normalize roles: accept partRole OR partRoles
List<String> roleList = new ArrayList<>();
if (partRole != null && !partRole.isBlank()) roleList.add(partRole);
if (partRoles != null && !partRoles.isEmpty()) roleList.addAll(partRoles);
roleList = roleList.stream().filter(s -> s != null && !s.isBlank()).distinct().toList();
Specification<Product> spec = Specification.where(CatalogProductSpecifications.isCatalogVisible());
// platform optional: omit/blank/ALL => universal
if (platform != null && !platform.isBlank() && !"ALL".equalsIgnoreCase(platform)) {
spec = spec.and(CatalogProductSpecifications.platformEquals(platform));
}
if (!roleList.isEmpty()) {
spec = spec.and(CatalogProductSpecifications.partRoleIn(roleList));
}
if (brands != null && !brands.isEmpty()) {
spec = spec.and(CatalogProductSpecifications.brandNameIn(brands));
}
if (q != null && !q.isBlank()) {
spec = spec.and(CatalogProductSpecifications.queryLike(q));
}
Page<Product> page = productRepository.findAll(spec, pageable);
if (page.isEmpty()) {
return new PageImpl<>(List.of(), pageable, 0);
}
// Bulk offers for this page (no N+1)
List<Integer> productIds = page.getContent().stream().map(Product::getId).toList();
List<ProductOffer> offers = productOfferRepository.findByProduct_IdIn(productIds);
Map<Integer, ProductOffer> bestOfferByProductId = pickBestOffers(offers);
List<ProductSummaryDto> dtos = page.getContent().stream().map(p -> {
ProductOffer best = bestOfferByProductId.get(p.getId());
BigDecimal price = best != null ? best.getEffectivePrice() : null;
String buyUrl = best != null ? best.getBuyUrl() : null;
return ProductMapper.toSummary(p, price, buyUrl);
}).toList();
return new PageImpl<>(dtos, pageable, page.getTotalElements());
}
@Override
public List<ProductSummaryDto> getProductsByIds(CatalogProductIdsRequest request) {
List<Integer> ids = request != null ? request.getIds() : null;
if (ids == null || ids.isEmpty()) return List.of();
ids = ids.stream().filter(Objects::nonNull).distinct().toList();
List<Product> products = productRepository.findByIdIn(ids);
if (products.isEmpty()) return List.of();
List<ProductOffer> offers = productOfferRepository.findByProduct_IdIn(ids);
Map<Integer, ProductOffer> bestOfferByProductId = pickBestOffers(offers);
Map<Integer, Product> productById = products.stream()
.collect(Collectors.toMap(Product::getId, p -> p));
List<ProductSummaryDto> out = new ArrayList<>();
for (Integer id : ids) {
Product p = productById.get(id);
if (p == null) continue;
ProductOffer best = bestOfferByProductId.get(id);
BigDecimal price = best != null ? best.getEffectivePrice() : null;
String buyUrl = best != null ? best.getBuyUrl() : null;
out.add(ProductMapper.toSummary(p, price, buyUrl));
}
return out;
}
private Map<Integer, ProductOffer> pickBestOffers(List<ProductOffer> offers) {
Map<Integer, ProductOffer> best = new HashMap<>();
if (offers == null || offers.isEmpty()) return best;
for (ProductOffer o : offers) {
if (o == null || o.getProduct() == null || o.getProduct().getId() == null) continue;
Integer pid = o.getProduct().getId();
BigDecimal price = o.getEffectivePrice();
if (price == null) continue;
ProductOffer current = best.get(pid);
if (current == null) {
best.put(pid, o);
continue;
}
// ---- ranking rules (in order) ----
// 1) prefer in-stock
boolean oStock = Boolean.TRUE.equals(o.getInStock());
boolean cStock = Boolean.TRUE.equals(current.getInStock());
if (oStock != cStock) {
if (oStock) best.put(pid, o);
continue;
}
// 2) prefer cheaper price
BigDecimal currentPrice = current.getEffectivePrice();
if (currentPrice == null || price.compareTo(currentPrice) < 0) {
best.put(pid, o);
continue;
}
if (price.compareTo(currentPrice) > 0) continue;
// 3) tie-break: most recently seen
OffsetDateTime oSeen = o.getLastSeenAt();
OffsetDateTime cSeen = current.getLastSeenAt();
if (oSeen != null && cSeen != null && oSeen.isAfter(cSeen)) {
best.put(pid, o);
continue;
}
if (oSeen != null && cSeen == null) {
best.put(pid, o);
}
// 4) tie-break: prefer offer with buyUrl
String oUrl = o.getBuyUrl();
String cUrl = current.getBuyUrl();
if ((oUrl != null && !oUrl.isBlank()) && (cUrl == null || cUrl.isBlank())) {
best.put(pid, o);
}
}
return best;
}
private Pageable sanitizeCatalogPageable(Pageable pageable) {
if (pageable == null) {
return PageRequest.of(0, 24, Sort.by(Sort.Direction.DESC, "updatedAt"));
}
int page = pageable.getPageNumber();
int requested = pageable.getPageSize();
int size = Math.min(Math.max(requested, 1), 48); // ✅ hard cap
// Default sort if none provided
if (pageable.getSort() == null || pageable.getSort().isUnsorted()) {
return PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "updatedAt"));
}
// Only allow safe sorts (for now)
Sort.Order first = pageable.getSort().stream().findFirst().orElse(null);
if (first == null) {
return PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "updatedAt"));
}
String prop = first.getProperty();
Sort.Direction dir = first.getDirection();
// IMPORTANT:
// If you're still using JPA Specifications (Product entity), you can only sort by Product fields.
// Once you switch to the native "best offer" query, you can allow "price" and "brand" sorts.
return switch (prop) {
case "updatedAt", "createdAt", "name" -> PageRequest.of(page, size, Sort.by(dir, prop));
default -> PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "updatedAt"));
};
}
package group.goforward.battlbuilder.service.impl;
import group.goforward.battlbuilder.model.Product;
import group.goforward.battlbuilder.model.ProductOffer;
import group.goforward.battlbuilder.repo.ProductOfferRepository;
import group.goforward.battlbuilder.repo.ProductRepository;
import group.goforward.battlbuilder.repo.catalog.spec.CatalogProductSpecifications;
import group.goforward.battlbuilder.service.CatalogQueryService;
import group.goforward.battlbuilder.web.dto.ProductSummaryDto;
import group.goforward.battlbuilder.web.dto.catalog.CatalogProductIdsRequest;
import group.goforward.battlbuilder.web.mapper.ProductMapper;
import org.springframework.data.domain.*;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.time.OffsetDateTime;
import java.util.*;
import java.util.stream.Collectors;
@Service
public class CatalogQueryServiceImpl implements CatalogQueryService {
private final ProductRepository productRepository;
private final ProductOfferRepository productOfferRepository;
public CatalogQueryServiceImpl(ProductRepository productRepository,
ProductOfferRepository productOfferRepository) {
this.productRepository = productRepository;
this.productOfferRepository = productOfferRepository;
}
@Override
public Page<ProductSummaryDto> getOptions(
String platform,
String partRole,
List<String> partRoles,
List<String> brands,
String q,
Pageable pageable
) {
pageable = sanitizeCatalogPageable(pageable);
// Normalize roles: accept partRole OR partRoles
List<String> roleList = new ArrayList<>();
if (partRole != null && !partRole.isBlank()) roleList.add(partRole);
if (partRoles != null && !partRoles.isEmpty()) roleList.addAll(partRoles);
roleList = roleList.stream().filter(s -> s != null && !s.isBlank()).distinct().toList();
Specification<Product> spec = Specification.where(CatalogProductSpecifications.isCatalogVisible());
// platform optional: omit/blank/ALL => universal
if (platform != null && !platform.isBlank() && !"ALL".equalsIgnoreCase(platform)) {
spec = spec.and(CatalogProductSpecifications.platformEquals(platform));
}
if (!roleList.isEmpty()) {
spec = spec.and(CatalogProductSpecifications.partRoleIn(roleList));
}
if (brands != null && !brands.isEmpty()) {
spec = spec.and(CatalogProductSpecifications.brandNameIn(brands));
}
if (q != null && !q.isBlank()) {
spec = spec.and(CatalogProductSpecifications.queryLike(q));
}
Page<Product> page = productRepository.findAll(spec, pageable);
if (page.isEmpty()) {
return new PageImpl<>(List.of(), pageable, 0);
}
// Bulk offers for this page (no N+1)
List<Integer> productIds = page.getContent().stream().map(Product::getId).toList();
List<ProductOffer> offers = productOfferRepository.findByProduct_IdIn(productIds);
Map<Integer, ProductOffer> bestOfferByProductId = pickBestOffers(offers);
List<ProductSummaryDto> dtos = page.getContent().stream().map(p -> {
ProductOffer best = bestOfferByProductId.get(p.getId());
BigDecimal price = best != null ? best.getEffectivePrice() : null;
String buyUrl = best != null ? best.getBuyUrl() : null;
return ProductMapper.toSummary(p, price, buyUrl);
}).toList();
return new PageImpl<>(dtos, pageable, page.getTotalElements());
}
@Override
public List<ProductSummaryDto> getProductsByIds(CatalogProductIdsRequest request) {
List<Integer> ids = request != null ? request.getIds() : null;
if (ids == null || ids.isEmpty()) return List.of();
ids = ids.stream().filter(Objects::nonNull).distinct().toList();
List<Product> products = productRepository.findByIdIn(ids);
if (products.isEmpty()) return List.of();
List<ProductOffer> offers = productOfferRepository.findByProduct_IdIn(ids);
Map<Integer, ProductOffer> bestOfferByProductId = pickBestOffers(offers);
Map<Integer, Product> productById = products.stream()
.collect(Collectors.toMap(Product::getId, p -> p));
List<ProductSummaryDto> out = new ArrayList<>();
for (Integer id : ids) {
Product p = productById.get(id);
if (p == null) continue;
ProductOffer best = bestOfferByProductId.get(id);
BigDecimal price = best != null ? best.getEffectivePrice() : null;
String buyUrl = best != null ? best.getBuyUrl() : null;
out.add(ProductMapper.toSummary(p, price, buyUrl));
}
return out;
}
private Map<Integer, ProductOffer> pickBestOffers(List<ProductOffer> offers) {
Map<Integer, ProductOffer> best = new HashMap<>();
if (offers == null || offers.isEmpty()) return best;
for (ProductOffer o : offers) {
if (o == null || o.getProduct() == null || o.getProduct().getId() == null) continue;
Integer pid = o.getProduct().getId();
BigDecimal price = o.getEffectivePrice();
if (price == null) continue;
ProductOffer current = best.get(pid);
if (current == null) {
best.put(pid, o);
continue;
}
// ---- ranking rules (in order) ----
// 1) prefer in-stock
boolean oStock = Boolean.TRUE.equals(o.getInStock());
boolean cStock = Boolean.TRUE.equals(current.getInStock());
if (oStock != cStock) {
if (oStock) best.put(pid, o);
continue;
}
// 2) prefer cheaper price
BigDecimal currentPrice = current.getEffectivePrice();
if (currentPrice == null || price.compareTo(currentPrice) < 0) {
best.put(pid, o);
continue;
}
if (price.compareTo(currentPrice) > 0) continue;
// 3) tie-break: most recently seen
OffsetDateTime oSeen = o.getLastSeenAt();
OffsetDateTime cSeen = current.getLastSeenAt();
if (oSeen != null && cSeen != null && oSeen.isAfter(cSeen)) {
best.put(pid, o);
continue;
}
if (oSeen != null && cSeen == null) {
best.put(pid, o);
}
// 4) tie-break: prefer offer with buyUrl
String oUrl = o.getBuyUrl();
String cUrl = current.getBuyUrl();
if ((oUrl != null && !oUrl.isBlank()) && (cUrl == null || cUrl.isBlank())) {
best.put(pid, o);
}
}
return best;
}
private Pageable sanitizeCatalogPageable(Pageable pageable) {
if (pageable == null) {
return PageRequest.of(0, 24, Sort.by(Sort.Direction.DESC, "updatedAt"));
}
int page = pageable.getPageNumber();
int requested = pageable.getPageSize();
int size = Math.min(Math.max(requested, 1), 48); // ✅ hard cap
// Default sort if none provided
if (pageable.getSort() == null || pageable.getSort().isUnsorted()) {
return PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "updatedAt"));
}
// Only allow safe sorts (for now)
Sort.Order first = pageable.getSort().stream().findFirst().orElse(null);
if (first == null) {
return PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "updatedAt"));
}
String prop = first.getProperty();
Sort.Direction dir = first.getDirection();
// IMPORTANT:
// If you're still using JPA Specifications (Product entity), you can only sort by Product fields.
// Once you switch to the native "best offer" query, you can allow "price" and "brand" sorts.
return switch (prop) {
case "updatedAt", "createdAt", "name" -> PageRequest.of(page, size, Sort.by(dir, prop));
default -> PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "updatedAt"));
};
}
}

View File

@@ -1,135 +1,135 @@
package group.goforward.battlbuilder.service.impl;
import group.goforward.battlbuilder.catalog.classification.PartRoleResolver;
import group.goforward.battlbuilder.imports.MerchantFeedRow;
import group.goforward.battlbuilder.model.Merchant;
import group.goforward.battlbuilder.model.PartRoleSource;
import group.goforward.battlbuilder.service.CategoryClassificationService;
import org.springframework.stereotype.Service;
import java.util.Locale;
import java.util.Optional;
@Service
public class CategoryClassificationServiceImpl implements CategoryClassificationService {
private final MerchantCategoryMappingService merchantCategoryMappingService;
private final PartRoleResolver partRoleResolver;
public CategoryClassificationServiceImpl(
MerchantCategoryMappingService merchantCategoryMappingService,
PartRoleResolver partRoleResolver
) {
this.merchantCategoryMappingService = merchantCategoryMappingService;
this.partRoleResolver = partRoleResolver;
}
@Override
public Result classify(Merchant merchant, MerchantFeedRow row) {
String rawCategoryKey = buildRawCategoryKey(row);
String platformFinal = inferPlatform(row);
if (platformFinal == null || platformFinal.isBlank()) platformFinal = "AR-15";
return classify(merchant, row, platformFinal, rawCategoryKey);
}
@Override
public Result classify(Merchant merchant, MerchantFeedRow row, String platformFinal, String rawCategoryKey) {
if (platformFinal == null || platformFinal.isBlank()) platformFinal = "AR-15";
// 1) merchant map (authoritative if present)
Optional<String> mapped = merchantCategoryMappingService.resolveMappedPartRole(
merchant != null ? merchant.getId() : null,
rawCategoryKey,
platformFinal
);
if (mapped.isPresent()) {
String role = normalizePartRole(mapped.get());
return new Result(
platformFinal,
role,
rawCategoryKey,
PartRoleSource.MERCHANT_MAP,
"merchant_category_map: " + rawCategoryKey + " (" + platformFinal + ")"
);
}
// 2) rules
String resolved = partRoleResolver.resolve(platformFinal, row.productName(), rawCategoryKey);
if (resolved != null && !resolved.isBlank()) {
String role = normalizePartRole(resolved);
return new Result(
platformFinal,
role,
rawCategoryKey,
PartRoleSource.RULES,
"PartRoleResolver matched"
);
}
// 3) no inference: leave unknown and let it flow to PENDING_MAPPING
return new Result(
platformFinal,
"unknown",
rawCategoryKey,
PartRoleSource.UNKNOWN,
"no mapping or rules match"
);
}
private String buildRawCategoryKey(MerchantFeedRow row) {
String dept = trimOrNull(row.department());
String cat = trimOrNull(row.category());
String sub = trimOrNull(row.subCategory());
StringBuilder sb = new StringBuilder();
if (dept != null) sb.append(dept);
if (cat != null) {
if (!sb.isEmpty()) sb.append(" > ");
sb.append(cat);
}
if (sub != null) {
if (!sb.isEmpty()) sb.append(" > ");
sb.append(sub);
}
String result = sb.toString();
return result.isBlank() ? null : result;
}
private String inferPlatform(MerchantFeedRow row) {
String blob = String.join(" ",
coalesce(trimOrNull(row.department()), ""),
coalesce(trimOrNull(row.category()), ""),
coalesce(trimOrNull(row.subCategory()), "")
).toLowerCase(Locale.ROOT);
if (blob.contains("ar-15") || blob.contains("ar15")) return "AR-15";
if (blob.contains("ar-10") || blob.contains("ar10") || blob.contains("lr-308") || blob.contains("lr308")) return "AR-10";
if (blob.contains("ar-9") || blob.contains("ar9")) return "AR-9";
if (blob.contains("ak-47") || blob.contains("ak47")) return "AK-47";
return "AR-15";
}
private String normalizePartRole(String partRole) {
if (partRole == null) return "unknown";
String t = partRole.trim().toLowerCase(Locale.ROOT).replace('_', '-');
return t.isBlank() ? "unknown" : t;
}
private String trimOrNull(String v) {
if (v == null) return null;
String t = v.trim();
return t.isEmpty() ? null : t;
}
private String coalesce(String... values) {
if (values == null) return null;
for (String v : values) {
if (v != null && !v.isBlank()) return v;
}
return null;
}
package group.goforward.battlbuilder.service.impl;
import group.goforward.battlbuilder.catalog.classification.PartRoleResolver;
import group.goforward.battlbuilder.imports.MerchantFeedRow;
import group.goforward.battlbuilder.model.Merchant;
import group.goforward.battlbuilder.model.PartRoleSource;
import group.goforward.battlbuilder.service.CategoryClassificationService;
import org.springframework.stereotype.Service;
import java.util.Locale;
import java.util.Optional;
@Service
public class CategoryClassificationServiceImpl implements CategoryClassificationService {
private final MerchantCategoryMappingService merchantCategoryMappingService;
private final PartRoleResolver partRoleResolver;
public CategoryClassificationServiceImpl(
MerchantCategoryMappingService merchantCategoryMappingService,
PartRoleResolver partRoleResolver
) {
this.merchantCategoryMappingService = merchantCategoryMappingService;
this.partRoleResolver = partRoleResolver;
}
@Override
public Result classify(Merchant merchant, MerchantFeedRow row) {
String rawCategoryKey = buildRawCategoryKey(row);
String platformFinal = inferPlatform(row);
if (platformFinal == null || platformFinal.isBlank()) platformFinal = "AR-15";
return classify(merchant, row, platformFinal, rawCategoryKey);
}
@Override
public Result classify(Merchant merchant, MerchantFeedRow row, String platformFinal, String rawCategoryKey) {
if (platformFinal == null || platformFinal.isBlank()) platformFinal = "AR-15";
// 1) merchant map (authoritative if present)
Optional<String> mapped = merchantCategoryMappingService.resolveMappedPartRole(
merchant != null ? merchant.getId() : null,
rawCategoryKey,
platformFinal
);
if (mapped.isPresent()) {
String role = normalizePartRole(mapped.get());
return new Result(
platformFinal,
role,
rawCategoryKey,
PartRoleSource.MERCHANT_MAP,
"merchant_category_map: " + rawCategoryKey + " (" + platformFinal + ")"
);
}
// 2) rules
String resolved = partRoleResolver.resolve(platformFinal, row.productName(), rawCategoryKey);
if (resolved != null && !resolved.isBlank()) {
String role = normalizePartRole(resolved);
return new Result(
platformFinal,
role,
rawCategoryKey,
PartRoleSource.RULES,
"PartRoleResolver matched"
);
}
// 3) no inference: leave unknown and let it flow to PENDING_MAPPING
return new Result(
platformFinal,
"unknown",
rawCategoryKey,
PartRoleSource.UNKNOWN,
"no mapping or rules match"
);
}
private String buildRawCategoryKey(MerchantFeedRow row) {
String dept = trimOrNull(row.department());
String cat = trimOrNull(row.category());
String sub = trimOrNull(row.subCategory());
StringBuilder sb = new StringBuilder();
if (dept != null) sb.append(dept);
if (cat != null) {
if (!sb.isEmpty()) sb.append(" > ");
sb.append(cat);
}
if (sub != null) {
if (!sb.isEmpty()) sb.append(" > ");
sb.append(sub);
}
String result = sb.toString();
return result.isBlank() ? null : result;
}
private String inferPlatform(MerchantFeedRow row) {
String blob = String.join(" ",
coalesce(trimOrNull(row.department()), ""),
coalesce(trimOrNull(row.category()), ""),
coalesce(trimOrNull(row.subCategory()), "")
).toLowerCase(Locale.ROOT);
if (blob.contains("ar-15") || blob.contains("ar15")) return "AR-15";
if (blob.contains("ar-10") || blob.contains("ar10") || blob.contains("lr-308") || blob.contains("lr308")) return "AR-10";
if (blob.contains("ar-9") || blob.contains("ar9")) return "AR-9";
if (blob.contains("ak-47") || blob.contains("ak47")) return "AK-47";
return "AR-15";
}
private String normalizePartRole(String partRole) {
if (partRole == null) return "unknown";
String t = partRole.trim().toLowerCase(Locale.ROOT).replace('_', '-');
return t.isBlank() ? "unknown" : t;
}
private String trimOrNull(String v) {
if (v == null) return null;
String t = v.trim();
return t.isEmpty() ? null : t;
}
private String coalesce(String... values) {
if (values == null) return null;
for (String v : values) {
if (v != null && !v.isBlank()) return v;
}
return null;
}
}

View File

@@ -1,38 +1,38 @@
package group.goforward.battlbuilder.service.impl;
import group.goforward.battlbuilder.repo.MerchantCategoryMapRepository;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Optional;
@Service
public class MerchantCategoryMappingService {
private final MerchantCategoryMapRepository merchantCategoryMapRepository;
public MerchantCategoryMappingService(MerchantCategoryMapRepository merchantCategoryMapRepository) {
this.merchantCategoryMapRepository = merchantCategoryMapRepository;
}
public Optional<String> resolveMappedPartRole(
Integer merchantId,
String rawCategoryKey,
String platformFinal
) {
if (merchantId == null || rawCategoryKey == null || rawCategoryKey.isBlank()) {
return Optional.empty();
}
List<String> canonicalRoles =
merchantCategoryMapRepository.findCanonicalPartRoles(merchantId, rawCategoryKey);
if (canonicalRoles == null || canonicalRoles.isEmpty()) {
return Optional.empty();
}
return canonicalRoles.stream()
.filter(v -> v != null && !v.isBlank())
.findFirst();
}
package group.goforward.battlbuilder.service.impl;
import group.goforward.battlbuilder.repo.MerchantCategoryMapRepository;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Optional;
@Service
public class MerchantCategoryMappingService {
private final MerchantCategoryMapRepository merchantCategoryMapRepository;
public MerchantCategoryMappingService(MerchantCategoryMapRepository merchantCategoryMapRepository) {
this.merchantCategoryMapRepository = merchantCategoryMapRepository;
}
public Optional<String> resolveMappedPartRole(
Integer merchantId,
String rawCategoryKey,
String platformFinal
) {
if (merchantId == null || rawCategoryKey == null || rawCategoryKey.isBlank()) {
return Optional.empty();
}
List<String> canonicalRoles =
merchantCategoryMapRepository.findCanonicalPartRoles(merchantId, rawCategoryKey);
if (canonicalRoles == null || canonicalRoles.isEmpty()) {
return Optional.empty();
}
return canonicalRoles.stream()
.filter(v -> v != null && !v.isBlank())
.findFirst();
}
}

View File

@@ -1,174 +1,174 @@
package group.goforward.battlbuilder.service.impl;
import group.goforward.battlbuilder.model.Product;
import group.goforward.battlbuilder.model.ProductOffer;
import group.goforward.battlbuilder.repo.ProductOfferRepository;
import group.goforward.battlbuilder.repo.ProductRepository;
import group.goforward.battlbuilder.service.ProductQueryService;
import group.goforward.battlbuilder.web.dto.ProductOfferDto;
import group.goforward.battlbuilder.web.dto.ProductSummaryDto;
import group.goforward.battlbuilder.web.mapper.ProductMapper;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.util.*;
import java.util.stream.Collectors;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.Pageable;
import java.util.Collections;
import java.util.Map;
import java.util.List;
@Service
public class ProductQueryServiceImpl implements ProductQueryService {
private final ProductRepository productRepository;
private final ProductOfferRepository productOfferRepository;
public ProductQueryServiceImpl(
ProductRepository productRepository,
ProductOfferRepository productOfferRepository
) {
this.productRepository = productRepository;
this.productOfferRepository = productOfferRepository;
}
@Override
public List<ProductSummaryDto> getProducts(String platform, List<String> partRoles) {
final boolean allPlatforms = platform != null && platform.equalsIgnoreCase("ALL");
List<Product> products;
if (partRoles == null || partRoles.isEmpty()) {
products = allPlatforms
? productRepository.findAllWithBrand()
: productRepository.findByPlatformWithBrand(platform);
} else {
products = allPlatforms
? productRepository.findByPartRoleInWithBrand(partRoles)
: productRepository.findByPlatformAndPartRoleInWithBrand(platform, partRoles);
}
if (products.isEmpty()) return List.of();
List<Integer> productIds = products.stream().map(Product::getId).toList();
// ✅ canonical repo method
List<ProductOffer> allOffers = productOfferRepository.findByProduct_IdIn(productIds);
Map<Integer, List<ProductOffer>> offersByProductId = allOffers.stream()
.filter(o -> o.getProduct() != null && o.getProduct().getId() != null)
.collect(Collectors.groupingBy(o -> o.getProduct().getId()));
return products.stream()
.map(p -> {
List<ProductOffer> offersForProduct =
offersByProductId.getOrDefault(p.getId(), Collections.emptyList());
ProductOffer bestOffer = pickBestOffer(offersForProduct);
BigDecimal price = bestOffer != null ? bestOffer.getEffectivePrice() : null;
String buyUrl = bestOffer != null ? bestOffer.getBuyUrl() : null;
return ProductMapper.toSummary(p, price, buyUrl);
})
.toList();
}
@Override
public Page<ProductSummaryDto> getProductsPage(String platform, List<String> partRoles, Pageable pageable) {
final boolean allPlatforms = platform != null && platform.equalsIgnoreCase("ALL");
Page<Product> productPage;
if (partRoles == null || partRoles.isEmpty()) {
productPage = allPlatforms
? productRepository.findAllWithBrand(pageable)
: productRepository.findByPlatformWithBrand(platform, pageable);
} else {
productPage = allPlatforms
? productRepository.findByPartRoleInWithBrand(partRoles, pageable)
: productRepository.findByPlatformAndPartRoleInWithBrand(platform, partRoles, pageable);
}
List<Product> products = productPage.getContent();
if (products.isEmpty()) {
return Page.empty(pageable);
}
List<Integer> productIds = products.stream().map(Product::getId).toList();
// Only fetch offers for THIS PAGE of products
List<ProductOffer> allOffers = productOfferRepository.findByProduct_IdIn(productIds);
Map<Integer, List<ProductOffer>> offersByProductId = allOffers.stream()
.filter(o -> o.getProduct() != null && o.getProduct().getId() != null)
.collect(Collectors.groupingBy(o -> o.getProduct().getId()));
List<ProductSummaryDto> dtos = products.stream()
.map(p -> {
List<ProductOffer> offersForProduct =
offersByProductId.getOrDefault(p.getId(), Collections.emptyList());
ProductOffer bestOffer = pickBestOffer(offersForProduct);
BigDecimal price = bestOffer != null ? bestOffer.getEffectivePrice() : null;
String buyUrl = bestOffer != null ? bestOffer.getBuyUrl() : null;
return ProductMapper.toSummary(p, price, buyUrl);
})
.toList();
return new PageImpl<>(dtos, pageable, productPage.getTotalElements());
}
//
// Product Offers
//
@Override
public List<ProductOfferDto> getOffersForProduct(Integer productId) {
// ✅ canonical repo method
List<ProductOffer> offers = productOfferRepository.findByProduct_Id(productId);
return offers.stream()
.map(offer -> {
ProductOfferDto dto = new ProductOfferDto();
dto.setId(offer.getId().toString());
dto.setMerchantName(offer.getMerchant().getName());
dto.setPrice(offer.getEffectivePrice());
dto.setOriginalPrice(offer.getOriginalPrice());
dto.setInStock(Boolean.TRUE.equals(offer.getInStock()));
dto.setBuyUrl(offer.getBuyUrl());
dto.setLastUpdated(offer.getLastSeenAt());
return dto;
})
.toList();
}
@Override
public ProductSummaryDto getProductById(Integer productId) {
Product product = productRepository.findById(productId).orElse(null);
if (product == null) return null;
// ✅ canonical repo method
List<ProductOffer> offers = productOfferRepository.findByProduct_Id(productId);
ProductOffer bestOffer = pickBestOffer(offers);
BigDecimal price = bestOffer != null ? bestOffer.getEffectivePrice() : null;
String buyUrl = bestOffer != null ? bestOffer.getBuyUrl() : null;
return ProductMapper.toSummary(product, price, buyUrl);
}
private ProductOffer pickBestOffer(List<ProductOffer> offers) {
if (offers == null || offers.isEmpty()) return null;
// MVP: lowest effective price wins. (Later: prefer in-stock, etc.)
return offers.stream()
.filter(o -> o.getEffectivePrice() != null)
.min(Comparator.comparing(ProductOffer::getEffectivePrice))
.orElse(null);
}
package group.goforward.battlbuilder.service.impl;
import group.goforward.battlbuilder.model.Product;
import group.goforward.battlbuilder.model.ProductOffer;
import group.goforward.battlbuilder.repo.ProductOfferRepository;
import group.goforward.battlbuilder.repo.ProductRepository;
import group.goforward.battlbuilder.service.ProductQueryService;
import group.goforward.battlbuilder.web.dto.ProductOfferDto;
import group.goforward.battlbuilder.web.dto.ProductSummaryDto;
import group.goforward.battlbuilder.web.mapper.ProductMapper;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.util.*;
import java.util.stream.Collectors;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.Pageable;
import java.util.Collections;
import java.util.Map;
import java.util.List;
@Service
public class ProductQueryServiceImpl implements ProductQueryService {
private final ProductRepository productRepository;
private final ProductOfferRepository productOfferRepository;
public ProductQueryServiceImpl(
ProductRepository productRepository,
ProductOfferRepository productOfferRepository
) {
this.productRepository = productRepository;
this.productOfferRepository = productOfferRepository;
}
@Override
public List<ProductSummaryDto> getProducts(String platform, List<String> partRoles) {
final boolean allPlatforms = platform != null && platform.equalsIgnoreCase("ALL");
List<Product> products;
if (partRoles == null || partRoles.isEmpty()) {
products = allPlatforms
? productRepository.findAllWithBrand()
: productRepository.findByPlatformWithBrand(platform);
} else {
products = allPlatforms
? productRepository.findByPartRoleInWithBrand(partRoles)
: productRepository.findByPlatformAndPartRoleInWithBrand(platform, partRoles);
}
if (products.isEmpty()) return List.of();
List<Integer> productIds = products.stream().map(Product::getId).toList();
// ✅ canonical repo method
List<ProductOffer> allOffers = productOfferRepository.findByProduct_IdIn(productIds);
Map<Integer, List<ProductOffer>> offersByProductId = allOffers.stream()
.filter(o -> o.getProduct() != null && o.getProduct().getId() != null)
.collect(Collectors.groupingBy(o -> o.getProduct().getId()));
return products.stream()
.map(p -> {
List<ProductOffer> offersForProduct =
offersByProductId.getOrDefault(p.getId(), Collections.emptyList());
ProductOffer bestOffer = pickBestOffer(offersForProduct);
BigDecimal price = bestOffer != null ? bestOffer.getEffectivePrice() : null;
String buyUrl = bestOffer != null ? bestOffer.getBuyUrl() : null;
return ProductMapper.toSummary(p, price, buyUrl);
})
.toList();
}
@Override
public Page<ProductSummaryDto> getProductsPage(String platform, List<String> partRoles, Pageable pageable) {
final boolean allPlatforms = platform != null && platform.equalsIgnoreCase("ALL");
Page<Product> productPage;
if (partRoles == null || partRoles.isEmpty()) {
productPage = allPlatforms
? productRepository.findAllWithBrand(pageable)
: productRepository.findByPlatformWithBrand(platform, pageable);
} else {
productPage = allPlatforms
? productRepository.findByPartRoleInWithBrand(partRoles, pageable)
: productRepository.findByPlatformAndPartRoleInWithBrand(platform, partRoles, pageable);
}
List<Product> products = productPage.getContent();
if (products.isEmpty()) {
return Page.empty(pageable);
}
List<Integer> productIds = products.stream().map(Product::getId).toList();
// Only fetch offers for THIS PAGE of products
List<ProductOffer> allOffers = productOfferRepository.findByProduct_IdIn(productIds);
Map<Integer, List<ProductOffer>> offersByProductId = allOffers.stream()
.filter(o -> o.getProduct() != null && o.getProduct().getId() != null)
.collect(Collectors.groupingBy(o -> o.getProduct().getId()));
List<ProductSummaryDto> dtos = products.stream()
.map(p -> {
List<ProductOffer> offersForProduct =
offersByProductId.getOrDefault(p.getId(), Collections.emptyList());
ProductOffer bestOffer = pickBestOffer(offersForProduct);
BigDecimal price = bestOffer != null ? bestOffer.getEffectivePrice() : null;
String buyUrl = bestOffer != null ? bestOffer.getBuyUrl() : null;
return ProductMapper.toSummary(p, price, buyUrl);
})
.toList();
return new PageImpl<>(dtos, pageable, productPage.getTotalElements());
}
//
// Product Offers
//
@Override
public List<ProductOfferDto> getOffersForProduct(Integer productId) {
// ✅ canonical repo method
List<ProductOffer> offers = productOfferRepository.findByProduct_Id(productId);
return offers.stream()
.map(offer -> {
ProductOfferDto dto = new ProductOfferDto();
dto.setId(offer.getId().toString());
dto.setMerchantName(offer.getMerchant().getName());
dto.setPrice(offer.getEffectivePrice());
dto.setOriginalPrice(offer.getOriginalPrice());
dto.setInStock(Boolean.TRUE.equals(offer.getInStock()));
dto.setBuyUrl(offer.getBuyUrl());
dto.setLastUpdated(offer.getLastSeenAt());
return dto;
})
.toList();
}
@Override
public ProductSummaryDto getProductById(Integer productId) {
Product product = productRepository.findById(productId).orElse(null);
if (product == null) return null;
// ✅ canonical repo method
List<ProductOffer> offers = productOfferRepository.findByProduct_Id(productId);
ProductOffer bestOffer = pickBestOffer(offers);
BigDecimal price = bestOffer != null ? bestOffer.getEffectivePrice() : null;
String buyUrl = bestOffer != null ? bestOffer.getBuyUrl() : null;
return ProductMapper.toSummary(product, price, buyUrl);
}
private ProductOffer pickBestOffer(List<ProductOffer> offers) {
if (offers == null || offers.isEmpty()) return null;
// MVP: lowest effective price wins. (Later: prefer in-stock, etc.)
return offers.stream()
.filter(o -> o.getEffectivePrice() != null)
.min(Comparator.comparing(ProductOffer::getEffectivePrice))
.orElse(null);
}
}

View File

@@ -1,175 +1,175 @@
package group.goforward.battlbuilder.service.impl;
import group.goforward.battlbuilder.model.ImportStatus;
import group.goforward.battlbuilder.model.PartRoleSource;
import group.goforward.battlbuilder.model.Product;
import group.goforward.battlbuilder.repo.ProductRepository;
import group.goforward.battlbuilder.service.ReclassificationService;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Optional;
@Service
public class ReclassificationServiceImpl implements ReclassificationService {
private static final String CLASSIFIER_VERSION = "v2025-12-28.1";
private final ProductRepository productRepository;
private final MerchantCategoryMappingService merchantCategoryMappingService;
// ✅ Keep ONE constructor. Spring will inject both deps.
public ReclassificationServiceImpl(
ProductRepository productRepository,
MerchantCategoryMappingService merchantCategoryMappingService
) {
this.productRepository = productRepository;
this.merchantCategoryMappingService = merchantCategoryMappingService;
}
// ============================
// Catalog category FK backfill
// ============================
@Override
@Transactional
public int applyCatalogCategoryMappingToProducts(
Integer merchantId,
String rawCategoryKey,
Integer canonicalCategoryId
) {
if (merchantId == null) throw new IllegalArgumentException("merchantId is required");
if (rawCategoryKey == null || rawCategoryKey.isBlank())
throw new IllegalArgumentException("rawCategoryKey is required");
return productRepository.applyCanonicalCategoryByPrimaryMerchantAndRawCategory(
merchantId,
rawCategoryKey.trim(),
canonicalCategoryId
);
}
/**
* Optional helper: bulk reclassify only PENDING_MAPPING for a merchant,
* using ONLY merchant_category_map (no rules, no inference).
*/
@Override
@Transactional
public int reclassifyPendingForMerchant(Integer merchantId) {
if (merchantId == null) throw new IllegalArgumentException("merchantId required");
List<Product> pending = productRepository.findPendingMappingByMerchantId(merchantId);
if (pending == null || pending.isEmpty()) return 0;
Instant now = Instant.now();
List<Product> toSave = new ArrayList<>();
int updated = 0;
for (Product p : pending) {
if (p.getDeletedAt() != null) continue;
if (Boolean.TRUE.equals(p.getPartRoleLocked())) continue;
String rawCategoryKey = p.getRawCategoryKey();
if (rawCategoryKey == null || rawCategoryKey.isBlank()) continue;
String platformFinal = normalizePlatformOrNull(p.getPlatform());
Optional<String> mappedRole = merchantCategoryMappingService.resolveMappedPartRole(
merchantId, rawCategoryKey, platformFinal
);
if (mappedRole.isEmpty()) continue;
String normalized = normalizePartRole(mappedRole.get());
if ("unknown".equals(normalized)) continue;
String current = normalizePartRole(p.getPartRole());
if (normalized.equals(current) && p.getImportStatus() == ImportStatus.MAPPED) continue;
p.setPartRole(normalized);
p.setImportStatus(ImportStatus.MAPPED);
p.setPartRoleSource(PartRoleSource.MERCHANT_MAP);
p.setClassifierVersion(CLASSIFIER_VERSION);
p.setClassifiedAt(now);
p.setClassificationReason("merchant_category_map: " + rawCategoryKey +
(platformFinal != null ? (" (" + platformFinal + ")") : ""));
toSave.add(p);
updated++;
}
if (!toSave.isEmpty()) productRepository.saveAll(toSave);
return updated;
}
/**
* Called by MappingAdminService after creating/updating a mapping.
* Applies mapping to all products for merchant+rawCategoryKey.
*/
@Override
@Transactional
public int applyMappingToProducts(Integer merchantId, String rawCategoryKey) {
if (merchantId == null) throw new IllegalArgumentException("merchantId required");
if (rawCategoryKey == null || rawCategoryKey.isBlank()) throw new IllegalArgumentException("rawCategoryKey required");
List<Product> products = productRepository.findByMerchantIdAndRawCategoryKey(merchantId, rawCategoryKey);
if (products == null || products.isEmpty()) return 0;
Instant now = Instant.now();
List<Product> toSave = new ArrayList<>();
int updated = 0;
for (Product p : products) {
if (p.getDeletedAt() != null) continue;
if (Boolean.TRUE.equals(p.getPartRoleLocked())) continue;
String platformFinal = normalizePlatformOrNull(p.getPlatform());
Optional<String> mappedRole = merchantCategoryMappingService.resolveMappedPartRole(
merchantId, rawCategoryKey, platformFinal
);
if (mappedRole.isEmpty()) continue;
String normalized = normalizePartRole(mappedRole.get());
if ("unknown".equals(normalized)) continue;
String current = normalizePartRole(p.getPartRole());
if (normalized.equals(current) && p.getImportStatus() == ImportStatus.MAPPED) continue;
p.setPartRole(normalized);
p.setImportStatus(ImportStatus.MAPPED);
p.setPartRoleSource(PartRoleSource.MERCHANT_MAP);
p.setClassifierVersion(CLASSIFIER_VERSION);
p.setClassifiedAt(now);
p.setClassificationReason("merchant_category_map: " + rawCategoryKey +
(platformFinal != null ? (" (" + platformFinal + ")") : ""));
toSave.add(p);
updated++;
}
if (!toSave.isEmpty()) productRepository.saveAll(toSave);
return updated;
}
// -----------------
// Helpers
// -----------------
private String normalizePlatformOrNull(String platform) {
if (platform == null) return null;
String t = platform.trim();
return t.isEmpty() ? null : t;
}
private String normalizePartRole(String partRole) {
if (partRole == null) return "unknown";
String t = partRole.trim().toLowerCase(Locale.ROOT).replace('_', '-');
return t.isBlank() ? "unknown" : t;
}
package group.goforward.battlbuilder.service.impl;
import group.goforward.battlbuilder.model.ImportStatus;
import group.goforward.battlbuilder.model.PartRoleSource;
import group.goforward.battlbuilder.model.Product;
import group.goforward.battlbuilder.repo.ProductRepository;
import group.goforward.battlbuilder.service.ReclassificationService;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Optional;
@Service
public class ReclassificationServiceImpl implements ReclassificationService {
private static final String CLASSIFIER_VERSION = "v2025-12-28.1";
private final ProductRepository productRepository;
private final MerchantCategoryMappingService merchantCategoryMappingService;
// ✅ Keep ONE constructor. Spring will inject both deps.
public ReclassificationServiceImpl(
ProductRepository productRepository,
MerchantCategoryMappingService merchantCategoryMappingService
) {
this.productRepository = productRepository;
this.merchantCategoryMappingService = merchantCategoryMappingService;
}
// ============================
// Catalog category FK backfill
// ============================
@Override
@Transactional
public int applyCatalogCategoryMappingToProducts(
Integer merchantId,
String rawCategoryKey,
Integer canonicalCategoryId
) {
if (merchantId == null) throw new IllegalArgumentException("merchantId is required");
if (rawCategoryKey == null || rawCategoryKey.isBlank())
throw new IllegalArgumentException("rawCategoryKey is required");
return productRepository.applyCanonicalCategoryByPrimaryMerchantAndRawCategory(
merchantId,
rawCategoryKey.trim(),
canonicalCategoryId
);
}
/**
* Optional helper: bulk reclassify only PENDING_MAPPING for a merchant,
* using ONLY merchant_category_map (no rules, no inference).
*/
@Override
@Transactional
public int reclassifyPendingForMerchant(Integer merchantId) {
if (merchantId == null) throw new IllegalArgumentException("merchantId required");
List<Product> pending = productRepository.findPendingMappingByMerchantId(merchantId);
if (pending == null || pending.isEmpty()) return 0;
Instant now = Instant.now();
List<Product> toSave = new ArrayList<>();
int updated = 0;
for (Product p : pending) {
if (p.getDeletedAt() != null) continue;
if (Boolean.TRUE.equals(p.getPartRoleLocked())) continue;
String rawCategoryKey = p.getRawCategoryKey();
if (rawCategoryKey == null || rawCategoryKey.isBlank()) continue;
String platformFinal = normalizePlatformOrNull(p.getPlatform());
Optional<String> mappedRole = merchantCategoryMappingService.resolveMappedPartRole(
merchantId, rawCategoryKey, platformFinal
);
if (mappedRole.isEmpty()) continue;
String normalized = normalizePartRole(mappedRole.get());
if ("unknown".equals(normalized)) continue;
String current = normalizePartRole(p.getPartRole());
if (normalized.equals(current) && p.getImportStatus() == ImportStatus.MAPPED) continue;
p.setPartRole(normalized);
p.setImportStatus(ImportStatus.MAPPED);
p.setPartRoleSource(PartRoleSource.MERCHANT_MAP);
p.setClassifierVersion(CLASSIFIER_VERSION);
p.setClassifiedAt(now);
p.setClassificationReason("merchant_category_map: " + rawCategoryKey +
(platformFinal != null ? (" (" + platformFinal + ")") : ""));
toSave.add(p);
updated++;
}
if (!toSave.isEmpty()) productRepository.saveAll(toSave);
return updated;
}
/**
* Called by MappingAdminService after creating/updating a mapping.
* Applies mapping to all products for merchant+rawCategoryKey.
*/
@Override
@Transactional
public int applyMappingToProducts(Integer merchantId, String rawCategoryKey) {
if (merchantId == null) throw new IllegalArgumentException("merchantId required");
if (rawCategoryKey == null || rawCategoryKey.isBlank()) throw new IllegalArgumentException("rawCategoryKey required");
List<Product> products = productRepository.findByMerchantIdAndRawCategoryKey(merchantId, rawCategoryKey);
if (products == null || products.isEmpty()) return 0;
Instant now = Instant.now();
List<Product> toSave = new ArrayList<>();
int updated = 0;
for (Product p : products) {
if (p.getDeletedAt() != null) continue;
if (Boolean.TRUE.equals(p.getPartRoleLocked())) continue;
String platformFinal = normalizePlatformOrNull(p.getPlatform());
Optional<String> mappedRole = merchantCategoryMappingService.resolveMappedPartRole(
merchantId, rawCategoryKey, platformFinal
);
if (mappedRole.isEmpty()) continue;
String normalized = normalizePartRole(mappedRole.get());
if ("unknown".equals(normalized)) continue;
String current = normalizePartRole(p.getPartRole());
if (normalized.equals(current) && p.getImportStatus() == ImportStatus.MAPPED) continue;
p.setPartRole(normalized);
p.setImportStatus(ImportStatus.MAPPED);
p.setPartRoleSource(PartRoleSource.MERCHANT_MAP);
p.setClassifierVersion(CLASSIFIER_VERSION);
p.setClassifiedAt(now);
p.setClassificationReason("merchant_category_map: " + rawCategoryKey +
(platformFinal != null ? (" (" + platformFinal + ")") : ""));
toSave.add(p);
updated++;
}
if (!toSave.isEmpty()) productRepository.saveAll(toSave);
return updated;
}
// -----------------
// Helpers
// -----------------
private String normalizePlatformOrNull(String platform) {
if (platform == null) return null;
String t = platform.trim();
return t.isEmpty() ? null : t;
}
private String normalizePartRole(String partRole) {
if (partRole == null) return "unknown";
String t = partRole.trim().toLowerCase(Locale.ROOT).replace('_', '-');
return t.isBlank() ? "unknown" : t;
}
}

View File

@@ -1,12 +1,12 @@
/**
* Services package for the BattlBuilder application.
* <p>
* Contains business logic service classes for product management,
* category classification, mapping recommendations, and merchant operations.
*
* @author Forward Group, LLC
* @version 1.0
* @since 2025-12-10
*/
package group.goforward.battlbuilder.service;
/**
* Services package for the BattlBuilder application.
* <p>
* Contains business logic service classes for product management,
* category classification, mapping recommendations, and merchant operations.
*
* @author Forward Group, LLC
* @version 1.0
* @since 2025-12-10
*/
package group.goforward.battlbuilder.service;

View File

@@ -1,183 +1,183 @@
package group.goforward.battlbuilder.service.utils;
import group.goforward.battlbuilder.model.Product;
import group.goforward.battlbuilder.repo.ProductRepository;
import io.minio.BucketExistsArgs;
import io.minio.MakeBucketArgs;
import io.minio.MinioClient;
import io.minio.PutObjectArgs;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.io.InputStream;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.Locale;
import java.util.Optional;
@Service
public class ImageUrlToMinioMigrator {
private final ProductRepository productRepository;
private final MinioClient minioClient;
private final String bucket;
private final String publicBaseUrl;
private final HttpClient httpClient = HttpClient.newBuilder()
.followRedirects(HttpClient.Redirect.NORMAL)
.connectTimeout(Duration.ofSeconds(15))
.build();
public ImageUrlToMinioMigrator(ProductRepository productRepository,
MinioClient minioClient,
@Value("${minio.bucket}") String bucket,
@Value("${minio.public-base-url}") String publicBaseUrl) {
this.productRepository = productRepository;
this.minioClient = minioClient;
this.bucket = bucket;
this.publicBaseUrl = trimTrailingSlash(publicBaseUrl);
}
/**
* Migrates Product.mainImageUrl URLs to MinIO and rewrites mainImageUrl to the new location.
*
* @param pageSize batch size for DB paging
* @param dryRun if true: download+upload is skipped and DB is not updated
* @param maxItems optional cap for safety (null = no cap)
* @return count of successfully migrated products
*/
@Transactional
public int migrateMainImages(int pageSize, boolean dryRun, Integer maxItems) {
ensureBucketExists();
int migrated = 0;
int page = 0;
while (true) {
if (maxItems != null && migrated >= maxItems) break;
Page<Product> batch = productRepository
.findNeedingBattlImageUrlMigration(PageRequest.of(page, pageSize));
if (batch.isEmpty()) break;
for (Product p : batch.getContent()) {
if (maxItems != null && migrated >= maxItems) break;
String sourceUrl = p.getMainImageUrl();
if (sourceUrl == null || sourceUrl.isBlank()) continue;
// Extra safety: skip if already set (covers any edge cases outside the query)
if (p.getBattlImageUrl() != null && !p.getBattlImageUrl().trim().isEmpty()) continue;
try {
if (!dryRun) {
String newUrl = uploadFromUrlToMinio(p, sourceUrl);
p.setBattlImageUrl(newUrl);
productRepository.save(p);
}
migrated++;
} catch (Exception ex) {
// fail-soft: continue migrating other products
}
}
if (!batch.hasNext()) break;
page++;
}
return migrated;
}
private String uploadFromUrlToMinio(Product p, String sourceUrl) throws Exception {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(sourceUrl))
.timeout(Duration.ofSeconds(60))
.GET()
.build();
HttpResponse<InputStream> response = httpClient.send(request, HttpResponse.BodyHandlers.ofInputStream());
int status = response.statusCode();
if (status < 200 || status >= 300) {
throw new IllegalStateException("Failed to download image. HTTP " + status + " url=" + sourceUrl);
}
String contentType = response.headers()
.firstValue("content-type")
.map(v -> v.split(";", 2)[0].trim())
.orElse("application/octet-stream");
long contentLength = response.headers()
.firstValue("content-length")
.flatMap(ImageUrlToMinioMigrator::parseLongSafe)
.orElse(-1L);
String ext = extensionForContentType(contentType);
// Store under a stable key; adjust if you want per-merchant, hashed names, etc.
String objectName = "products/" + p.getId() + "/main" + ext;
try (InputStream in = response.body()) {
PutObjectArgs.Builder put = PutObjectArgs.builder()
.bucket(bucket)
.object(objectName)
.contentType(contentType);
if (contentLength >= 0) {
put.stream(in, contentLength, -1);
} else {
put.stream(in, -1, 10L * 1024 * 1024);
}
minioClient.putObject(put.build());
}
return publicBaseUrl + "/" + bucket + "/" + objectName;
}
private void ensureBucketExists() {
try {
boolean exists = minioClient.bucketExists(BucketExistsArgs.builder().bucket(bucket).build());
if (!exists) {
minioClient.makeBucket(MakeBucketArgs.builder().bucket(bucket).build());
}
} catch (Exception e) {
throw new IllegalStateException("Unable to ensure MinIO bucket exists: " + bucket, e);
}
}
private boolean looksAlreadyMigrated(String url) {
String prefix = publicBaseUrl + "/" + bucket + "/";
return url.startsWith(prefix);
}
private static Optional<Long> parseLongSafe(String v) {
try {
return Optional.of(Long.parseLong(v));
} catch (Exception e) {
return Optional.empty();
}
}
private static String extensionForContentType(String contentType) {
String ct = contentType.toLowerCase(Locale.ROOT);
if (ct.equals("image/jpeg") || ct.equals("image/jpg")) return ".jpg";
if (ct.equals("image/png")) return ".png";
if (ct.equals("image/webp")) return ".webp";
if (ct.equals("image/gif")) return ".gif";
if (ct.equals("image/svg+xml")) return ".svg";
return ".bin";
}
private static String trimTrailingSlash(String s) {
if (s == null) return "";
return s.endsWith("/") ? s.substring(0, s.length() - 1) : s;
}
}
package group.goforward.battlbuilder.service.utils;
import group.goforward.battlbuilder.model.Product;
import group.goforward.battlbuilder.repo.ProductRepository;
import io.minio.BucketExistsArgs;
import io.minio.MakeBucketArgs;
import io.minio.MinioClient;
import io.minio.PutObjectArgs;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.io.InputStream;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.Locale;
import java.util.Optional;
@Service
public class ImageUrlToMinioMigrator {
private final ProductRepository productRepository;
private final MinioClient minioClient;
private final String bucket;
private final String publicBaseUrl;
private final HttpClient httpClient = HttpClient.newBuilder()
.followRedirects(HttpClient.Redirect.NORMAL)
.connectTimeout(Duration.ofSeconds(15))
.build();
public ImageUrlToMinioMigrator(ProductRepository productRepository,
MinioClient minioClient,
@Value("${minio.bucket}") String bucket,
@Value("${minio.public-base-url}") String publicBaseUrl) {
this.productRepository = productRepository;
this.minioClient = minioClient;
this.bucket = bucket;
this.publicBaseUrl = trimTrailingSlash(publicBaseUrl);
}
/**
* Migrates Product.mainImageUrl URLs to MinIO and rewrites mainImageUrl to the new location.
*
* @param pageSize batch size for DB paging
* @param dryRun if true: download+upload is skipped and DB is not updated
* @param maxItems optional cap for safety (null = no cap)
* @return count of successfully migrated products
*/
@Transactional
public int migrateMainImages(int pageSize, boolean dryRun, Integer maxItems) {
ensureBucketExists();
int migrated = 0;
int page = 0;
while (true) {
if (maxItems != null && migrated >= maxItems) break;
Page<Product> batch = productRepository
.findNeedingBattlImageUrlMigration(PageRequest.of(page, pageSize));
if (batch.isEmpty()) break;
for (Product p : batch.getContent()) {
if (maxItems != null && migrated >= maxItems) break;
String sourceUrl = p.getMainImageUrl();
if (sourceUrl == null || sourceUrl.isBlank()) continue;
// Extra safety: skip if already set (covers any edge cases outside the query)
if (p.getBattlImageUrl() != null && !p.getBattlImageUrl().trim().isEmpty()) continue;
try {
if (!dryRun) {
String newUrl = uploadFromUrlToMinio(p, sourceUrl);
p.setBattlImageUrl(newUrl);
productRepository.save(p);
}
migrated++;
} catch (Exception ex) {
// fail-soft: continue migrating other products
}
}
if (!batch.hasNext()) break;
page++;
}
return migrated;
}
private String uploadFromUrlToMinio(Product p, String sourceUrl) throws Exception {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(sourceUrl))
.timeout(Duration.ofSeconds(60))
.GET()
.build();
HttpResponse<InputStream> response = httpClient.send(request, HttpResponse.BodyHandlers.ofInputStream());
int status = response.statusCode();
if (status < 200 || status >= 300) {
throw new IllegalStateException("Failed to download image. HTTP " + status + " url=" + sourceUrl);
}
String contentType = response.headers()
.firstValue("content-type")
.map(v -> v.split(";", 2)[0].trim())
.orElse("application/octet-stream");
long contentLength = response.headers()
.firstValue("content-length")
.flatMap(ImageUrlToMinioMigrator::parseLongSafe)
.orElse(-1L);
String ext = extensionForContentType(contentType);
// Store under a stable key; adjust if you want per-merchant, hashed names, etc.
String objectName = "products/" + p.getId() + "/main" + ext;
try (InputStream in = response.body()) {
PutObjectArgs.Builder put = PutObjectArgs.builder()
.bucket(bucket)
.object(objectName)
.contentType(contentType);
if (contentLength >= 0) {
put.stream(in, contentLength, -1);
} else {
put.stream(in, -1, 10L * 1024 * 1024);
}
minioClient.putObject(put.build());
}
return publicBaseUrl + "/" + bucket + "/" + objectName;
}
private void ensureBucketExists() {
try {
boolean exists = minioClient.bucketExists(BucketExistsArgs.builder().bucket(bucket).build());
if (!exists) {
minioClient.makeBucket(MakeBucketArgs.builder().bucket(bucket).build());
}
} catch (Exception e) {
throw new IllegalStateException("Unable to ensure MinIO bucket exists: " + bucket, e);
}
}
private boolean looksAlreadyMigrated(String url) {
String prefix = publicBaseUrl + "/" + bucket + "/";
return url.startsWith(prefix);
}
private static Optional<Long> parseLongSafe(String v) {
try {
return Optional.of(Long.parseLong(v));
} catch (Exception e) {
return Optional.empty();
}
}
private static String extensionForContentType(String contentType) {
String ct = contentType.toLowerCase(Locale.ROOT);
if (ct.equals("image/jpeg") || ct.equals("image/jpg")) return ".jpg";
if (ct.equals("image/png")) return ".png";
if (ct.equals("image/webp")) return ".webp";
if (ct.equals("image/gif")) return ".gif";
if (ct.equals("image/svg+xml")) return ".svg";
return ".bin";
}
private static String trimTrailingSlash(String s) {
if (s == null) return "";
return s.endsWith("/") ? s.substring(0, s.length() - 1) : s;
}
}

View File

@@ -1,28 +1,28 @@
package group.goforward.battlbuilder.service.utils;
import org.springframework.boot.CommandLineRunner;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;
@Component
@Profile("migrate-images-to-minio")
public class MigrateProductImagesToMinioRunner implements CommandLineRunner {
private final ImageUrlToMinioMigrator migrator;
public MigrateProductImagesToMinioRunner(ImageUrlToMinioMigrator migrator) {
this.migrator = migrator;
}
@Override
public void run(String... args) {
// Tune as needed. Start small; you can remove maxItems once you're confident.
int migrated = migrator.migrateMainImages(
200, // pageSize
false, // dryRun
1000 // maxItems safety cap
);
System.out.println("Migrated product images: " + migrated);
}
}
package group.goforward.battlbuilder.service.utils;
import org.springframework.boot.CommandLineRunner;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;
@Component
@Profile("migrate-images-to-minio")
public class MigrateProductImagesToMinioRunner implements CommandLineRunner {
private final ImageUrlToMinioMigrator migrator;
public MigrateProductImagesToMinioRunner(ImageUrlToMinioMigrator migrator) {
this.migrator = migrator;
}
@Override
public void run(String... args) {
// Tune as needed. Start small; you can remove maxItems once you're confident.
int migrated = migrator.migrateMainImages(
200, // pageSize
false, // dryRun
1000 // maxItems safety cap
);
System.out.println("Migrated product images: " + migrated);
}
}

Some files were not shown because too many files have changed in this diff Show More