mirror of
https://gitea.gofwd.group/Forward_Group/ballistic-builder-spring.git
synced 2026-01-20 16:51:03 -05:00
refactored api/products to not be a god class. Added all new endpoints,etc for builds.
This commit is contained in:
@@ -0,0 +1,76 @@
|
|||||||
|
package group.goforward.battlbuilder.controllers;
|
||||||
|
|
||||||
|
import group.goforward.battlbuilder.services.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.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));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,172 +1,56 @@
|
|||||||
package group.goforward.battlbuilder.controllers;
|
package group.goforward.battlbuilder.controllers;
|
||||||
|
|
||||||
import group.goforward.battlbuilder.model.Product;
|
|
||||||
import group.goforward.battlbuilder.model.ProductOffer;
|
|
||||||
import group.goforward.battlbuilder.repos.ProductOfferRepository;
|
|
||||||
import group.goforward.battlbuilder.repos.ProductRepository;
|
|
||||||
import group.goforward.battlbuilder.web.dto.ProductOfferDto;
|
import group.goforward.battlbuilder.web.dto.ProductOfferDto;
|
||||||
import group.goforward.battlbuilder.web.dto.ProductSummaryDto;
|
import group.goforward.battlbuilder.web.dto.ProductSummaryDto;
|
||||||
import group.goforward.battlbuilder.web.mapper.ProductMapper;
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
import org.springframework.cache.annotation.Cacheable;
|
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
import java.util.List;
|
||||||
import java.util.*;
|
|
||||||
import java.util.stream.Collectors;
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LEGACY CONTROLLER (Deprecated)
|
||||||
|
*
|
||||||
|
* Do not add new features here.
|
||||||
|
* Canonical API lives in ProductV1Controller (/api/v1/products).
|
||||||
|
*
|
||||||
|
* This exists only to keep older clients working temporarily.
|
||||||
|
* Disable by default using:
|
||||||
|
* app.api.legacy.enabled=false
|
||||||
|
*
|
||||||
|
* NOTE:
|
||||||
|
* Even when disabled, Spring still compiles this class. So it must not reference
|
||||||
|
* missing services/methods.
|
||||||
|
*/
|
||||||
|
@Deprecated
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/products")
|
@RequestMapping("/api/products")
|
||||||
@CrossOrigin
|
@CrossOrigin
|
||||||
|
@ConditionalOnProperty(name = "app.api.legacy.enabled", havingValue = "true", matchIfMissing = false)
|
||||||
public class ProductController {
|
public class ProductController {
|
||||||
|
|
||||||
private final ProductRepository productRepository;
|
private static final String MSG =
|
||||||
private final ProductOfferRepository productOfferRepository;
|
"Legacy endpoint disabled. Use /api/v1/products instead.";
|
||||||
|
|
||||||
public ProductController(
|
|
||||||
ProductRepository productRepository,
|
|
||||||
ProductOfferRepository productOfferRepository
|
|
||||||
) {
|
|
||||||
this.productRepository = productRepository;
|
|
||||||
this.productOfferRepository = productOfferRepository;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* List products for the builder, filterable by platform + partRoles.
|
|
||||||
*
|
|
||||||
* Examples:
|
|
||||||
* - GET /api/products?platform=AR-15
|
|
||||||
* - GET /api/products?platform=AR-15&partRoles=upper-receiver&partRoles=upper
|
|
||||||
* - GET /api/products?platform=ALL (no platform filter)
|
|
||||||
* - GET /api/products?platform=ALL&partRoles=magazine
|
|
||||||
*/
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
@Cacheable(
|
public ResponseEntity<?> getProducts(
|
||||||
value = "gunbuilderProducts",
|
|
||||||
key = "#platform + '::' + (#partRoles == null ? 'ALL' : #partRoles.toString())"
|
|
||||||
)
|
|
||||||
public List<ProductSummaryDto> getProducts(
|
|
||||||
@RequestParam(defaultValue = "AR-15") String platform,
|
@RequestParam(defaultValue = "AR-15") String platform,
|
||||||
@RequestParam(required = false, name = "partRoles") List<String> partRoles
|
@RequestParam(required = false, name = "partRoles") List<String> partRoles
|
||||||
) {
|
) {
|
||||||
final boolean allPlatforms = platform != null && platform.equalsIgnoreCase("ALL");
|
// Legacy disabled by design (Option B cleanup)
|
||||||
|
return ResponseEntity.status(410).body(MSG);
|
||||||
long started = System.currentTimeMillis();
|
|
||||||
System.out.println("getProducts: start, platform=" + platform +
|
|
||||||
", allPlatforms=" + allPlatforms +
|
|
||||||
", partRoles=" + (partRoles == null ? "null" : partRoles));
|
|
||||||
|
|
||||||
// 1) Load products (with brand pre-fetched)
|
|
||||||
long tProductsStart = System.currentTimeMillis();
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
long tProductsEnd = System.currentTimeMillis();
|
|
||||||
System.out.println("getProducts: loaded products: " +
|
|
||||||
products.size() + " in " + (tProductsEnd - tProductsStart) + " ms");
|
|
||||||
|
|
||||||
if (products.isEmpty()) {
|
|
||||||
long took = System.currentTimeMillis() - started;
|
|
||||||
System.out.println("getProducts: 0 products in " + took + " ms");
|
|
||||||
return List.of();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2) Load offers for these product IDs
|
|
||||||
long tOffersStart = System.currentTimeMillis();
|
|
||||||
List<Integer> productIds = products.stream()
|
|
||||||
.map(Product::getId)
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
List<ProductOffer> allOffers =
|
|
||||||
productOfferRepository.findByProductIdIn(productIds);
|
|
||||||
|
|
||||||
long tOffersEnd = System.currentTimeMillis();
|
|
||||||
System.out.println("getProducts: loaded offers: " +
|
|
||||||
allOffers.size() + " in " + (tOffersEnd - tOffersStart) + " ms");
|
|
||||||
|
|
||||||
Map<Integer, List<ProductOffer>> offersByProductId = allOffers.stream()
|
|
||||||
.collect(Collectors.groupingBy(o -> o.getProduct().getId()));
|
|
||||||
|
|
||||||
// 3) Map to DTOs with price and buyUrl
|
|
||||||
long tMapStart = System.currentTimeMillis();
|
|
||||||
List<ProductSummaryDto> result = 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();
|
|
||||||
|
|
||||||
long tMapEnd = System.currentTimeMillis();
|
|
||||||
long took = System.currentTimeMillis() - started;
|
|
||||||
|
|
||||||
System.out.println("getProducts: mapping to DTOs took " +
|
|
||||||
(tMapEnd - tMapStart) + " ms");
|
|
||||||
System.out.println("getProducts: TOTAL " + took + " ms (" +
|
|
||||||
"products=" + (tProductsEnd - tProductsStart) + " ms, " +
|
|
||||||
"offers=" + (tOffersEnd - tOffersStart) + " ms, " +
|
|
||||||
"map=" + (tMapEnd - tMapStart) + " ms)");
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/{id}/offers")
|
@GetMapping("/{id}/offers")
|
||||||
public List<ProductOfferDto> getOffersForProduct(@PathVariable("id") Integer productId) {
|
public ResponseEntity<?> getOffersForProduct(@PathVariable("id") Integer productId) {
|
||||||
List<ProductOffer> offers = productOfferRepository.findByProductId(productId);
|
return ResponseEntity.status(410).body(MSG);
|
||||||
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
|
|
||||||
private ProductOffer pickBestOffer(List<ProductOffer> offers) {
|
|
||||||
if (offers == null || offers.isEmpty()) return null;
|
|
||||||
|
|
||||||
// Right now: lowest price wins, regardless of stock
|
|
||||||
return offers.stream()
|
|
||||||
.filter(o -> o.getEffectivePrice() != null)
|
|
||||||
.min(Comparator.comparing(ProductOffer::getEffectivePrice))
|
|
||||||
.orElse(null);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/{id}")
|
@GetMapping("/{id}")
|
||||||
public ResponseEntity<ProductSummaryDto> getProductById(@PathVariable("id") Integer productId) {
|
public ResponseEntity<?> getProductById(@PathVariable("id") Integer productId) {
|
||||||
return productRepository.findById(productId)
|
return ResponseEntity.status(410).body(MSG);
|
||||||
.map(product -> {
|
|
||||||
List<ProductOffer> offers = productOfferRepository.findByProductId(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);
|
|
||||||
})
|
|
||||||
.map(ResponseEntity::ok)
|
|
||||||
.orElseGet(() -> ResponseEntity.notFound().build());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If you *really* need typed responses for an old client, we can re-add
|
||||||
|
// a real service layer once we align on the actual ProductQueryService API.
|
||||||
}
|
}
|
||||||
@@ -1,703 +1,45 @@
|
|||||||
package group.goforward.battlbuilder.controllers;
|
package group.goforward.battlbuilder.controllers;
|
||||||
|
|
||||||
import group.goforward.battlbuilder.model.Build;
|
import group.goforward.battlbuilder.services.ProductQueryService;
|
||||||
import group.goforward.battlbuilder.model.BuildItem;
|
|
||||||
import group.goforward.battlbuilder.model.Product;
|
|
||||||
import group.goforward.battlbuilder.model.ProductOffer;
|
|
||||||
import group.goforward.battlbuilder.repos.BuildItemRepository;
|
|
||||||
import group.goforward.battlbuilder.repos.BuildRepository;
|
|
||||||
import group.goforward.battlbuilder.repos.ProductOfferRepository;
|
|
||||||
import group.goforward.battlbuilder.repos.ProductRepository;
|
|
||||||
import group.goforward.battlbuilder.web.dto.BuildCreateRequest;
|
|
||||||
import group.goforward.battlbuilder.web.dto.BuildDto;
|
|
||||||
import group.goforward.battlbuilder.web.dto.BuildItemDto;
|
|
||||||
import group.goforward.battlbuilder.web.dto.ProductDto;
|
|
||||||
import group.goforward.battlbuilder.web.dto.ProductOfferDto;
|
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.http.ResponseEntity;
|
||||||
import org.springframework.security.core.Authentication;
|
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
import java.util.*;
|
import java.util.List;
|
||||||
import java.util.stream.Collectors;
|
|
||||||
|
|
||||||
@CrossOrigin
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/v1/products")
|
@RequestMapping("/api/v1/products")
|
||||||
|
@CrossOrigin
|
||||||
public class ProductV1Controller {
|
public class ProductV1Controller {
|
||||||
|
|
||||||
private final ProductRepository productRepository;
|
private final ProductQueryService productQueryService;
|
||||||
private final ProductOfferRepository productOfferRepository;
|
|
||||||
|
|
||||||
// ✅ Builds
|
public ProductV1Controller(ProductQueryService productQueryService) {
|
||||||
private final BuildRepository buildRepository;
|
this.productQueryService = productQueryService;
|
||||||
private final BuildItemRepository buildItemRepository;
|
|
||||||
|
|
||||||
public ProductV1Controller(
|
|
||||||
ProductRepository productRepository,
|
|
||||||
ProductOfferRepository productOfferRepository,
|
|
||||||
BuildRepository buildRepository,
|
|
||||||
BuildItemRepository buildItemRepository
|
|
||||||
) {
|
|
||||||
this.productRepository = productRepository;
|
|
||||||
this.productOfferRepository = productOfferRepository;
|
|
||||||
this.buildRepository = buildRepository;
|
|
||||||
this.buildItemRepository = buildItemRepository;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// =========================================================================
|
|
||||||
// PRODUCTS
|
|
||||||
// =========================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* List products (v1 summary contract)
|
|
||||||
* Keep this lightweight for grids/lists.
|
|
||||||
*/
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
public List<ProductDto> listProducts(
|
@Cacheable(
|
||||||
@RequestParam(name = "platform", required = false) String platform,
|
value = "gunbuilderProductsV1",
|
||||||
@RequestParam(name = "partRoles", required = false) List<String> partRoles
|
key = "#platform + '::' + (#partRoles == null ? 'ALL' : #partRoles.toString())"
|
||||||
|
)
|
||||||
|
public List<ProductSummaryDto> getProducts(
|
||||||
|
@RequestParam(defaultValue = "AR-15") String platform,
|
||||||
|
@RequestParam(required = false, name = "partRoles") List<String> partRoles
|
||||||
) {
|
) {
|
||||||
boolean allPlatforms =
|
return productQueryService.getProducts(platform, partRoles);
|
||||||
(platform == null || platform.isBlank() || platform.equalsIgnoreCase("ALL"));
|
|
||||||
|
|
||||||
List<Product> products;
|
|
||||||
|
|
||||||
if (partRoles == null || partRoles.isEmpty()) {
|
|
||||||
products = allPlatforms
|
|
||||||
? productRepository.findAllWithBrand()
|
|
||||||
: productRepository.findByPlatformWithBrand(platform);
|
|
||||||
} else {
|
|
||||||
List<String> roles = partRoles.stream()
|
|
||||||
.filter(Objects::nonNull)
|
|
||||||
.map(String::trim)
|
|
||||||
.filter(s -> !s.isEmpty())
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
if (roles.isEmpty()) {
|
|
||||||
products = allPlatforms
|
|
||||||
? productRepository.findAllWithBrand()
|
|
||||||
: productRepository.findByPlatformWithBrand(platform);
|
|
||||||
} else {
|
|
||||||
products = allPlatforms
|
|
||||||
? productRepository.findByPartRoleInWithBrand(roles)
|
|
||||||
: productRepository.findByPlatformAndPartRoleInWithBrand(platform, roles);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (products.isEmpty()) return List.of();
|
@GetMapping("/{id}/offers")
|
||||||
|
public List<ProductOfferDto> getOffersForProduct(@PathVariable("id") Integer productId) {
|
||||||
List<Integer> productIds = products.stream().map(Product::getId).toList();
|
return productQueryService.getOffersForProduct(productId);
|
||||||
List<ProductOffer> offers = productOfferRepository.findByProductIdIn(productIds);
|
|
||||||
|
|
||||||
Map<Integer, List<ProductOffer>> offersByProductId = offers.stream()
|
|
||||||
.collect(Collectors.groupingBy(o -> o.getProduct().getId()));
|
|
||||||
|
|
||||||
return products.stream()
|
|
||||||
.map(p -> {
|
|
||||||
ProductOffer best = pickBestOffer(offersByProductId.get(p.getId()));
|
|
||||||
return toProductDtoSummary(p, best);
|
|
||||||
})
|
|
||||||
.toList();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Product details (v1 contract)
|
|
||||||
* This endpoint is allowed to be "fat" for the details page.
|
|
||||||
*/
|
|
||||||
@GetMapping("/{id}")
|
@GetMapping("/{id}")
|
||||||
public ResponseEntity<ProductDto> getProduct(@PathVariable("id") String id) {
|
public ResponseEntity<ProductSummaryDto> getProductById(@PathVariable("id") Integer productId) {
|
||||||
Integer productId = parsePositiveInt(id);
|
ProductSummaryDto dto = productQueryService.getProductById(productId);
|
||||||
if (productId == null) return ResponseEntity.badRequest().build();
|
return dto != null ? ResponseEntity.ok(dto) : ResponseEntity.notFound().build();
|
||||||
|
|
||||||
Optional<Product> productOpt = productRepository.findById(productId);
|
|
||||||
if (productOpt.isEmpty()) return ResponseEntity.notFound().build();
|
|
||||||
|
|
||||||
Product product = productOpt.get();
|
|
||||||
|
|
||||||
// Pull offers + merchant
|
|
||||||
List<ProductOffer> offers = productOfferRepository.findByProductId(productId);
|
|
||||||
ProductOffer best = pickBestOffer(offers);
|
|
||||||
|
|
||||||
ProductDto dto = toProductDtoDetails(product, best, offers);
|
|
||||||
return ResponseEntity.ok(dto);
|
|
||||||
}
|
|
||||||
|
|
||||||
// =========================================================================
|
|
||||||
// ME BUILDS (TEMP HOME INSIDE ProductV1Controller)
|
|
||||||
// NOTE: Routes become /api/v1/products/me/builds (works now, we can refactor later)
|
|
||||||
// =========================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* MAIN FUNCTIONALITY:
|
|
||||||
* Create/save a build for the current logged-in user.
|
|
||||||
*
|
|
||||||
* This expects resolveUserId(auth) to return an Integer userId.
|
|
||||||
*/
|
|
||||||
@PostMapping("/me/builds")
|
|
||||||
public ResponseEntity<BuildDto> createBuild(
|
|
||||||
Authentication auth,
|
|
||||||
@RequestBody BuildCreateRequest req
|
|
||||||
) {
|
|
||||||
Integer userId = resolveUserId(auth);
|
|
||||||
if (userId == null) return ResponseEntity.status(401).build();
|
|
||||||
|
|
||||||
if (req == null || req.getTitle() == null || req.getTitle().isBlank()) {
|
|
||||||
return ResponseEntity.badRequest().build();
|
|
||||||
}
|
|
||||||
|
|
||||||
Build b = new Build();
|
|
||||||
b.setUserId(userId);
|
|
||||||
b.setTitle(req.getTitle().trim());
|
|
||||||
b.setDescription(req.getDescription());
|
|
||||||
b.setIsPublic(req.getIsPublic() != null && req.getIsPublic());
|
|
||||||
|
|
||||||
b = buildRepository.save(b);
|
|
||||||
|
|
||||||
// Save items
|
|
||||||
List<BuildCreateRequest.BuildItemCreateRequest> items =
|
|
||||||
req.getItems() != null ? req.getItems() : List.of();
|
|
||||||
|
|
||||||
// Prefetch products in one pass (avoid N+1)
|
|
||||||
Set<Integer> productIds = items.stream()
|
|
||||||
.map(BuildCreateRequest.BuildItemCreateRequest::getProductId)
|
|
||||||
.filter(Objects::nonNull)
|
|
||||||
.collect(Collectors.toSet());
|
|
||||||
|
|
||||||
Map<Integer, Product> productsById = productIds.isEmpty()
|
|
||||||
? Map.of()
|
|
||||||
: productRepository.findAllById(productIds).stream()
|
|
||||||
.collect(Collectors.toMap(Product::getId, p -> p));
|
|
||||||
|
|
||||||
for (BuildCreateRequest.BuildItemCreateRequest it : items) {
|
|
||||||
if (it == null) continue;
|
|
||||||
if (it.getProductId() == null) continue;
|
|
||||||
if (it.getSlot() == null || it.getSlot().isBlank()) continue;
|
|
||||||
|
|
||||||
Product p = productsById.get(it.getProductId());
|
|
||||||
if (p == null) continue;
|
|
||||||
|
|
||||||
BuildItem bi = new BuildItem();
|
|
||||||
bi.setBuild(b);
|
|
||||||
bi.setProduct(p);
|
|
||||||
bi.setSlot(it.getSlot().trim());
|
|
||||||
bi.setPosition(it.getPosition() != null ? it.getPosition() : 0);
|
|
||||||
bi.setQuantity(it.getQuantity() != null ? it.getQuantity() : 1);
|
|
||||||
|
|
||||||
buildItemRepository.save(bi);
|
|
||||||
}
|
|
||||||
|
|
||||||
List<BuildItem> savedItems = buildItemRepository.findByBuild_Id(b.getId());
|
|
||||||
return ResponseEntity.ok(toBuildDto(b, savedItems));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* MAIN FUNCTIONALITY:
|
|
||||||
* List builds for the current user (lightweight list; no items).
|
|
||||||
*
|
|
||||||
* NOTE: For performance, you should add a real repo method later like:
|
|
||||||
* findByUserIdAndDeletedAtIsNullOrderByUpdatedAtDesc(userId)
|
|
||||||
*/
|
|
||||||
@GetMapping("/me/builds")
|
|
||||||
public ResponseEntity<List<BuildDto>> listMyBuilds(Authentication auth) {
|
|
||||||
Integer userId = resolveUserId(auth);
|
|
||||||
if (userId == null) return ResponseEntity.status(401).build();
|
|
||||||
|
|
||||||
List<Build> mine = buildRepository.findByUserIdAndDeletedAtIsNullOrderByUpdatedAtDesc(userId);
|
|
||||||
return ResponseEntity.ok(mine.stream().map(this::toBuildDtoNoItems).toList());
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@PutMapping("/me/builds/{uuid}")
|
|
||||||
public ResponseEntity<BuildDto> replaceBuildItems(
|
|
||||||
Authentication auth,
|
|
||||||
@PathVariable("uuid") String uuidRaw,
|
|
||||||
@RequestBody BuildCreateRequest req
|
|
||||||
) {
|
|
||||||
Integer userId = resolveUserId(auth);
|
|
||||||
if (userId == null) return ResponseEntity.status(401).build();
|
|
||||||
|
|
||||||
UUID uuid;
|
|
||||||
try {
|
|
||||||
uuid = UUID.fromString(uuidRaw);
|
|
||||||
} catch (Exception e) {
|
|
||||||
return ResponseEntity.badRequest().build();
|
|
||||||
}
|
|
||||||
|
|
||||||
Optional<Build> opt = buildRepository.findByUuid(uuid);
|
|
||||||
if (opt.isEmpty()) return ResponseEntity.notFound().build();
|
|
||||||
|
|
||||||
Build b = opt.get();
|
|
||||||
if (b.getDeletedAt() != null) return ResponseEntity.notFound().build();
|
|
||||||
|
|
||||||
// ✅ Owner check
|
|
||||||
if (!Objects.equals(b.getUserId(), userId)) {
|
|
||||||
return ResponseEntity.status(403).build();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Optional: allow updating build metadata too
|
|
||||||
if (req != null) {
|
|
||||||
if (req.getTitle() != null && !req.getTitle().isBlank()) {
|
|
||||||
b.setTitle(req.getTitle().trim());
|
|
||||||
}
|
|
||||||
b.setDescription(req.getDescription());
|
|
||||||
if (req.getIsPublic() != null) b.setIsPublic(req.getIsPublic());
|
|
||||||
b = buildRepository.save(b);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ✅ Replace items: delete then insert
|
|
||||||
buildItemRepository.deleteByBuild_Id(b.getId());
|
|
||||||
|
|
||||||
List<BuildCreateRequest.BuildItemCreateRequest> items =
|
|
||||||
(req != null && req.getItems() != null) ? req.getItems() : List.of();
|
|
||||||
|
|
||||||
Set<Integer> productIds = items.stream()
|
|
||||||
.map(BuildCreateRequest.BuildItemCreateRequest::getProductId)
|
|
||||||
.filter(Objects::nonNull)
|
|
||||||
.collect(Collectors.toSet());
|
|
||||||
|
|
||||||
Map<Integer, Product> productsById = productIds.isEmpty()
|
|
||||||
? Map.of()
|
|
||||||
: productRepository.findAllById(productIds).stream()
|
|
||||||
.collect(Collectors.toMap(Product::getId, p -> p));
|
|
||||||
|
|
||||||
for (BuildCreateRequest.BuildItemCreateRequest it : items) {
|
|
||||||
if (it == null) continue;
|
|
||||||
if (it.getProductId() == null) continue;
|
|
||||||
if (it.getSlot() == null || it.getSlot().isBlank()) continue;
|
|
||||||
|
|
||||||
Product p = productsById.get(it.getProductId());
|
|
||||||
if (p == null) continue;
|
|
||||||
|
|
||||||
BuildItem bi = new BuildItem();
|
|
||||||
bi.setBuild(b);
|
|
||||||
bi.setProduct(p);
|
|
||||||
bi.setSlot(it.getSlot().trim());
|
|
||||||
bi.setPosition(it.getPosition() != null ? it.getPosition() : 0);
|
|
||||||
bi.setQuantity(it.getQuantity() != null ? it.getQuantity() : 1);
|
|
||||||
|
|
||||||
// IMPORTANT if your BuildItem doesn't have @PrePersist yet:
|
|
||||||
// bi.setCreatedAt(OffsetDateTime.now());
|
|
||||||
|
|
||||||
buildItemRepository.save(bi);
|
|
||||||
}
|
|
||||||
|
|
||||||
List<BuildItem> savedItems = buildItemRepository.findByBuild_Id(b.getId());
|
|
||||||
return ResponseEntity.ok(toBuildDto(b, savedItems));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* MAIN FUNCTIONALITY:
|
|
||||||
* Load build by UUID.
|
|
||||||
* - Owner can see it
|
|
||||||
* - Non-owner can only see if is_public = true
|
|
||||||
*/
|
|
||||||
@GetMapping("/builds/{uuid}")
|
|
||||||
public ResponseEntity<BuildDto> getBuildByUuid(
|
|
||||||
Authentication auth,
|
|
||||||
@PathVariable("uuid") String uuidRaw
|
|
||||||
) {
|
|
||||||
UUID uuid;
|
|
||||||
try {
|
|
||||||
uuid = UUID.fromString(uuidRaw);
|
|
||||||
} catch (Exception e) {
|
|
||||||
return ResponseEntity.badRequest().build();
|
|
||||||
}
|
|
||||||
|
|
||||||
Optional<Build> opt = buildRepository.findByUuid(uuid);
|
|
||||||
if (opt.isEmpty()) return ResponseEntity.notFound().build();
|
|
||||||
|
|
||||||
Build b = opt.get();
|
|
||||||
if (b.getDeletedAt() != null) return ResponseEntity.notFound().build();
|
|
||||||
|
|
||||||
Integer userId = resolveUserId(auth);
|
|
||||||
boolean isOwner = userId != null && Objects.equals(b.getUserId(), userId);
|
|
||||||
boolean isPublic = Boolean.TRUE.equals(b.getIsPublic());
|
|
||||||
|
|
||||||
if (!isOwner && !isPublic) return ResponseEntity.status(403).build();
|
|
||||||
|
|
||||||
List<BuildItem> items = buildItemRepository.findByBuild_Uuid(uuid);
|
|
||||||
return ResponseEntity.ok(toBuildDto(b, items));
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// =========================================================================
|
|
||||||
// HELPERS — Products
|
|
||||||
// =========================================================================
|
|
||||||
|
|
||||||
private static Integer parsePositiveInt(String raw) {
|
|
||||||
try {
|
|
||||||
int n = Integer.parseInt(raw);
|
|
||||||
return n > 0 ? n : null;
|
|
||||||
} catch (Exception e) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* MVP: "best" means lowest effective price.
|
|
||||||
* (You can later enhance this to prefer in-stock, then price, etc.)
|
|
||||||
*/
|
|
||||||
private ProductOffer pickBestOffer(List<ProductOffer> offers) {
|
|
||||||
if (offers == null || offers.isEmpty()) return null;
|
|
||||||
|
|
||||||
return offers.stream()
|
|
||||||
.filter(o -> o.getEffectivePrice() != null)
|
|
||||||
.min(Comparator.comparing(ProductOffer::getEffectivePrice))
|
|
||||||
.orElse(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Summary DTO for list/grid pages (keep small).
|
|
||||||
*/
|
|
||||||
private ProductDto toProductDtoSummary(Product p, ProductOffer bestOffer) {
|
|
||||||
ProductDto dto = new ProductDto();
|
|
||||||
|
|
||||||
dto.setId(String.valueOf(p.getId()));
|
|
||||||
dto.setName(p.getName());
|
|
||||||
dto.setBrand(p.getBrand() != null ? p.getBrand().getName() : null);
|
|
||||||
dto.setPlatform(p.getPlatform());
|
|
||||||
dto.setPartRole(p.getPartRole());
|
|
||||||
dto.setCategoryKey(p.getRawCategoryKey());
|
|
||||||
|
|
||||||
dto.setPrice(bestOffer != null ? bestOffer.getEffectivePrice() : null);
|
|
||||||
dto.setBuyUrl(bestOffer != null ? bestOffer.getBuyUrl() : null);
|
|
||||||
dto.setInStock(bestOffer != null ? bestOffer.getInStock() : null);
|
|
||||||
|
|
||||||
dto.setImageUrl(p.getMainImageUrl());
|
|
||||||
return dto;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Details DTO for product page (returns richer fields + offers table)
|
|
||||||
*/
|
|
||||||
private ProductDto toProductDtoDetails(Product p, ProductOffer bestOffer, List<ProductOffer> offers) {
|
|
||||||
ProductDto dto = new ProductDto();
|
|
||||||
|
|
||||||
dto.setId(String.valueOf(p.getId()));
|
|
||||||
dto.setName(p.getName());
|
|
||||||
dto.setBrand(p.getBrand() != null ? p.getBrand().getName() : null);
|
|
||||||
dto.setPlatform(p.getPlatform());
|
|
||||||
dto.setPartRole(p.getPartRole());
|
|
||||||
dto.setCategoryKey(p.getRawCategoryKey());
|
|
||||||
|
|
||||||
dto.setPrice(bestOffer != null ? bestOffer.getEffectivePrice() : null);
|
|
||||||
dto.setBuyUrl(bestOffer != null ? bestOffer.getBuyUrl() : null);
|
|
||||||
dto.setInStock(bestOffer != null ? bestOffer.getInStock() : null);
|
|
||||||
|
|
||||||
dto.setImageUrl(p.getMainImageUrl());
|
|
||||||
dto.setMainImageUrl(p.getMainImageUrl());
|
|
||||||
dto.setBattlImageUrl(p.getBattlImageUrl());
|
|
||||||
|
|
||||||
dto.setSlug(p.getSlug());
|
|
||||||
dto.setMpn(p.getMpn());
|
|
||||||
dto.setUpc(p.getUpc());
|
|
||||||
dto.setConfiguration(p.getConfiguration() != null ? p.getConfiguration().name() : null);
|
|
||||||
dto.setPlatformLocked(p.getPlatformLocked());
|
|
||||||
|
|
||||||
String normalized = normalizeDescription(p.getDescription());
|
|
||||||
dto.setDescription(normalized);
|
|
||||||
|
|
||||||
String shortDesc = p.getShortDescription();
|
|
||||||
if (shortDesc == null || shortDesc.isBlank()) {
|
|
||||||
shortDesc = deriveShortDescription(normalized);
|
|
||||||
}
|
|
||||||
dto.setShortDescription(shortDesc);
|
|
||||||
|
|
||||||
List<ProductOfferDto> offerDtos = (offers == null ? List.<ProductOffer>of() : offers)
|
|
||||||
.stream()
|
|
||||||
.map(this::toOfferDto)
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
dto.setOffers(offerDtos);
|
|
||||||
return dto;
|
|
||||||
}
|
|
||||||
|
|
||||||
private ProductOfferDto toOfferDto(ProductOffer o) {
|
|
||||||
ProductOfferDto dto = new ProductOfferDto();
|
|
||||||
|
|
||||||
dto.setId(String.valueOf(o.getId()));
|
|
||||||
dto.setMerchantName(o.getMerchant() != null ? o.getMerchant().getName() : null);
|
|
||||||
dto.setPrice(o.getPrice());
|
|
||||||
dto.setOriginalPrice(o.getOriginalPrice());
|
|
||||||
dto.setInStock(Boolean.TRUE.equals(o.getInStock()));
|
|
||||||
dto.setBuyUrl(o.getBuyUrl());
|
|
||||||
|
|
||||||
// IMPORTANT: map lastSeenAt → lastUpdated
|
|
||||||
dto.setLastUpdated(o.getLastSeenAt());
|
|
||||||
|
|
||||||
return dto;
|
|
||||||
}
|
|
||||||
|
|
||||||
// =========================================================================
|
|
||||||
// HELPERS — Description normalization (feed -> readable bullets)
|
|
||||||
// =========================================================================
|
|
||||||
|
|
||||||
private static String normalizeDescription(String raw) {
|
|
||||||
if (raw == null) return null;
|
|
||||||
|
|
||||||
String s = raw.trim();
|
|
||||||
if (s.isEmpty()) return null;
|
|
||||||
|
|
||||||
s = s.replace("\r\n", "\n").replace("\r", "\n");
|
|
||||||
s = s.replace("\t", " ");
|
|
||||||
s = collapseSpaces(s);
|
|
||||||
|
|
||||||
s = unglueHeaders(s);
|
|
||||||
s = normalizeSectionHeaders(s);
|
|
||||||
s = bulletizeContent(s);
|
|
||||||
|
|
||||||
int MAX = 8000;
|
|
||||||
if (s.length() > MAX) {
|
|
||||||
s = s.substring(0, MAX).trim() + "…";
|
|
||||||
}
|
|
||||||
|
|
||||||
return s.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String deriveShortDescription(String normalizedDescription) {
|
|
||||||
if (normalizedDescription == null || normalizedDescription.isBlank()) return null;
|
|
||||||
|
|
||||||
String[] lines = normalizedDescription.split("\n");
|
|
||||||
for (String line : lines) {
|
|
||||||
String t = line.trim();
|
|
||||||
if (t.isEmpty()) continue;
|
|
||||||
if (isHeaderLine(t)) continue;
|
|
||||||
|
|
||||||
if (t.startsWith("- ")) t = t.substring(2).trim();
|
|
||||||
|
|
||||||
int MAX = 220;
|
|
||||||
if (t.length() > MAX) t = t.substring(0, MAX).trim() + "…";
|
|
||||||
return t;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static boolean isHeaderLine(String line) {
|
|
||||||
String l = line.toLowerCase(Locale.ROOT).trim();
|
|
||||||
return l.equals("features")
|
|
||||||
|| l.equals("specifications")
|
|
||||||
|| l.equals("specs")
|
|
||||||
|| l.equals("includes")
|
|
||||||
|| l.equals("notes")
|
|
||||||
|| l.equals("overview");
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String collapseSpaces(String s) {
|
|
||||||
return s.replaceAll("[ ]{2,}", " ");
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String unglueHeaders(String s) {
|
|
||||||
String[] headers = new String[]{"Features", "Specifications", "Specs", "Includes", "Overview", "Notes"};
|
|
||||||
for (String h : headers) {
|
|
||||||
s = s.replaceAll("(?i)\\b" + h + "(?=[A-Za-z0-9])", h + "\n");
|
|
||||||
}
|
|
||||||
return s;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String normalizeSectionHeaders(String s) {
|
|
||||||
s = s.replaceAll("(?i)\\s*(\\bFeatures\\b)\\s*", "\n\nFeatures\n");
|
|
||||||
s = s.replaceAll("(?i)\\s*(\\bSpecifications\\b|\\bSpecs\\b)\\s*", "\n\nSpecifications\n");
|
|
||||||
s = s.replaceAll("(?i)\\s*(\\bIncludes\\b)\\s*", "\n\nIncludes\n");
|
|
||||||
s = s.replaceAll("(?i)\\s*(\\bOverview\\b)\\s*", "\n\nOverview\n");
|
|
||||||
s = s.replaceAll("(?i)\\s*(\\bNotes\\b)\\s*", "\n\nNotes\n");
|
|
||||||
s = s.replaceAll("\\n{3,}", "\n\n");
|
|
||||||
return s.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String bulletizeContent(String s) {
|
|
||||||
String[] lines = s.split("\n");
|
|
||||||
StringBuilder out = new StringBuilder();
|
|
||||||
|
|
||||||
for (String line : lines) {
|
|
||||||
String t = line.trim();
|
|
||||||
if (t.isEmpty()) {
|
|
||||||
out.append("\n");
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isHeaderLine(t)) {
|
|
||||||
out.append(t).append("\n");
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (t.startsWith("- ") || t.startsWith("• ")) {
|
|
||||||
out.append(t.startsWith("• ") ? "- " + t.substring(2).trim() : t).append("\n");
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
List<String> parts = splitOnSeparators(t);
|
|
||||||
if (parts.size() == 1) {
|
|
||||||
parts = splitHeuristic(parts.get(0));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (parts.size() > 1) {
|
|
||||||
for (String p : parts) {
|
|
||||||
String bp = p.trim();
|
|
||||||
if (bp.isEmpty()) continue;
|
|
||||||
out.append("- ").append(bp).append("\n");
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
out.append(t).append("\n");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return out.toString().replaceAll("\\n{3,}", "\n\n").trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static List<String> splitOnSeparators(String line) {
|
|
||||||
String normalized = line.replace("•", "|");
|
|
||||||
String[] raw = normalized.split("\\s*[;|]\\s*");
|
|
||||||
List<String> parts = new ArrayList<>();
|
|
||||||
for (String r : raw) {
|
|
||||||
String t = r.trim();
|
|
||||||
if (!t.isEmpty()) parts.add(t);
|
|
||||||
}
|
|
||||||
return parts;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static List<String> splitHeuristic(String line) {
|
|
||||||
String t = line.trim();
|
|
||||||
if (t.length() < 180) return List.of(t);
|
|
||||||
|
|
||||||
List<String> parts = new ArrayList<>();
|
|
||||||
String[] chunks = t.split("(?<!\\d)\\.\\s+"); // avoid splitting 17.5
|
|
||||||
for (String c : chunks) {
|
|
||||||
String s = c.trim();
|
|
||||||
if (s.isEmpty()) continue;
|
|
||||||
if (s.contains(" - ")) {
|
|
||||||
for (String sub : s.split("\\s+-\\s+")) {
|
|
||||||
String ss = sub.trim();
|
|
||||||
if (!ss.isEmpty()) parts.add(ss);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
parts.add(s);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (parts.size() <= 1) return List.of(t);
|
|
||||||
|
|
||||||
int MAX_BULLETS = 18;
|
|
||||||
if (parts.size() > MAX_BULLETS) {
|
|
||||||
parts = parts.subList(0, MAX_BULLETS);
|
|
||||||
parts.add("…");
|
|
||||||
}
|
|
||||||
|
|
||||||
return parts;
|
|
||||||
}
|
|
||||||
|
|
||||||
// =========================================================================
|
|
||||||
// HELPERS — Build DTO mapping + auth user resolution
|
|
||||||
// =========================================================================
|
|
||||||
|
|
||||||
private BuildDto toBuildDtoNoItems(Build b) {
|
|
||||||
BuildDto dto = new BuildDto();
|
|
||||||
dto.setId(String.valueOf(b.getId()));
|
|
||||||
dto.setUuid(b.getUuid());
|
|
||||||
dto.setTitle(b.getTitle());
|
|
||||||
dto.setDescription(b.getDescription());
|
|
||||||
dto.setIsPublic(b.getIsPublic());
|
|
||||||
dto.setCreatedAt(b.getCreatedAt());
|
|
||||||
dto.setUpdatedAt(b.getUpdatedAt());
|
|
||||||
dto.setItems(null);
|
|
||||||
return dto;
|
|
||||||
}
|
|
||||||
|
|
||||||
private BuildDto toBuildDto(Build b, List<BuildItem> items) {
|
|
||||||
BuildDto dto = toBuildDtoNoItems(b);
|
|
||||||
|
|
||||||
List<BuildItemDto> itemDtos = (items == null ? List.<BuildItem>of() : items).stream()
|
|
||||||
.filter(Objects::nonNull)
|
|
||||||
.sorted(Comparator.comparing(BuildItem::getSlot)
|
|
||||||
.thenComparing(BuildItem::getPosition))
|
|
||||||
.map(this::toBuildItemDto)
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
dto.setItems(itemDtos);
|
|
||||||
return dto;
|
|
||||||
}
|
|
||||||
|
|
||||||
private BuildItemDto toBuildItemDto(BuildItem bi) {
|
|
||||||
BuildItemDto dto = new BuildItemDto();
|
|
||||||
dto.setId(String.valueOf(bi.getId()));
|
|
||||||
dto.setUuid(bi.getUuid());
|
|
||||||
dto.setSlot(bi.getSlot());
|
|
||||||
dto.setPosition(bi.getPosition());
|
|
||||||
dto.setQuantity(bi.getQuantity());
|
|
||||||
|
|
||||||
Product p = bi.getProduct();
|
|
||||||
if (p != null) {
|
|
||||||
dto.setProductId(String.valueOf(p.getId()));
|
|
||||||
dto.setProductName(p.getName());
|
|
||||||
dto.setProductBrand(p.getBrand() != null ? p.getBrand().getName() : null);
|
|
||||||
dto.setProductImageUrl(p.getBattlImageUrl() != null ? p.getBattlImageUrl() : p.getMainImageUrl());
|
|
||||||
}
|
|
||||||
|
|
||||||
return dto;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* MAIN FUNCTIONALITY (TEMP):
|
|
||||||
* Resolve the logged-in user's id.
|
|
||||||
*
|
|
||||||
* Replace with your real principal logic.
|
|
||||||
*
|
|
||||||
* Common options:
|
|
||||||
* 1) auth.getName() returns email -> lookup userId by email via UserRepository
|
|
||||||
* 2) principal is a custom UserDetails that exposes getId()
|
|
||||||
*/
|
|
||||||
// private Integer resolveUserId(Authentication auth) {
|
|
||||||
// if (auth == null || !auth.isAuthenticated()) return null;
|
|
||||||
//
|
|
||||||
// Object principal = auth.getPrincipal();
|
|
||||||
// if (principal == null) return null;
|
|
||||||
//
|
|
||||||
// // 1) If your principal exposes getId() (custom UserDetails)
|
|
||||||
// try {
|
|
||||||
// var m = principal.getClass().getMethod("getId");
|
|
||||||
// Object id = m.invoke(principal);
|
|
||||||
// if (id instanceof Integer i) return i;
|
|
||||||
// if (id instanceof Long l) return Math.toIntExact(l);
|
|
||||||
// if (id != null) return Integer.parseInt(String.valueOf(id));
|
|
||||||
// } catch (Exception ignored) { }
|
|
||||||
//
|
|
||||||
// // 2) If you store userId on "details"
|
|
||||||
// try {
|
|
||||||
// Object details = auth.getDetails();
|
|
||||||
// if (details != null) {
|
|
||||||
// var m = details.getClass().getMethod("getId");
|
|
||||||
// Object id = m.invoke(details);
|
|
||||||
// if (id instanceof Integer i) return i;
|
|
||||||
// if (id instanceof Long l) return Math.toIntExact(l);
|
|
||||||
// if (id != null) return Integer.parseInt(String.valueOf(id));
|
|
||||||
// }
|
|
||||||
// } catch (Exception ignored) { }
|
|
||||||
//
|
|
||||||
// // 3) If auth.getName() is actually a numeric id (some JWT setups do this)
|
|
||||||
// try {
|
|
||||||
// String name = auth.getName();
|
|
||||||
// if (name != null && name.matches("^\\d+$")) {
|
|
||||||
// return Integer.parseInt(name);
|
|
||||||
// }
|
|
||||||
// } catch (Exception ignored) { }
|
|
||||||
//
|
|
||||||
// // 4) If principal is a Map-like structure with "userId"/"id" (some token decoders)
|
|
||||||
// if (principal instanceof Map<?, ?> map) {
|
|
||||||
// Object id = map.get("userId");
|
|
||||||
// if (id == null) id = map.get("id");
|
|
||||||
// if (id != null) {
|
|
||||||
// try { return Integer.parseInt(String.valueOf(id)); } catch (Exception ignored) {}
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// System.out.println("PRINCIPAL=" + auth.getPrincipal().getClass() + " :: " + auth.getPrincipal());
|
|
||||||
// System.out.println("NAME=" + auth.getName());
|
|
||||||
// return null;
|
|
||||||
// }
|
|
||||||
private Integer resolveUserId(Authentication auth) {
|
|
||||||
// DEV MODE ONLY
|
|
||||||
// TODO: Replace with above once auth is wired end-to-end
|
|
||||||
|
|
||||||
return 3; // <- pick a user_id that exists in your users table
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +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)
|
||||||
|
*
|
||||||
|
* 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; }
|
||||||
|
}
|
||||||
@@ -3,19 +3,13 @@ package group.goforward.battlbuilder.repos;
|
|||||||
import group.goforward.battlbuilder.model.BuildItem;
|
import group.goforward.battlbuilder.model.BuildItem;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
import jakarta.transaction.Transactional;
|
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
public interface BuildItemRepository extends JpaRepository<BuildItem, Integer> {
|
public interface BuildItemRepository extends JpaRepository<BuildItem, Integer> {
|
||||||
|
|
||||||
// main “load build” query
|
List<BuildItem> findByBuild_IdIn(List<Integer> buildIds);
|
||||||
List<BuildItem> findByBuild_Uuid(UUID buildUuid);
|
|
||||||
|
|
||||||
// handy for cleanup or listing items
|
|
||||||
List<BuildItem> findByBuild_Id(Integer buildId);
|
List<BuildItem> findByBuild_Id(Integer buildId);
|
||||||
|
|
||||||
@Transactional
|
long deleteByBuild_Id(Integer buildId);
|
||||||
void deleteByBuild_Id(Integer buildId);
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package group.goforward.battlbuilder.repos;
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -1,21 +1,19 @@
|
|||||||
package group.goforward.battlbuilder.repos;
|
package group.goforward.battlbuilder.repos;
|
||||||
|
|
||||||
import group.goforward.battlbuilder.model.Build;
|
import group.goforward.battlbuilder.model.Build;
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
|
import org.springframework.data.domain.Pageable;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
public interface BuildRepository extends JpaRepository<Build, Integer> {
|
public interface BuildRepository extends JpaRepository<Build, Integer> {
|
||||||
Optional<Build> findByUuid(UUID uuid);
|
|
||||||
|
|
||||||
// My builds list (excludes soft-deleted)
|
Page<Build> findByIsPublicTrueAndDeletedAtIsNullOrderByUpdatedAtDesc(Pageable pageable);
|
||||||
List<Build> findByUserIdAndDeletedAtIsNullOrderByUpdatedAtDesc(Integer userId);
|
|
||||||
|
// Temporary vault behavior until Build.user exists:
|
||||||
|
Page<Build> findByDeletedAtIsNullOrderByUpdatedAtDesc(Pageable pageable);
|
||||||
|
|
||||||
// Optional: if you want /me/builds/{uuid} fetch with ownership checks
|
|
||||||
Optional<Build> findByUuidAndDeletedAtIsNull(UUID uuid);
|
Optional<Build> findByUuidAndDeletedAtIsNull(UUID uuid);
|
||||||
|
|
||||||
// Optional: owner-only lookups
|
|
||||||
Optional<Build> findByUuidAndUserIdAndDeletedAtIsNull(UUID uuid, Integer userId);
|
|
||||||
}
|
}
|
||||||
@@ -9,16 +9,13 @@ import java.util.Collection;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
|
|
||||||
public interface ProductOfferRepository extends JpaRepository<ProductOffer, Integer> {
|
public interface ProductOfferRepository extends JpaRepository<ProductOffer, Integer> {
|
||||||
|
|
||||||
List<ProductOffer> findByProductId(Integer productId);
|
List<ProductOffer> findByProduct_Id(Integer productId);
|
||||||
|
|
||||||
// Used by the /api/products/gunbuilder endpoint
|
List<ProductOffer> findByProduct_IdIn(Collection<Integer> productIds);
|
||||||
List<ProductOffer> findByProductIdIn(Collection<Integer> productIds);
|
|
||||||
|
|
||||||
// Unique offer lookup for importer upsert
|
Optional<ProductOffer> findByMerchant_IdAndAvantlinkProductId(
|
||||||
Optional<ProductOffer> findByMerchantIdAndAvantlinkProductId(
|
|
||||||
Integer merchantId,
|
Integer merchantId,
|
||||||
String avantlinkProductId
|
String avantlinkProductId
|
||||||
);
|
);
|
||||||
@@ -39,8 +36,6 @@ public interface ProductOfferRepository extends JpaRepository<ProductOffer, Inte
|
|||||||
from ProductOffer po
|
from ProductOffer po
|
||||||
join fetch po.merchant
|
join fetch po.merchant
|
||||||
where po.product.id = :productId
|
where po.product.id = :productId
|
||||||
""")
|
""")
|
||||||
List<ProductOffer> findByProductIdWithMerchant(
|
List<ProductOffer> findByProductIdWithMerchant(@Param("productId") Integer productId);
|
||||||
@Param("productId") Integer productId
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package group.goforward.battlbuilder.services;
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package group.goforward.battlbuilder.services;
|
||||||
|
|
||||||
|
import group.goforward.battlbuilder.web.dto.ProductOfferDto;
|
||||||
|
import group.goforward.battlbuilder.web.dto.ProductSummaryDto;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public interface ProductQueryService {
|
||||||
|
|
||||||
|
List<ProductSummaryDto> getProducts(String platform, List<String> partRoles);
|
||||||
|
|
||||||
|
List<ProductOfferDto> getOffersForProduct(Integer productId);
|
||||||
|
|
||||||
|
ProductSummaryDto getProductById(Integer productId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,386 @@
|
|||||||
|
package group.goforward.battlbuilder.services.impl;
|
||||||
|
|
||||||
|
import group.goforward.battlbuilder.model.Build;
|
||||||
|
import group.goforward.battlbuilder.model.BuildItem;
|
||||||
|
import group.goforward.battlbuilder.model.BuildProfile;
|
||||||
|
import group.goforward.battlbuilder.model.ProductOffer;
|
||||||
|
import group.goforward.battlbuilder.repos.BuildItemRepository;
|
||||||
|
import group.goforward.battlbuilder.repos.BuildProfileRepository;
|
||||||
|
import group.goforward.battlbuilder.repos.BuildRepository;
|
||||||
|
import group.goforward.battlbuilder.repos.ProductOfferRepository;
|
||||||
|
import group.goforward.battlbuilder.services.BuildService;
|
||||||
|
import group.goforward.battlbuilder.web.dto.BuildDto;
|
||||||
|
import group.goforward.battlbuilder.web.dto.BuildFeedCardDto;
|
||||||
|
import group.goforward.battlbuilder.web.dto.BuildItemDto;
|
||||||
|
import group.goforward.battlbuilder.web.dto.BuildSummaryDto;
|
||||||
|
import group.goforward.battlbuilder.web.dto.UpdateBuildRequest;
|
||||||
|
import org.springframework.data.domain.PageRequest;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.math.RoundingMode;
|
||||||
|
import java.util.*;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class BuildServiceImpl implements BuildService {
|
||||||
|
|
||||||
|
private final BuildRepository buildRepository;
|
||||||
|
private final BuildProfileRepository buildProfileRepository;
|
||||||
|
private final BuildItemRepository buildItemRepository;
|
||||||
|
private final ProductOfferRepository productOfferRepository;
|
||||||
|
|
||||||
|
public BuildServiceImpl(
|
||||||
|
BuildRepository buildRepository,
|
||||||
|
BuildProfileRepository buildProfileRepository,
|
||||||
|
BuildItemRepository buildItemRepository,
|
||||||
|
ProductOfferRepository productOfferRepository
|
||||||
|
) {
|
||||||
|
this.buildRepository = buildRepository;
|
||||||
|
this.buildProfileRepository = buildProfileRepository;
|
||||||
|
this.buildItemRepository = buildItemRepository;
|
||||||
|
this.productOfferRepository = productOfferRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------
|
||||||
|
// Public feed (/builds)
|
||||||
|
// ---------------------------
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<BuildFeedCardDto> listPublicBuilds(int limit) {
|
||||||
|
int safeLimit = clamp(limit, 1, 100);
|
||||||
|
|
||||||
|
List<Build> builds = buildRepository
|
||||||
|
.findByIsPublicTrueAndDeletedAtIsNullOrderByUpdatedAtDesc(PageRequest.of(0, safeLimit))
|
||||||
|
.getContent();
|
||||||
|
|
||||||
|
if (builds.isEmpty()) return List.of();
|
||||||
|
|
||||||
|
List<Integer> buildIds = builds.stream().map(Build::getId).toList();
|
||||||
|
|
||||||
|
Map<Integer, BuildProfile> profileByBuildId = buildProfileRepository.findByBuildIdIn(buildIds)
|
||||||
|
.stream()
|
||||||
|
.filter(Objects::nonNull)
|
||||||
|
.collect(Collectors.toMap(BuildProfile::getBuildId, p -> p));
|
||||||
|
|
||||||
|
List<BuildItem> items = buildItemRepository.findByBuild_IdIn(buildIds);
|
||||||
|
|
||||||
|
Map<Integer, List<BuildItem>> itemsByBuildId = items.stream()
|
||||||
|
.filter(Objects::nonNull)
|
||||||
|
.filter(bi -> bi.getBuild() != null && bi.getBuild().getId() != null)
|
||||||
|
.collect(Collectors.groupingBy(bi -> bi.getBuild().getId()));
|
||||||
|
|
||||||
|
Set<Integer> productIds = items.stream()
|
||||||
|
.map(bi -> bi.getProduct() != null ? bi.getProduct().getId() : null)
|
||||||
|
.filter(Objects::nonNull)
|
||||||
|
.collect(Collectors.toSet());
|
||||||
|
|
||||||
|
Map<Integer, BigDecimal> bestPriceByProductId = loadBestPrices(productIds);
|
||||||
|
|
||||||
|
return builds.stream()
|
||||||
|
.map(b -> {
|
||||||
|
BuildProfile p = profileByBuildId.get(b.getId());
|
||||||
|
List<BuildItem> its = itemsByBuildId.getOrDefault(b.getId(), List.of());
|
||||||
|
|
||||||
|
BigDecimal estDollars = computeEstimatedPriceDollars(its, bestPriceByProductId);
|
||||||
|
int estCents = dollarsToCents(estDollars);
|
||||||
|
|
||||||
|
return toFeedCardDto(b, p, estCents);
|
||||||
|
})
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------
|
||||||
|
// Vault list (/builds/me)
|
||||||
|
// ---------------------------
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<BuildSummaryDto> listMyBuilds(int limit) {
|
||||||
|
int safeLimit = clamp(limit, 1, 200);
|
||||||
|
|
||||||
|
// MVP: ownership not implemented yet -> return all non-deleted builds
|
||||||
|
List<Build> builds = buildRepository
|
||||||
|
.findByDeletedAtIsNullOrderByUpdatedAtDesc(PageRequest.of(0, safeLimit))
|
||||||
|
.getContent();
|
||||||
|
|
||||||
|
if (builds.isEmpty()) return List.of();
|
||||||
|
|
||||||
|
return builds.stream().map(BuildSummaryDto::from).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------
|
||||||
|
// Load one build (Vault edit / builder load)
|
||||||
|
// GET /api/v1/builds/me/{uuid}
|
||||||
|
// ---------------------------
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public BuildDto getMyBuild(UUID uuid) {
|
||||||
|
if (uuid == null) throw new IllegalArgumentException("uuid is required");
|
||||||
|
|
||||||
|
Build build = buildRepository.findByUuidAndDeletedAtIsNull(uuid)
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("Build not found"));
|
||||||
|
|
||||||
|
List<BuildItem> items = buildItemRepository.findByBuild_Id(build.getId());
|
||||||
|
return toBuildDto(build, items);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------
|
||||||
|
// Create new build (Save As…)
|
||||||
|
// POST /api/v1/builds/me
|
||||||
|
// ---------------------------
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public BuildDto createMyBuild(UpdateBuildRequest req) {
|
||||||
|
if (req == null) throw new IllegalArgumentException("request body is required");
|
||||||
|
|
||||||
|
String title = (req.getTitle() == null || req.getTitle().isBlank())
|
||||||
|
? "Untitled Build"
|
||||||
|
: req.getTitle().trim();
|
||||||
|
|
||||||
|
Build build = new Build();
|
||||||
|
build.setTitle(title);
|
||||||
|
build.setDescription(req.getDescription());
|
||||||
|
build.setIsPublic(req.getIsPublic() != null ? req.getIsPublic() : Boolean.FALSE);
|
||||||
|
|
||||||
|
if (build.getUuid() == null) build.setUuid(UUID.randomUUID());
|
||||||
|
|
||||||
|
Build saved = buildRepository.save(build);
|
||||||
|
|
||||||
|
if (req.getItems() != null) {
|
||||||
|
List<BuildItem> newItems = buildItemsFromRequest(saved, req.getItems());
|
||||||
|
if (!newItems.isEmpty()) buildItemRepository.saveAll(newItems);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<BuildItem> items = buildItemRepository.findByBuild_Id(saved.getId());
|
||||||
|
return toBuildDto(saved, items);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------
|
||||||
|
// Update build (Vault edit save)
|
||||||
|
// PUT /api/v1/builds/me/{uuid}
|
||||||
|
// ---------------------------
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public BuildDto updateMyBuild(UUID uuid, UpdateBuildRequest req) {
|
||||||
|
if (uuid == null) throw new IllegalArgumentException("uuid is required");
|
||||||
|
if (req == null) throw new IllegalArgumentException("request body is required");
|
||||||
|
|
||||||
|
Build build = buildRepository.findByUuidAndDeletedAtIsNull(uuid)
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("Build not found"));
|
||||||
|
|
||||||
|
if (req.getTitle() != null) build.setTitle(req.getTitle().trim());
|
||||||
|
if (req.getDescription() != null) build.setDescription(req.getDescription());
|
||||||
|
if (req.getIsPublic() != null) build.setIsPublic(req.getIsPublic());
|
||||||
|
|
||||||
|
Build saved = buildRepository.save(build);
|
||||||
|
|
||||||
|
if (req.getItems() != null) {
|
||||||
|
buildItemRepository.deleteByBuild_Id(saved.getId());
|
||||||
|
List<BuildItem> newItems = buildItemsFromRequest(saved, req.getItems());
|
||||||
|
if (!newItems.isEmpty()) buildItemRepository.saveAll(newItems);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<BuildItem> items = buildItemRepository.findByBuild_Id(saved.getId());
|
||||||
|
return toBuildDto(saved, items);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------
|
||||||
|
// BuildItem helper
|
||||||
|
// ---------------------------
|
||||||
|
|
||||||
|
private List<BuildItem> buildItemsFromRequest(Build build, List<UpdateBuildRequest.Item> incoming) {
|
||||||
|
List<BuildItem> out = new ArrayList<>();
|
||||||
|
|
||||||
|
for (UpdateBuildRequest.Item it : incoming) {
|
||||||
|
if (it == null) continue;
|
||||||
|
if (it.getProductId() == null) continue;
|
||||||
|
if (it.getSlot() == null || it.getSlot().isBlank()) continue;
|
||||||
|
|
||||||
|
BuildItem bi = new BuildItem();
|
||||||
|
bi.setBuild(build);
|
||||||
|
|
||||||
|
// Product proxy by ID only
|
||||||
|
var product = new group.goforward.battlbuilder.model.Product();
|
||||||
|
product.setId(it.getProductId());
|
||||||
|
bi.setProduct(product);
|
||||||
|
|
||||||
|
bi.setSlot(it.getSlot());
|
||||||
|
bi.setPosition(it.getPosition() != null ? it.getPosition() : 0);
|
||||||
|
bi.setQuantity(it.getQuantity() != null && it.getQuantity() > 0 ? it.getQuantity() : 1);
|
||||||
|
|
||||||
|
out.add(bi);
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------
|
||||||
|
// DTO mapping (Build -> BuildDto)
|
||||||
|
// ---------------------------
|
||||||
|
|
||||||
|
private BuildDto toBuildDto(Build build, List<BuildItem> items) {
|
||||||
|
BuildDto dto = new BuildDto();
|
||||||
|
dto.setId(build.getId() != null ? String.valueOf(build.getId()) : null);
|
||||||
|
dto.setUuid(build.getUuid());
|
||||||
|
dto.setTitle(build.getTitle());
|
||||||
|
dto.setDescription(build.getDescription());
|
||||||
|
dto.setIsPublic(build.getIsPublic());
|
||||||
|
dto.setCreatedAt(build.getCreatedAt());
|
||||||
|
dto.setUpdatedAt(build.getUpdatedAt());
|
||||||
|
|
||||||
|
List<BuildItemDto> itemDtos = (items == null ? List.<BuildItem>of() : items).stream()
|
||||||
|
.filter(Objects::nonNull)
|
||||||
|
.filter(bi -> bi.getDeletedAt() == null)
|
||||||
|
.map(bi -> {
|
||||||
|
BuildItemDto it = new BuildItemDto();
|
||||||
|
|
||||||
|
it.setId(bi.getId() != null ? String.valueOf(bi.getId()) : null);
|
||||||
|
it.setUuid(bi.getUuid());
|
||||||
|
it.setSlot(bi.getSlot());
|
||||||
|
it.setPosition(bi.getPosition());
|
||||||
|
it.setQuantity(bi.getQuantity());
|
||||||
|
|
||||||
|
// BuildItemDto.productId is String
|
||||||
|
it.setProductId(
|
||||||
|
(bi.getProduct() != null && bi.getProduct().getId() != null)
|
||||||
|
? String.valueOf(bi.getProduct().getId())
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
|
||||||
|
// Optional / safe defaults for now
|
||||||
|
it.setProductName(null);
|
||||||
|
it.setProductBrand(null);
|
||||||
|
it.setProductImageUrl(null);
|
||||||
|
|
||||||
|
return it;
|
||||||
|
})
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
dto.setItems(itemDtos);
|
||||||
|
return dto;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------
|
||||||
|
// Price helpers (feed)
|
||||||
|
// ---------------------------
|
||||||
|
|
||||||
|
private Map<Integer, BigDecimal> loadBestPrices(Set<Integer> productIds) {
|
||||||
|
if (productIds == null || productIds.isEmpty()) return Map.of();
|
||||||
|
|
||||||
|
List<ProductOffer> offers = productOfferRepository.findByProduct_IdIn(productIds);
|
||||||
|
|
||||||
|
Map<Integer, List<ProductOffer>> offersByProductId = offers.stream()
|
||||||
|
.filter(Objects::nonNull)
|
||||||
|
.filter(o -> o.getProduct() != null && o.getProduct().getId() != null)
|
||||||
|
.collect(Collectors.groupingBy(o -> o.getProduct().getId()));
|
||||||
|
|
||||||
|
Map<Integer, BigDecimal> best = new HashMap<>();
|
||||||
|
|
||||||
|
for (Map.Entry<Integer, List<ProductOffer>> e : offersByProductId.entrySet()) {
|
||||||
|
ProductOffer winner = pickBestOffer(e.getValue());
|
||||||
|
if (winner != null && winner.getEffectivePrice() != null) {
|
||||||
|
best.put(e.getKey(), winner.getEffectivePrice());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
private BigDecimal computeEstimatedPriceDollars(
|
||||||
|
List<BuildItem> items,
|
||||||
|
Map<Integer, BigDecimal> bestPriceByProductId
|
||||||
|
) {
|
||||||
|
if (items == null || items.isEmpty()) return BigDecimal.ZERO;
|
||||||
|
|
||||||
|
BigDecimal sum = BigDecimal.ZERO;
|
||||||
|
|
||||||
|
for (BuildItem bi : items) {
|
||||||
|
if (bi == null) continue;
|
||||||
|
if (bi.getDeletedAt() != null) continue;
|
||||||
|
if (bi.getProduct() == null || bi.getProduct().getId() == null) continue;
|
||||||
|
|
||||||
|
Integer pid = bi.getProduct().getId();
|
||||||
|
int qty = (bi.getQuantity() != null && bi.getQuantity() > 0) ? bi.getQuantity() : 1;
|
||||||
|
|
||||||
|
BigDecimal each = bestPriceByProductId.get(pid);
|
||||||
|
if (each == null) continue;
|
||||||
|
|
||||||
|
sum = sum.add(each.multiply(BigDecimal.valueOf(qty)));
|
||||||
|
}
|
||||||
|
|
||||||
|
return sum.signum() < 0 ? BigDecimal.ZERO : sum;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int dollarsToCents(BigDecimal dollars) {
|
||||||
|
if (dollars == null) return 0;
|
||||||
|
|
||||||
|
BigDecimal cents = dollars
|
||||||
|
.multiply(BigDecimal.valueOf(100))
|
||||||
|
.setScale(0, RoundingMode.HALF_UP);
|
||||||
|
|
||||||
|
if (cents.signum() < 0) return 0;
|
||||||
|
|
||||||
|
long asLong = cents.longValue();
|
||||||
|
if (asLong > Integer.MAX_VALUE) return Integer.MAX_VALUE;
|
||||||
|
return (int) asLong;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ProductOffer pickBestOffer(List<ProductOffer> offers) {
|
||||||
|
if (offers == null || offers.isEmpty()) return null;
|
||||||
|
|
||||||
|
return offers.stream()
|
||||||
|
.filter(o -> o.getEffectivePrice() != null)
|
||||||
|
.min(Comparator.comparing(ProductOffer::getEffectivePrice))
|
||||||
|
.orElse(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private BuildFeedCardDto toFeedCardDto(Build b, BuildProfile p, int estPriceCents) {
|
||||||
|
BuildFeedCardDto dto = new BuildFeedCardDto();
|
||||||
|
|
||||||
|
dto.setUuid(b.getUuid());
|
||||||
|
dto.setTitle(b.getTitle());
|
||||||
|
dto.setSlug(b.getUuid() != null ? b.getUuid().toString() : null);
|
||||||
|
dto.setCreator("anonymous");
|
||||||
|
|
||||||
|
if (p != null) {
|
||||||
|
dto.setCaliber(blankToNull(p.getCaliber()));
|
||||||
|
dto.setBuildClass(blankToNull(p.getBuildClass()));
|
||||||
|
dto.setCoverImageUrl(blankToNull(p.getCoverImageUrl()));
|
||||||
|
dto.setTags(parseTags(p.getTagsCsv()));
|
||||||
|
} else {
|
||||||
|
dto.setCaliber(null);
|
||||||
|
dto.setBuildClass(null);
|
||||||
|
dto.setCoverImageUrl(null);
|
||||||
|
dto.setTags(List.of());
|
||||||
|
}
|
||||||
|
|
||||||
|
dto.setPrice(estPriceCents);
|
||||||
|
dto.setVotes(0);
|
||||||
|
|
||||||
|
return dto;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<String> parseTags(String tagsCsv) {
|
||||||
|
if (tagsCsv == null || tagsCsv.isBlank()) return List.of();
|
||||||
|
|
||||||
|
return Arrays.stream(tagsCsv.split(","))
|
||||||
|
.map(String::trim)
|
||||||
|
.filter(s -> !s.isEmpty())
|
||||||
|
.distinct()
|
||||||
|
.limit(12)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String blankToNull(String s) {
|
||||||
|
if (s == null) return null;
|
||||||
|
String t = s.trim();
|
||||||
|
return t.isEmpty() ? null : t;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int clamp(int n, int min, int max) {
|
||||||
|
return Math.max(min, Math.min(max, n));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -309,7 +309,7 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
|
|||||||
}
|
}
|
||||||
|
|
||||||
ProductOffer offer = productOfferRepository
|
ProductOffer offer = productOfferRepository
|
||||||
.findByMerchantIdAndAvantlinkProductId(merchant.getId(), avantlinkProductId)
|
.findByMerchant_IdAndAvantlinkProductId(merchant.getId(), avantlinkProductId)
|
||||||
.orElseGet(ProductOffer::new);
|
.orElseGet(ProductOffer::new);
|
||||||
|
|
||||||
if (offer.getId() == null) {
|
if (offer.getId() == null) {
|
||||||
@@ -394,7 +394,7 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
|
|||||||
if (avantlinkProductId == null || avantlinkProductId.isBlank()) return;
|
if (avantlinkProductId == null || avantlinkProductId.isBlank()) return;
|
||||||
|
|
||||||
ProductOffer offer = productOfferRepository
|
ProductOffer offer = productOfferRepository
|
||||||
.findByMerchantIdAndAvantlinkProductId(merchant.getId(), avantlinkProductId)
|
.findByMerchant_IdAndAvantlinkProductId(merchant.getId(), avantlinkProductId)
|
||||||
.orElse(null);
|
.orElse(null);
|
||||||
|
|
||||||
if (offer == null) {
|
if (offer == null) {
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
package group.goforward.battlbuilder.services.impl;
|
||||||
|
|
||||||
|
import group.goforward.battlbuilder.model.Product;
|
||||||
|
import group.goforward.battlbuilder.model.ProductOffer;
|
||||||
|
import group.goforward.battlbuilder.repos.ProductOfferRepository;
|
||||||
|
import group.goforward.battlbuilder.repos.ProductRepository;
|
||||||
|
import group.goforward.battlbuilder.services.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;
|
||||||
|
|
||||||
|
@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 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package group.goforward.battlbuilder.web.dto;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contract for /builds feed cards.
|
||||||
|
* Matches the Next.js BuildsPage expectations.
|
||||||
|
*
|
||||||
|
* Dev Notes:
|
||||||
|
* - We are using Option 2 for money: price is INTEGER CENTS (not dollars).
|
||||||
|
* Example: $19.99 -> 1999
|
||||||
|
* - Keep the field name "price" for UI continuity for now.
|
||||||
|
* (We can rename to priceCents later with a coordinated UI change.)
|
||||||
|
* - "buildClass" is a string for now ("Rifle" | "Pistol" | "NFA").
|
||||||
|
* Later we can move to enum if/when we want strict validation.
|
||||||
|
*/
|
||||||
|
public class BuildFeedCardDto {
|
||||||
|
|
||||||
|
// Stable public identifier for routes + API consumers
|
||||||
|
private UUID uuid;
|
||||||
|
|
||||||
|
// Display
|
||||||
|
private String title;
|
||||||
|
|
||||||
|
// MVP: can just be uuid string; later can be derived from title (unique per creator)
|
||||||
|
private String slug;
|
||||||
|
|
||||||
|
// Placeholder until user profiles exist
|
||||||
|
private String creator;
|
||||||
|
|
||||||
|
// From BuildProfile (meta)
|
||||||
|
private String caliber;
|
||||||
|
|
||||||
|
// "Rifle" | "Pistol" | "NFA" (string for MVP)
|
||||||
|
private String buildClass;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Estimated build price in CENTS (Option 2).
|
||||||
|
* Example: 245000 == $2,450.00
|
||||||
|
*
|
||||||
|
* IMPORTANT:
|
||||||
|
* - UI must divide by 100 for display.
|
||||||
|
* - Keep nullable optional; treat null as 0 in the UI.
|
||||||
|
*/
|
||||||
|
private Integer price;
|
||||||
|
|
||||||
|
// Aggregated vote count (0 until votes table exists)
|
||||||
|
private Integer votes;
|
||||||
|
|
||||||
|
// Tag strings (from profile meta)
|
||||||
|
private List<String> tags;
|
||||||
|
|
||||||
|
// Optional hero image for the card (from build_media or profile)
|
||||||
|
private String coverImageUrl;
|
||||||
|
|
||||||
|
public UUID getUuid() { return uuid; }
|
||||||
|
public void setUuid(UUID uuid) { this.uuid = uuid; }
|
||||||
|
|
||||||
|
public String getTitle() { return title; }
|
||||||
|
public void setTitle(String title) { this.title = title; }
|
||||||
|
|
||||||
|
public String getSlug() { return slug; }
|
||||||
|
public void setSlug(String slug) { this.slug = slug; }
|
||||||
|
|
||||||
|
public String getCreator() { return creator; }
|
||||||
|
public void setCreator(String creator) { this.creator = creator; }
|
||||||
|
|
||||||
|
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 Integer getPrice() { return price; }
|
||||||
|
public void setPrice(Integer price) { this.price = price; }
|
||||||
|
|
||||||
|
public Integer getVotes() { return votes; }
|
||||||
|
public void setVotes(Integer votes) { this.votes = votes; }
|
||||||
|
|
||||||
|
public List<String> getTags() { return tags; }
|
||||||
|
public void setTags(List<String> tags) { this.tags = tags; }
|
||||||
|
|
||||||
|
public String getCoverImageUrl() { return coverImageUrl; }
|
||||||
|
public void setCoverImageUrl(String coverImageUrl) { this.coverImageUrl = coverImageUrl; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package group.goforward.battlbuilder.web.dto;
|
||||||
|
|
||||||
|
import group.goforward.battlbuilder.model.Build;
|
||||||
|
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lightweight list DTO for Vault ("My Builds").
|
||||||
|
* Keep this stable for the UI even if entity changes.
|
||||||
|
*/
|
||||||
|
public class BuildSummaryDto {
|
||||||
|
|
||||||
|
private Integer id; // internal DB id (optional for UI)
|
||||||
|
private UUID uuid; // stable public identifier
|
||||||
|
private String title;
|
||||||
|
private String description;
|
||||||
|
private Boolean isPublic;
|
||||||
|
private OffsetDateTime createdAt;
|
||||||
|
private OffsetDateTime updatedAt;
|
||||||
|
|
||||||
|
public static BuildSummaryDto from(Build b) {
|
||||||
|
BuildSummaryDto dto = new BuildSummaryDto();
|
||||||
|
dto.setId(b.getId());
|
||||||
|
dto.setUuid(b.getUuid());
|
||||||
|
dto.setTitle(b.getTitle());
|
||||||
|
dto.setDescription(b.getDescription());
|
||||||
|
dto.setIsPublic(b.getIsPublic());
|
||||||
|
dto.setCreatedAt(b.getCreatedAt());
|
||||||
|
dto.setUpdatedAt(b.getUpdatedAt());
|
||||||
|
return dto;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getId() { return id; }
|
||||||
|
public void setId(Integer id) { this.id = id; }
|
||||||
|
|
||||||
|
public UUID getUuid() { return uuid; }
|
||||||
|
public void setUuid(UUID uuid) { this.uuid = uuid; }
|
||||||
|
|
||||||
|
public String getTitle() { return title; }
|
||||||
|
public void setTitle(String title) { this.title = title; }
|
||||||
|
|
||||||
|
public String getDescription() { return description; }
|
||||||
|
public void setDescription(String description) { this.description = description; }
|
||||||
|
|
||||||
|
public Boolean getIsPublic() { return isPublic; }
|
||||||
|
public void setIsPublic(Boolean isPublic) { this.isPublic = isPublic; }
|
||||||
|
|
||||||
|
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; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package group.goforward.battlbuilder.web.dto;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request DTO for creating/updating Build + items.
|
||||||
|
* Keeps entity fields protected from client-side overposting.
|
||||||
|
*
|
||||||
|
* NOTE:
|
||||||
|
* - We reuse this for BOTH create (POST /me) and update (PUT /me/{uuid}) for MVP speed.
|
||||||
|
*/
|
||||||
|
public class UpdateBuildRequest {
|
||||||
|
|
||||||
|
private String title;
|
||||||
|
private String description;
|
||||||
|
private Boolean isPublic;
|
||||||
|
|
||||||
|
// Build items (builder slots)
|
||||||
|
private List<Item> items;
|
||||||
|
|
||||||
|
// optional profile fields (if you update profile in same request later)
|
||||||
|
private String caliber;
|
||||||
|
private String buildClass;
|
||||||
|
private String coverImageUrl;
|
||||||
|
private List<String> tags;
|
||||||
|
|
||||||
|
public static class Item {
|
||||||
|
private Integer productId;
|
||||||
|
private String slot;
|
||||||
|
private Integer position;
|
||||||
|
private Integer quantity;
|
||||||
|
|
||||||
|
public Integer getProductId() { return productId; }
|
||||||
|
public void setProductId(Integer productId) { this.productId = productId; }
|
||||||
|
|
||||||
|
public String getSlot() { return slot; }
|
||||||
|
public void setSlot(String slot) { this.slot = slot; }
|
||||||
|
|
||||||
|
public Integer getPosition() { return position; }
|
||||||
|
public void setPosition(Integer position) { this.position = position; }
|
||||||
|
|
||||||
|
public Integer getQuantity() { return quantity; }
|
||||||
|
public void setQuantity(Integer quantity) { this.quantity = quantity; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getTitle() { return title; }
|
||||||
|
public void setTitle(String title) { this.title = title; }
|
||||||
|
|
||||||
|
public String getDescription() { return description; }
|
||||||
|
public void setDescription(String description) { this.description = description; }
|
||||||
|
|
||||||
|
public Boolean getIsPublic() { return isPublic; }
|
||||||
|
public void setIsPublic(Boolean isPublic) { this.isPublic = isPublic; }
|
||||||
|
|
||||||
|
public List<Item> getItems() { return items; }
|
||||||
|
public void setItems(List<Item> items) { this.items = items; }
|
||||||
|
|
||||||
|
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 List<String> getTags() { return tags; }
|
||||||
|
public void setTags(List<String> tags) { this.tags = tags; }
|
||||||
|
}
|
||||||
@@ -46,3 +46,5 @@ minio.bucket=battlbuilds
|
|||||||
# Public base URL used to write back into products.main_image_url
|
# Public base URL used to write back into products.main_image_url
|
||||||
minio.public-base-url=https://minio.dev.gofwd.group
|
minio.public-base-url=https://minio.dev.gofwd.group
|
||||||
|
|
||||||
|
# --- Feature toggles ---
|
||||||
|
app.api.legacy.enabled=false
|
||||||
Reference in New Issue
Block a user