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;
|
package group.goforward.battlbuilder;
|
||||||
|
|
||||||
|
import group.goforward.battlbuilder.config.ShortLinksProperties;
|
||||||
import org.springframework.boot.SpringApplication;
|
import org.springframework.boot.SpringApplication;
|
||||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
import org.springframework.boot.autoconfigure.domain.EntityScan;
|
import org.springframework.boot.autoconfigure.domain.EntityScan;
|
||||||
|
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||||
import org.springframework.cache.annotation.EnableCaching;
|
import org.springframework.cache.annotation.EnableCaching;
|
||||||
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
|
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
|
||||||
|
|
||||||
@SpringBootApplication
|
@SpringBootApplication
|
||||||
@EnableCaching
|
@EnableCaching
|
||||||
|
@EnableConfigurationProperties(ShortLinksProperties.class)
|
||||||
@EntityScan(basePackages = {
|
@EntityScan(basePackages = {
|
||||||
"group.goforward.battlbuilder.model",
|
"group.goforward.battlbuilder.model",
|
||||||
"group.goforward.battlbuilder.enrichment"
|
"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 group.goforward.battlbuilder.security.JwtAuthenticationFilter;
|
||||||
import org.springframework.context.annotation.Bean;
|
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()
|
||||||
.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
|
// 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.ProductRepository;
|
||||||
import group.goforward.battlbuilder.repos.spec.CatalogProductSpecifications;
|
import group.goforward.battlbuilder.repos.spec.CatalogProductSpecifications;
|
||||||
import group.goforward.battlbuilder.services.CatalogQueryService;
|
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.ProductSummaryDto;
|
||||||
import group.goforward.battlbuilder.web.dto.catalog.CatalogProductIdsRequest;
|
import group.goforward.battlbuilder.web.dto.catalog.CatalogProductIdsRequest;
|
||||||
import group.goforward.battlbuilder.web.mapper.ProductMapper;
|
import group.goforward.battlbuilder.web.mapper.ProductMapper;
|
||||||
|
|
||||||
import org.springframework.data.domain.*;
|
import org.springframework.data.domain.*;
|
||||||
import org.springframework.data.jpa.domain.Specification;
|
import org.springframework.data.jpa.domain.Specification;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
@@ -24,11 +24,16 @@ public class CatalogQueryServiceImpl implements CatalogQueryService {
|
|||||||
|
|
||||||
private final ProductRepository productRepository;
|
private final ProductRepository productRepository;
|
||||||
private final ProductOfferRepository productOfferRepository;
|
private final ProductOfferRepository productOfferRepository;
|
||||||
|
private final ShortLinkService shortLinkService;
|
||||||
|
|
||||||
public CatalogQueryServiceImpl(ProductRepository productRepository,
|
public CatalogQueryServiceImpl(
|
||||||
ProductOfferRepository productOfferRepository) {
|
ProductRepository productRepository,
|
||||||
|
ProductOfferRepository productOfferRepository,
|
||||||
|
ShortLinkService shortLinkService
|
||||||
|
) {
|
||||||
this.productRepository = productRepository;
|
this.productRepository = productRepository;
|
||||||
this.productOfferRepository = productOfferRepository;
|
this.productOfferRepository = productOfferRepository;
|
||||||
|
this.shortLinkService = shortLinkService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -81,7 +86,11 @@ public class CatalogQueryServiceImpl implements CatalogQueryService {
|
|||||||
ProductOffer best = bestOfferByProductId.get(p.getId());
|
ProductOffer best = bestOfferByProductId.get(p.getId());
|
||||||
BigDecimal price = best != null ? best.getEffectivePrice() : null;
|
BigDecimal price = best != null ? best.getEffectivePrice() : null;
|
||||||
String buyUrl = best != null ? best.getBuyUrl() : 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();
|
}).toList();
|
||||||
|
|
||||||
return new PageImpl<>(dtos, pageable, page.getTotalElements());
|
return new PageImpl<>(dtos, pageable, page.getTotalElements());
|
||||||
@@ -111,8 +120,11 @@ public class CatalogQueryServiceImpl implements CatalogQueryService {
|
|||||||
ProductOffer best = bestOfferByProductId.get(id);
|
ProductOffer best = bestOfferByProductId.get(id);
|
||||||
BigDecimal price = best != null ? best.getEffectivePrice() : null;
|
BigDecimal price = best != null ? best.getEffectivePrice() : null;
|
||||||
String buyUrl = best != null ? best.getBuyUrl() : 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;
|
return out;
|
||||||
@@ -135,7 +147,6 @@ public class CatalogQueryServiceImpl implements CatalogQueryService {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- ranking rules (in order) ----
|
|
||||||
// 1) prefer in-stock
|
// 1) prefer in-stock
|
||||||
boolean oStock = Boolean.TRUE.equals(o.getInStock());
|
boolean oStock = Boolean.TRUE.equals(o.getInStock());
|
||||||
boolean cStock = Boolean.TRUE.equals(current.getInStock());
|
boolean cStock = Boolean.TRUE.equals(current.getInStock());
|
||||||
@@ -182,14 +193,12 @@ public class CatalogQueryServiceImpl implements CatalogQueryService {
|
|||||||
|
|
||||||
int page = pageable.getPageNumber();
|
int page = pageable.getPageNumber();
|
||||||
int requested = pageable.getPageSize();
|
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()) {
|
if (pageable.getSort() == null || pageable.getSort().isUnsorted()) {
|
||||||
return PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "updatedAt"));
|
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);
|
Sort.Order first = pageable.getSort().stream().findFirst().orElse(null);
|
||||||
if (first == null) {
|
if (first == null) {
|
||||||
return PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "updatedAt"));
|
return PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "updatedAt"));
|
||||||
@@ -198,9 +207,6 @@ public class CatalogQueryServiceImpl implements CatalogQueryService {
|
|||||||
String prop = first.getProperty();
|
String prop = first.getProperty();
|
||||||
Sort.Direction dir = first.getDirection();
|
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) {
|
return switch (prop) {
|
||||||
case "updatedAt", "createdAt", "name" -> PageRequest.of(page, size, Sort.by(dir, prop));
|
case "updatedAt", "createdAt", "name" -> PageRequest.of(page, size, Sort.by(dir, prop));
|
||||||
default -> PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "updatedAt"));
|
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.ProductOfferRepository;
|
||||||
import group.goforward.battlbuilder.repos.ProductRepository;
|
import group.goforward.battlbuilder.repos.ProductRepository;
|
||||||
import group.goforward.battlbuilder.services.ProductQueryService;
|
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.ProductOfferDto;
|
||||||
import group.goforward.battlbuilder.web.dto.ProductSummaryDto;
|
import group.goforward.battlbuilder.web.dto.ProductSummaryDto;
|
||||||
import group.goforward.battlbuilder.web.mapper.ProductMapper;
|
|
||||||
import group.goforward.battlbuilder.web.dto.catalog.ProductSort;
|
import group.goforward.battlbuilder.web.dto.catalog.ProductSort;
|
||||||
|
import group.goforward.battlbuilder.web.mapper.ProductMapper;
|
||||||
import java.math.BigDecimal;
|
|
||||||
import java.util.*;
|
|
||||||
import java.util.stream.Collectors;
|
|
||||||
import org.springframework.data.domain.Page;
|
import org.springframework.data.domain.Page;
|
||||||
import org.springframework.data.domain.PageImpl;
|
import org.springframework.data.domain.PageImpl;
|
||||||
import org.springframework.data.domain.Pageable;
|
import org.springframework.data.domain.Pageable;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
import java.util.stream.Collectors;
|
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.Map;
|
import java.util.Comparator;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.ArrayList;
|
import java.util.Map;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class ProductQueryServiceImpl implements ProductQueryService {
|
public class ProductQueryServiceImpl implements ProductQueryService {
|
||||||
|
|
||||||
private final ProductRepository productRepository;
|
private final ProductRepository productRepository;
|
||||||
private final ProductOfferRepository productOfferRepository;
|
private final ProductOfferRepository productOfferRepository;
|
||||||
|
private final ShortLinkService shortLinkService;
|
||||||
|
|
||||||
public ProductQueryServiceImpl(
|
public ProductQueryServiceImpl(
|
||||||
ProductRepository productRepository,
|
ProductRepository productRepository,
|
||||||
ProductOfferRepository productOfferRepository
|
ProductOfferRepository productOfferRepository,
|
||||||
|
ShortLinkService shortLinkService
|
||||||
) {
|
) {
|
||||||
this.productRepository = productRepository;
|
this.productRepository = productRepository;
|
||||||
this.productOfferRepository = productOfferRepository;
|
this.productOfferRepository = productOfferRepository;
|
||||||
|
this.shortLinkService = shortLinkService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -58,7 +58,6 @@ public class ProductQueryServiceImpl implements ProductQueryService {
|
|||||||
|
|
||||||
List<Integer> productIds = products.stream().map(Product::getId).toList();
|
List<Integer> productIds = products.stream().map(Product::getId).toList();
|
||||||
|
|
||||||
// ✅ canonical repo method
|
|
||||||
List<ProductOffer> allOffers = productOfferRepository.findByProduct_IdIn(productIds);
|
List<ProductOffer> allOffers = productOfferRepository.findByProduct_IdIn(productIds);
|
||||||
|
|
||||||
Map<Integer, List<ProductOffer>> offersByProductId = allOffers.stream()
|
Map<Integer, List<ProductOffer>> offersByProductId = allOffers.stream()
|
||||||
@@ -74,8 +73,11 @@ public class ProductQueryServiceImpl implements ProductQueryService {
|
|||||||
|
|
||||||
BigDecimal price = bestOffer != null ? bestOffer.getEffectivePrice() : null;
|
BigDecimal price = bestOffer != null ? bestOffer.getEffectivePrice() : null;
|
||||||
String buyUrl = bestOffer != null ? bestOffer.getBuyUrl() : 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();
|
.toList();
|
||||||
}
|
}
|
||||||
@@ -89,9 +91,6 @@ public class ProductQueryServiceImpl implements ProductQueryService {
|
|||||||
) {
|
) {
|
||||||
final boolean allPlatforms = platform != null && platform.equalsIgnoreCase("ALL");
|
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;
|
Pageable safePageable = pageable;
|
||||||
if (pageable != null && pageable.getSort() != null && pageable.getSort().isSorted()) {
|
if (pageable != null && pageable.getSort() != null && pageable.getSort().isSorted()) {
|
||||||
safePageable = Pageable.ofSize(pageable.getPageSize()).withPage(pageable.getPageNumber());
|
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();
|
List<Integer> productIds = products.stream().map(Product::getId).toList();
|
||||||
|
|
||||||
// Only fetch offers for THIS PAGE of products
|
|
||||||
List<ProductOffer> allOffers = productOfferRepository.findByProduct_IdIn(productIds);
|
List<ProductOffer> allOffers = productOfferRepository.findByProduct_IdIn(productIds);
|
||||||
|
|
||||||
Map<Integer, List<ProductOffer>> offersByProductId = allOffers.stream()
|
Map<Integer, List<ProductOffer>> offersByProductId = allOffers.stream()
|
||||||
.filter(o -> o.getProduct() != null && o.getProduct().getId() != null)
|
.filter(o -> o.getProduct() != null && o.getProduct().getId() != null)
|
||||||
.collect(Collectors.groupingBy(o -> o.getProduct().getId()));
|
.collect(Collectors.groupingBy(o -> o.getProduct().getId()));
|
||||||
|
|
||||||
// Build DTOs (same as before)
|
|
||||||
List<ProductSummaryDto> dtos = products.stream()
|
List<ProductSummaryDto> dtos = products.stream()
|
||||||
.map(p -> {
|
.map(p -> {
|
||||||
List<ProductOffer> offersForProduct =
|
List<ProductOffer> offersForProduct =
|
||||||
@@ -133,12 +130,14 @@ public class ProductQueryServiceImpl implements ProductQueryService {
|
|||||||
|
|
||||||
BigDecimal price = bestOffer != null ? bestOffer.getEffectivePrice() : null;
|
BigDecimal price = bestOffer != null ? bestOffer.getEffectivePrice() : null;
|
||||||
String buyUrl = bestOffer != null ? bestOffer.getBuyUrl() : 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();
|
.toList();
|
||||||
|
|
||||||
// Phase 3 "server-side sort by price" (within the page for now)
|
|
||||||
Comparator<ProductSummaryDto> byPriceNullLast =
|
Comparator<ProductSummaryDto> byPriceNullLast =
|
||||||
Comparator.comparing(ProductSummaryDto::getPrice, Comparator.nullsLast(Comparator.naturalOrder()));
|
Comparator.comparing(ProductSummaryDto::getPrice, Comparator.nullsLast(Comparator.naturalOrder()));
|
||||||
|
|
||||||
@@ -151,13 +150,8 @@ public class ProductQueryServiceImpl implements ProductQueryService {
|
|||||||
return new PageImpl<>(dtos, safePageable, productPage.getTotalElements());
|
return new PageImpl<>(dtos, safePageable, productPage.getTotalElements());
|
||||||
}
|
}
|
||||||
|
|
||||||
//
|
|
||||||
// Product Offers
|
|
||||||
//
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public List<ProductOfferDto> getOffersForProduct(Integer productId) {
|
public List<ProductOfferDto> getOffersForProduct(Integer productId) {
|
||||||
// ✅ canonical repo method
|
|
||||||
List<ProductOffer> offers = productOfferRepository.findByProduct_Id(productId);
|
List<ProductOffer> offers = productOfferRepository.findByProduct_Id(productId);
|
||||||
|
|
||||||
return offers.stream()
|
return offers.stream()
|
||||||
@@ -180,20 +174,21 @@ public class ProductQueryServiceImpl implements ProductQueryService {
|
|||||||
Product product = productRepository.findById(productId).orElse(null);
|
Product product = productRepository.findById(productId).orElse(null);
|
||||||
if (product == null) return null;
|
if (product == null) return null;
|
||||||
|
|
||||||
// ✅ canonical repo method
|
|
||||||
List<ProductOffer> offers = productOfferRepository.findByProduct_Id(productId);
|
List<ProductOffer> offers = productOfferRepository.findByProduct_Id(productId);
|
||||||
ProductOffer bestOffer = pickBestOffer(offers);
|
ProductOffer bestOffer = pickBestOffer(offers);
|
||||||
|
|
||||||
BigDecimal price = bestOffer != null ? bestOffer.getEffectivePrice() : null;
|
BigDecimal price = bestOffer != null ? bestOffer.getEffectivePrice() : null;
|
||||||
String buyUrl = bestOffer != null ? bestOffer.getBuyUrl() : 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) {
|
private ProductOffer pickBestOffer(List<ProductOffer> offers) {
|
||||||
if (offers == null || offers.isEmpty()) return null;
|
if (offers == null || offers.isEmpty()) return null;
|
||||||
|
|
||||||
// MVP: lowest effective price wins. (Later: prefer in-stock, etc.)
|
|
||||||
return offers.stream()
|
return offers.stream()
|
||||||
.filter(o -> o.getEffectivePrice() != null)
|
.filter(o -> o.getEffectivePrice() != null)
|
||||||
.min(Comparator.comparing(ProductOffer::getEffectivePrice))
|
.min(Comparator.comparing(ProductOffer::getEffectivePrice))
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ public class ProductSummaryDto {
|
|||||||
private String categoryKey;
|
private String categoryKey;
|
||||||
private BigDecimal price;
|
private BigDecimal price;
|
||||||
private String buyUrl;
|
private String buyUrl;
|
||||||
|
private String buyShortUrl;
|
||||||
private String imageUrl;
|
private String imageUrl;
|
||||||
|
|
||||||
|
|
||||||
@@ -79,6 +80,9 @@ public class ProductSummaryDto {
|
|||||||
this.buyUrl = buyUrl;
|
this.buyUrl = buyUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String getBuyShortUrl() { return buyShortUrl; }
|
||||||
|
public void setBuyShortUrl(String buyShortUrl) { this.buyShortUrl = buyShortUrl; }
|
||||||
|
|
||||||
public String getImageUrl() { return imageUrl; }
|
public String getImageUrl() { return imageUrl; }
|
||||||
|
|
||||||
public void setImageUrl(String imageUrl) { this.imageUrl = imageUrl; }
|
public void setImageUrl(String imageUrl) { this.imageUrl = imageUrl; }
|
||||||
|
|||||||
@@ -7,10 +7,9 @@ import java.math.BigDecimal;
|
|||||||
|
|
||||||
public class ProductMapper {
|
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();
|
ProductSummaryDto dto = new ProductSummaryDto();
|
||||||
|
|
||||||
// Product ID -> String
|
|
||||||
dto.setId(String.valueOf(product.getId()));
|
dto.setId(String.valueOf(product.getId()));
|
||||||
|
|
||||||
dto.setName(product.getName());
|
dto.setName(product.getName());
|
||||||
@@ -18,17 +17,14 @@ public class ProductMapper {
|
|||||||
dto.setPlatform(product.getPlatform());
|
dto.setPlatform(product.getPlatform());
|
||||||
dto.setPartRole(product.getPartRole());
|
dto.setPartRole(product.getPartRole());
|
||||||
|
|
||||||
// Use rawCategoryKey from the Product entity
|
|
||||||
dto.setCategoryKey(product.getRawCategoryKey());
|
dto.setCategoryKey(product.getRawCategoryKey());
|
||||||
|
|
||||||
// Price + buy URL from offers
|
|
||||||
dto.setPrice(price);
|
dto.setPrice(price);
|
||||||
dto.setBuyUrl(buyUrl);
|
dto.setBuyUrl(buyUrl); // keep raw
|
||||||
|
dto.setBuyShortUrl(buyShortUrl); // new
|
||||||
|
|
||||||
// product image
|
|
||||||
dto.setImageUrl(product.getMainImageUrl());
|
dto.setImageUrl(product.getMainImageUrl());
|
||||||
|
|
||||||
|
|
||||||
return dto;
|
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