short links

This commit is contained in:
2026-01-06 05:20:40 -05:00
parent 37327ee9bf
commit e96bc24bd2
14 changed files with 341 additions and 47 deletions

View File

@@ -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"

View File

@@ -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
// ----------------------------

View File

@@ -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;
}
}

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View File

@@ -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; }
}

View File

@@ -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);
}

View File

@@ -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();
}
}

View File

@@ -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"));

View File

@@ -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))

View File

@@ -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; }

View File

@@ -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;
}
}

View File

@@ -0,0 +1,3 @@
shortlinks:
enabled: true
publicBaseUrl: "http://localhost:8080"

View File

@@ -0,0 +1,3 @@
shortlinks:
enabled: true
publicBaseUrl: "https://battl.build"