mirror of
https://gitea.gofwd.group/Forward_Group/ballistic-builder-spring.git
synced 2026-01-20 16:51:03 -05:00
short links
This commit is contained in:
@@ -1,13 +1,16 @@
|
||||
package group.goforward.battlbuilder;
|
||||
|
||||
import group.goforward.battlbuilder.config.ShortLinksProperties;
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.boot.autoconfigure.domain.EntityScan;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.cache.annotation.EnableCaching;
|
||||
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
|
||||
|
||||
@SpringBootApplication
|
||||
@EnableCaching
|
||||
@EnableConfigurationProperties(ShortLinksProperties.class)
|
||||
@EntityScan(basePackages = {
|
||||
"group.goforward.battlbuilder.model",
|
||||
"group.goforward.battlbuilder.enrichment"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package group.goforward.battlbuilder.configuration;
|
||||
package group.goforward.battlbuilder.config;
|
||||
|
||||
import group.goforward.battlbuilder.security.JwtAuthenticationFilter;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
@@ -50,6 +50,10 @@ public class SecurityConfig {
|
||||
.requestMatchers(HttpMethod.GET, "/api/v1/builds").permitAll()
|
||||
.requestMatchers(HttpMethod.GET, "/api/v1/builds/*").permitAll()
|
||||
|
||||
// Short links (public redirect)
|
||||
.requestMatchers(HttpMethod.GET, "/go/**").permitAll()
|
||||
.requestMatchers(HttpMethod.HEAD, "/go/**").permitAll()
|
||||
|
||||
// ----------------------------
|
||||
// Protected
|
||||
// ----------------------------
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package group.goforward.battlbuilder.config;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
@ConfigurationProperties(prefix = "shortlinks")
|
||||
public class ShortLinksProperties {
|
||||
|
||||
/**
|
||||
* Master switch to enable short links.
|
||||
*/
|
||||
private boolean enabled = true;
|
||||
|
||||
/**
|
||||
* The public base URL used when generating links (differs in dev vs prod).
|
||||
* Examples:
|
||||
* - http://localhost:8080
|
||||
* - https://bb.ooo
|
||||
*/
|
||||
private String publicBaseUrl = "http://localhost:8080";
|
||||
|
||||
public boolean isEnabled() {
|
||||
return enabled;
|
||||
}
|
||||
|
||||
public void setEnabled(boolean enabled) {
|
||||
this.enabled = enabled;
|
||||
}
|
||||
|
||||
public String getPublicBaseUrl() {
|
||||
return publicBaseUrl;
|
||||
}
|
||||
|
||||
public void setPublicBaseUrl(String publicBaseUrl) {
|
||||
this.publicBaseUrl = publicBaseUrl;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package group.goforward.battlbuilder.controllers;
|
||||
|
||||
import group.goforward.battlbuilder.model.ShortLink;
|
||||
import group.goforward.battlbuilder.repos.ShortLinkRepository;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
|
||||
@Controller
|
||||
public class GoController {
|
||||
|
||||
private final ShortLinkRepository repo;
|
||||
|
||||
public GoController(ShortLinkRepository repo) {
|
||||
this.repo = repo;
|
||||
}
|
||||
|
||||
@GetMapping("/go/{code}")
|
||||
public ResponseEntity<Void> go(@PathVariable String code) {
|
||||
ShortLink link = repo.findByCodeAndIsActiveTrue(code).orElse(null);
|
||||
if (link == null) return ResponseEntity.notFound().build();
|
||||
|
||||
String dest = link.getDestinationUrl();
|
||||
|
||||
// Future: BUILD share links can compute a frontend URL here
|
||||
// if ("BUILD".equalsIgnoreCase(link.getType()) && link.getBuildUuid() != null) {
|
||||
// dest = "https://app.battlbuilder.com/build/" + link.getBuildUuid();
|
||||
// }
|
||||
|
||||
if (dest == null || dest.isBlank()) return ResponseEntity.notFound().build();
|
||||
|
||||
return ResponseEntity.status(302)
|
||||
.header(HttpHeaders.LOCATION, dest)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package group.goforward.battlbuilder.controllers;
|
||||
|
||||
import group.goforward.battlbuilder.repos.ShortLinkRepository;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.net.URI;
|
||||
|
||||
@RestController
|
||||
public class ShortLinkRedirectController {
|
||||
|
||||
private final ShortLinkRepository repo;
|
||||
|
||||
public ShortLinkRedirectController(ShortLinkRepository repo) {
|
||||
this.repo = repo;
|
||||
}
|
||||
|
||||
@GetMapping("/go/{code}")
|
||||
public ResponseEntity<Void> go(@PathVariable String code) {
|
||||
var link = repo.findByCodeAndIsActiveTrue(code)
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
|
||||
|
||||
if (!"BUY".equals(link.getType()) || link.getDestinationUrl() == null) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND);
|
||||
}
|
||||
|
||||
return ResponseEntity.status(HttpStatus.FOUND)
|
||||
.location(URI.create(link.getDestinationUrl()))
|
||||
.build();
|
||||
}
|
||||
|
||||
@GetMapping("/b/{code}")
|
||||
public ResponseEntity<Void> build(@PathVariable String code) {
|
||||
var link = repo.findByCodeAndIsActiveTrue(code)
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
|
||||
|
||||
if (!"BUILD".equals(link.getType()) || link.getBuildUuid() == null) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND);
|
||||
}
|
||||
|
||||
// Adjust to your actual frontend route
|
||||
return ResponseEntity.status(HttpStatus.FOUND)
|
||||
.location(URI.create("/builds/" + link.getBuildUuid()))
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package group.goforward.battlbuilder.model;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import org.hibernate.annotations.ColumnDefault;
|
||||
import org.hibernate.annotations.UuidGenerator;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
@Entity
|
||||
@Table(
|
||||
name = "short_links",
|
||||
indexes = {
|
||||
@Index(name = "idx_short_links_code", columnList = "code"),
|
||||
@Index(name = "idx_short_links_type_offer", columnList = "type,product_offer_id"),
|
||||
@Index(name = "idx_short_links_type_build", columnList = "type,build_uuid")
|
||||
}
|
||||
)
|
||||
public class ShortLink {
|
||||
|
||||
@Id
|
||||
@GeneratedValue
|
||||
@UuidGenerator
|
||||
@Column(name = "id", nullable = false)
|
||||
private UUID id;
|
||||
|
||||
@Column(name = "code", nullable = false, unique = true, length = 12)
|
||||
private String code;
|
||||
|
||||
// BUY | BUILD
|
||||
@Column(name = "type", nullable = false, length = 20)
|
||||
private String type;
|
||||
|
||||
@Column(name = "destination_url")
|
||||
private String destinationUrl;
|
||||
|
||||
@Column(name = "product_offer_id")
|
||||
private Integer productOfferId;
|
||||
|
||||
@Column(name = "build_uuid")
|
||||
private UUID buildUuid;
|
||||
|
||||
@ColumnDefault("true")
|
||||
@Column(name = "is_active", nullable = false)
|
||||
private Boolean isActive = true;
|
||||
|
||||
@ColumnDefault("now()")
|
||||
@Column(name = "created_at", nullable = false)
|
||||
private OffsetDateTime createdAt = OffsetDateTime.now();
|
||||
|
||||
public UUID getId() { return id; }
|
||||
public void setId(UUID id) { this.id = id; }
|
||||
|
||||
public String getCode() { return code; }
|
||||
public void setCode(String code) { this.code = code; }
|
||||
|
||||
public String getType() { return type; }
|
||||
public void setType(String type) { this.type = type; }
|
||||
|
||||
public String getDestinationUrl() { return destinationUrl; }
|
||||
public void setDestinationUrl(String destinationUrl) { this.destinationUrl = destinationUrl; }
|
||||
|
||||
public Integer getProductOfferId() { return productOfferId; }
|
||||
public void setProductOfferId(Integer productOfferId) { this.productOfferId = productOfferId; }
|
||||
|
||||
public UUID getBuildUuid() { return buildUuid; }
|
||||
public void setBuildUuid(UUID buildUuid) { this.buildUuid = buildUuid; }
|
||||
|
||||
public Boolean getIsActive() { return isActive; }
|
||||
public void setIsActive(Boolean active) { isActive = active; }
|
||||
|
||||
public OffsetDateTime getCreatedAt() { return createdAt; }
|
||||
public void setCreatedAt(OffsetDateTime createdAt) { this.createdAt = createdAt; }
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package group.goforward.battlbuilder.repos;
|
||||
|
||||
import group.goforward.battlbuilder.model.ShortLink;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
public interface ShortLinkRepository extends JpaRepository<ShortLink, UUID> {
|
||||
|
||||
Optional<ShortLink> findByCodeAndIsActiveTrue(String code);
|
||||
|
||||
Optional<ShortLink> findByTypeAndProductOfferId(String type, Integer productOfferId);
|
||||
|
||||
// Future short URL support for build sharing
|
||||
Optional<ShortLink> findByTypeAndBuildUuid(String type, UUID buildUuid);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package group.goforward.battlbuilder.services;
|
||||
|
||||
import group.goforward.battlbuilder.config.ShortLinksProperties;
|
||||
import group.goforward.battlbuilder.model.ShortLink;
|
||||
import group.goforward.battlbuilder.repos.ShortLinkRepository;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.security.SecureRandom;
|
||||
import java.util.UUID;
|
||||
|
||||
@Service
|
||||
public class ShortLinkService {
|
||||
|
||||
private static final String TYPE_BUY = "BUY";
|
||||
private static final String ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
|
||||
private static final SecureRandom RNG = new SecureRandom();
|
||||
|
||||
private final ShortLinkRepository repo;
|
||||
private final ShortLinksProperties props;
|
||||
|
||||
public ShortLinkService(ShortLinkRepository repo, ShortLinksProperties props) {
|
||||
this.repo = repo;
|
||||
this.props = props;
|
||||
}
|
||||
|
||||
public String shortBuyUrlForOffer(Integer offerId, String destinationUrl) {
|
||||
if (!props.isEnabled()) return destinationUrl;
|
||||
if (offerId == null || destinationUrl == null || destinationUrl.isBlank()) return destinationUrl;
|
||||
|
||||
var existing = repo.findByTypeAndProductOfferId(TYPE_BUY, offerId).orElse(null);
|
||||
if (existing != null) return publicUrl(existing.getCode());
|
||||
|
||||
for (int attempt = 0; attempt < 5; attempt++) {
|
||||
try {
|
||||
ShortLink link = new ShortLink();
|
||||
link.setType(TYPE_BUY);
|
||||
link.setProductOfferId(offerId);
|
||||
link.setDestinationUrl(destinationUrl);
|
||||
link.setCode(code(8));
|
||||
|
||||
repo.saveAndFlush(link); // flush forces the constraint check *here*
|
||||
return publicUrl(link.getCode());
|
||||
|
||||
} catch (DataIntegrityViolationException ignored) {
|
||||
// another thread/process created it first
|
||||
var raced = repo.findByTypeAndProductOfferId(TYPE_BUY, offerId).orElse(null);
|
||||
if (raced != null) return publicUrl(raced.getCode());
|
||||
}
|
||||
}
|
||||
|
||||
return destinationUrl;
|
||||
}
|
||||
|
||||
private String publicUrl(String code) {
|
||||
String base = props.getPublicBaseUrl();
|
||||
if (base == null) base = "";
|
||||
base = base.endsWith("/") ? base.substring(0, base.length() - 1) : base;
|
||||
return base + "/go/" + code;
|
||||
}
|
||||
|
||||
private String code(int len) {
|
||||
StringBuilder sb = new StringBuilder(len);
|
||||
for (int i = 0; i < len; i++) sb.append(ALPHABET.charAt(RNG.nextInt(ALPHABET.length())));
|
||||
return sb.toString();
|
||||
}
|
||||
}
|
||||
@@ -6,10 +6,10 @@ import group.goforward.battlbuilder.repos.ProductOfferRepository;
|
||||
import group.goforward.battlbuilder.repos.ProductRepository;
|
||||
import group.goforward.battlbuilder.repos.spec.CatalogProductSpecifications;
|
||||
import group.goforward.battlbuilder.services.CatalogQueryService;
|
||||
import group.goforward.battlbuilder.services.ShortLinkService;
|
||||
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;
|
||||
@@ -24,11 +24,16 @@ public class CatalogQueryServiceImpl implements CatalogQueryService {
|
||||
|
||||
private final ProductRepository productRepository;
|
||||
private final ProductOfferRepository productOfferRepository;
|
||||
private final ShortLinkService shortLinkService;
|
||||
|
||||
public CatalogQueryServiceImpl(ProductRepository productRepository,
|
||||
ProductOfferRepository productOfferRepository) {
|
||||
public CatalogQueryServiceImpl(
|
||||
ProductRepository productRepository,
|
||||
ProductOfferRepository productOfferRepository,
|
||||
ShortLinkService shortLinkService
|
||||
) {
|
||||
this.productRepository = productRepository;
|
||||
this.productOfferRepository = productOfferRepository;
|
||||
this.shortLinkService = shortLinkService;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -81,7 +86,11 @@ public class CatalogQueryServiceImpl implements CatalogQueryService {
|
||||
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);
|
||||
String buyShortUrl = best != null
|
||||
? shortLinkService.shortBuyUrlForOffer(best.getId(), buyUrl)
|
||||
: null;
|
||||
|
||||
return ProductMapper.toSummary(p, price, buyUrl, buyShortUrl);
|
||||
}).toList();
|
||||
|
||||
return new PageImpl<>(dtos, pageable, page.getTotalElements());
|
||||
@@ -111,8 +120,11 @@ public class CatalogQueryServiceImpl implements CatalogQueryService {
|
||||
ProductOffer best = bestOfferByProductId.get(id);
|
||||
BigDecimal price = best != null ? best.getEffectivePrice() : null;
|
||||
String buyUrl = best != null ? best.getBuyUrl() : null;
|
||||
String buyShortUrl = best != null
|
||||
? shortLinkService.shortBuyUrlForOffer(best.getId(), buyUrl)
|
||||
: null;
|
||||
|
||||
out.add(ProductMapper.toSummary(p, price, buyUrl));
|
||||
out.add(ProductMapper.toSummary(p, price, buyUrl, buyShortUrl));
|
||||
}
|
||||
|
||||
return out;
|
||||
@@ -135,7 +147,6 @@ public class CatalogQueryServiceImpl implements CatalogQueryService {
|
||||
continue;
|
||||
}
|
||||
|
||||
// ---- ranking rules (in order) ----
|
||||
// 1) prefer in-stock
|
||||
boolean oStock = Boolean.TRUE.equals(o.getInStock());
|
||||
boolean cStock = Boolean.TRUE.equals(current.getInStock());
|
||||
@@ -182,14 +193,12 @@ public class CatalogQueryServiceImpl implements CatalogQueryService {
|
||||
|
||||
int page = pageable.getPageNumber();
|
||||
int requested = pageable.getPageSize();
|
||||
int size = Math.min(Math.max(requested, 1), 48); // ✅ hard cap
|
||||
int size = Math.min(Math.max(requested, 1), 48);
|
||||
|
||||
// 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"));
|
||||
@@ -198,9 +207,6 @@ public class CatalogQueryServiceImpl implements CatalogQueryService {
|
||||
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"));
|
||||
|
||||
@@ -5,38 +5,38 @@ 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.services.ShortLinkService;
|
||||
import group.goforward.battlbuilder.web.dto.ProductOfferDto;
|
||||
import group.goforward.battlbuilder.web.dto.ProductSummaryDto;
|
||||
import group.goforward.battlbuilder.web.mapper.ProductMapper;
|
||||
import group.goforward.battlbuilder.web.dto.catalog.ProductSort;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
import group.goforward.battlbuilder.web.mapper.ProductMapper;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageImpl;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
|
||||
import java.util.stream.Collectors;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.Collections;
|
||||
import java.util.Map;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
public class ProductQueryServiceImpl implements ProductQueryService {
|
||||
|
||||
private final ProductRepository productRepository;
|
||||
private final ProductOfferRepository productOfferRepository;
|
||||
private final ShortLinkService shortLinkService;
|
||||
|
||||
public ProductQueryServiceImpl(
|
||||
ProductRepository productRepository,
|
||||
ProductOfferRepository productOfferRepository
|
||||
ProductOfferRepository productOfferRepository,
|
||||
ShortLinkService shortLinkService
|
||||
) {
|
||||
this.productRepository = productRepository;
|
||||
this.productOfferRepository = productOfferRepository;
|
||||
this.shortLinkService = shortLinkService;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -58,7 +58,6 @@ public class ProductQueryServiceImpl implements ProductQueryService {
|
||||
|
||||
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()
|
||||
@@ -74,8 +73,11 @@ public class ProductQueryServiceImpl implements ProductQueryService {
|
||||
|
||||
BigDecimal price = bestOffer != null ? bestOffer.getEffectivePrice() : null;
|
||||
String buyUrl = bestOffer != null ? bestOffer.getBuyUrl() : null;
|
||||
String buyShortUrl = bestOffer != null
|
||||
? shortLinkService.shortBuyUrlForOffer(bestOffer.getId(), buyUrl)
|
||||
: null;
|
||||
|
||||
return ProductMapper.toSummary(p, price, buyUrl);
|
||||
return ProductMapper.toSummary(p, price, buyUrl, buyShortUrl);
|
||||
})
|
||||
.toList();
|
||||
}
|
||||
@@ -89,9 +91,6 @@ public class ProductQueryServiceImpl implements ProductQueryService {
|
||||
) {
|
||||
final boolean allPlatforms = platform != null && platform.equalsIgnoreCase("ALL");
|
||||
|
||||
// IMPORTANT: ignore Pageable sorting (because we are doing our own "best price" logic)
|
||||
// If the client accidentally passes ?sort=..., Spring Data will try ordering by a Product field.
|
||||
// We'll strip it to be safe.
|
||||
Pageable safePageable = pageable;
|
||||
if (pageable != null && pageable.getSort() != null && pageable.getSort().isSorted()) {
|
||||
safePageable = Pageable.ofSize(pageable.getPageSize()).withPage(pageable.getPageNumber());
|
||||
@@ -116,14 +115,12 @@ public class ProductQueryServiceImpl implements ProductQueryService {
|
||||
|
||||
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()));
|
||||
|
||||
// Build DTOs (same as before)
|
||||
List<ProductSummaryDto> dtos = products.stream()
|
||||
.map(p -> {
|
||||
List<ProductOffer> offersForProduct =
|
||||
@@ -133,12 +130,14 @@ public class ProductQueryServiceImpl implements ProductQueryService {
|
||||
|
||||
BigDecimal price = bestOffer != null ? bestOffer.getEffectivePrice() : null;
|
||||
String buyUrl = bestOffer != null ? bestOffer.getBuyUrl() : null;
|
||||
String buyShortUrl = bestOffer != null
|
||||
? shortLinkService.shortBuyUrlForOffer(bestOffer.getId(), buyUrl)
|
||||
: null;
|
||||
|
||||
return ProductMapper.toSummary(p, price, buyUrl);
|
||||
return ProductMapper.toSummary(p, price, buyUrl, buyShortUrl);
|
||||
})
|
||||
.toList();
|
||||
|
||||
// Phase 3 "server-side sort by price" (within the page for now)
|
||||
Comparator<ProductSummaryDto> byPriceNullLast =
|
||||
Comparator.comparing(ProductSummaryDto::getPrice, Comparator.nullsLast(Comparator.naturalOrder()));
|
||||
|
||||
@@ -151,13 +150,8 @@ public class ProductQueryServiceImpl implements ProductQueryService {
|
||||
return new PageImpl<>(dtos, safePageable, productPage.getTotalElements());
|
||||
}
|
||||
|
||||
//
|
||||
// Product Offers
|
||||
//
|
||||
|
||||
@Override
|
||||
public List<ProductOfferDto> getOffersForProduct(Integer productId) {
|
||||
// ✅ canonical repo method
|
||||
List<ProductOffer> offers = productOfferRepository.findByProduct_Id(productId);
|
||||
|
||||
return offers.stream()
|
||||
@@ -180,20 +174,21 @@ public class ProductQueryServiceImpl implements ProductQueryService {
|
||||
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;
|
||||
String buyShortUrl = bestOffer != null
|
||||
? shortLinkService.shortBuyUrlForOffer(bestOffer.getId(), buyUrl)
|
||||
: null;
|
||||
|
||||
return ProductMapper.toSummary(product, price, buyUrl);
|
||||
return ProductMapper.toSummary(product, price, buyUrl, buyShortUrl);
|
||||
}
|
||||
|
||||
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))
|
||||
|
||||
@@ -12,6 +12,7 @@ public class ProductSummaryDto {
|
||||
private String categoryKey;
|
||||
private BigDecimal price;
|
||||
private String buyUrl;
|
||||
private String buyShortUrl;
|
||||
private String imageUrl;
|
||||
|
||||
|
||||
@@ -79,6 +80,9 @@ public class ProductSummaryDto {
|
||||
this.buyUrl = buyUrl;
|
||||
}
|
||||
|
||||
public String getBuyShortUrl() { return buyShortUrl; }
|
||||
public void setBuyShortUrl(String buyShortUrl) { this.buyShortUrl = buyShortUrl; }
|
||||
|
||||
public String getImageUrl() { return imageUrl; }
|
||||
|
||||
public void setImageUrl(String imageUrl) { this.imageUrl = imageUrl; }
|
||||
|
||||
@@ -7,10 +7,9 @@ import java.math.BigDecimal;
|
||||
|
||||
public class ProductMapper {
|
||||
|
||||
public static ProductSummaryDto toSummary(Product product, BigDecimal price, String buyUrl) {
|
||||
public static ProductSummaryDto toSummary(Product product, BigDecimal price, String buyUrl, String buyShortUrl) {
|
||||
ProductSummaryDto dto = new ProductSummaryDto();
|
||||
|
||||
// Product ID -> String
|
||||
dto.setId(String.valueOf(product.getId()));
|
||||
|
||||
dto.setName(product.getName());
|
||||
@@ -18,17 +17,14 @@ public class ProductMapper {
|
||||
dto.setPlatform(product.getPlatform());
|
||||
dto.setPartRole(product.getPartRole());
|
||||
|
||||
// Use rawCategoryKey from the Product entity
|
||||
dto.setCategoryKey(product.getRawCategoryKey());
|
||||
|
||||
// Price + buy URL from offers
|
||||
dto.setPrice(price);
|
||||
dto.setBuyUrl(buyUrl);
|
||||
dto.setBuyUrl(buyUrl); // keep raw
|
||||
dto.setBuyShortUrl(buyShortUrl); // new
|
||||
|
||||
// product image
|
||||
dto.setImageUrl(product.getMainImageUrl());
|
||||
|
||||
|
||||
return dto;
|
||||
}
|
||||
}
|
||||
3
src/main/resources/application-dev.yml
Normal file
3
src/main/resources/application-dev.yml
Normal file
@@ -0,0 +1,3 @@
|
||||
shortlinks:
|
||||
enabled: true
|
||||
publicBaseUrl: "http://localhost:8080"
|
||||
3
src/main/resources/application-prod.yml
Normal file
3
src/main/resources/application-prod.yml
Normal file
@@ -0,0 +1,3 @@
|
||||
shortlinks:
|
||||
enabled: true
|
||||
publicBaseUrl: "https://battl.build"
|
||||
Reference in New Issue
Block a user